Procházet zdrojové kódy

Merge pull request #2192 from yeatse/animatedimageview-macos

Support `KFAnimatedImage` and `AnimatedImageView` on macOS
Wei Wang před 2 roky
rodič
revize
35ce4f8b64

+ 70 - 5
Demo/Demo/Kingfisher-macOS-Demo/Base.lproj/Main.storyboard

@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
+<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
     <dependencies>
         <deployment identifier="macosx"/>
-        <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14460.31"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
     </dependencies>
     <scenes>
@@ -708,7 +708,7 @@
                                 </scroller>
                             </scrollView>
                             <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yIr-uo-Quc">
-                                <rect key="frame" x="14" y="447" width="114" height="32"/>
+                                <rect key="frame" x="13" y="448" width="108" height="32"/>
                                 <buttonCell key="cell" type="push" title="Clear Cache" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="ebf-qp-Vwt">
                                     <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
                                     <font key="font" metaFont="system"/>
@@ -717,8 +717,18 @@
                                     <action selector="clearCachePressedWithSender:" target="XfG-lQ-9wD" id="lfl-RU-nX5"/>
                                 </connections>
                             </button>
+                            <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eLA-Ce-crP" userLabel="Heavy GIFs">
+                                <rect key="frame" x="259" y="448" width="103" height="32"/>
+                                <buttonCell key="cell" type="push" title="Heavy GIFs" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="YYz-yg-1MB">
+                                    <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
+                                    <font key="font" metaFont="system"/>
+                                </buttonCell>
+                                <connections>
+                                    <segue destination="19n-lE-hND" kind="show" id="NZO-24-0LI"/>
+                                </connections>
+                            </button>
                             <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Ejh-qu-qmy">
-                                <rect key="frame" x="524" y="447" width="82" height="32"/>
+                                <rect key="frame" x="531" y="448" width="76" height="32"/>
                                 <buttonCell key="cell" type="push" title="Reload" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="DhD-Tg-Bw3">
                                     <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
                                     <font key="font" metaFont="system"/>
@@ -727,16 +737,31 @@
                                     <action selector="reloadPressedWithSender:" target="XfG-lQ-9wD" id="k24-Wi-NRd"/>
                                 </connections>
                             </button>
+                            <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="0Du-le-fYN">
+                                <rect key="frame" x="417" y="448" width="78" height="32"/>
+                                <buttonCell key="cell" type="push" title="SwiftUI" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="wIi-ia-bgi">
+                                    <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
+                                    <font key="font" metaFont="system"/>
+                                </buttonCell>
+                                <connections>
+                                    <segue destination="ei1-kq-tvV" kind="show" id="Ub0-4z-BqC"/>
+                                </connections>
+                            </button>
                         </subviews>
                         <constraints>
                             <constraint firstAttribute="trailing" secondItem="MlO-xV-qug" secondAttribute="trailing" id="18w-Qc-Jr6"/>
                             <constraint firstItem="yIr-uo-Quc" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="35L-th-6RE"/>
+                            <constraint firstItem="Ejh-qu-qmy" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="eLA-Ce-crP" secondAttribute="trailing" constant="12" symbolic="YES" id="7TA-fc-yjU"/>
                             <constraint firstItem="yIr-uo-Quc" firstAttribute="leading" secondItem="m2S-Jp-Qdl" secondAttribute="leading" constant="20" id="AhA-g2-Cms"/>
-                            <constraint firstItem="Ejh-qu-qmy" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="yIr-uo-Quc" secondAttribute="trailing" constant="12" symbolic="YES" id="BRb-HY-Dny"/>
+                            <constraint firstItem="eLA-Ce-crP" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="yIr-uo-Quc" secondAttribute="trailing" constant="12" symbolic="YES" id="BRb-HY-Dny"/>
                             <constraint firstItem="MlO-xV-qug" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="30" id="Bzu-9v-r7G"/>
+                            <constraint firstItem="0Du-le-fYN" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="DMs-p7-JWV"/>
                             <constraint firstAttribute="bottom" secondItem="MlO-xV-qug" secondAttribute="bottom" id="HY0-vM-k0l"/>
                             <constraint firstItem="MlO-xV-qug" firstAttribute="leading" secondItem="m2S-Jp-Qdl" secondAttribute="leading" id="Pp3-O7-2Bs"/>
+                            <constraint firstItem="eLA-Ce-crP" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="cJE-Zj-BEy"/>
                             <constraint firstAttribute="trailing" secondItem="Ejh-qu-qmy" secondAttribute="trailing" constant="20" id="eoW-Xb-6wq"/>
+                            <constraint firstItem="eLA-Ce-crP" firstAttribute="centerX" secondItem="m2S-Jp-Qdl" secondAttribute="centerX" id="xRm-9b-mgK"/>
+                            <constraint firstItem="Ejh-qu-qmy" firstAttribute="leading" secondItem="0Du-le-fYN" secondAttribute="trailing" constant="50" id="xc2-W0-bu0"/>
                             <constraint firstItem="Ejh-qu-qmy" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="xnX-II-7iN"/>
                         </constraints>
                     </view>
@@ -748,5 +773,45 @@
             </objects>
             <point key="canvasLocation" x="75" y="831"/>
         </scene>
+        <!--SwiftUI View Controller-->
+        <scene sceneID="Z7V-ea-dRX">
+            <objects>
+                <viewController id="ei1-kq-tvV" customClass="SwiftUIViewController" customModule="Kingfisher_macOS_Demo" customModuleProvider="target" sceneMemberID="viewController">
+                    <view key="view" id="JMh-S8-QlI">
+                        <rect key="frame" x="0.0" y="0.0" width="620" height="474"/>
+                        <autoresizingMask key="autoresizingMask"/>
+                    </view>
+                </viewController>
+                <customObject id="BB8-QC-got" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="75" y="1406"/>
+        </scene>
+        <!--Heavy View Controller-->
+        <scene sceneID="kQM-gs-M6P">
+            <objects>
+                <viewController id="19n-lE-hND" customClass="GIFHeavyViewController" customModule="Kingfisher_macOS_Demo" customModuleProvider="target" sceneMemberID="viewController">
+                    <view key="view" id="4Qc-Su-iB9">
+                        <rect key="frame" x="0.0" y="0.0" width="569" height="480"/>
+                        <autoresizingMask key="autoresizingMask"/>
+                        <subviews>
+                            <stackView distribution="fillEqually" orientation="vertical" alignment="leading" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="b04-pQ-0pR">
+                                <rect key="frame" x="0.0" y="0.0" width="569" height="480"/>
+                            </stackView>
+                        </subviews>
+                        <constraints>
+                            <constraint firstAttribute="bottom" secondItem="b04-pQ-0pR" secondAttribute="bottom" id="WP7-Ep-JhF"/>
+                            <constraint firstItem="b04-pQ-0pR" firstAttribute="leading" secondItem="4Qc-Su-iB9" secondAttribute="leading" id="t0Z-wK-nRD"/>
+                            <constraint firstAttribute="trailing" secondItem="b04-pQ-0pR" secondAttribute="trailing" id="yeI-o0-y8O"/>
+                            <constraint firstItem="b04-pQ-0pR" firstAttribute="top" secondItem="4Qc-Su-iB9" secondAttribute="top" id="yhl-hr-4Mw"/>
+                        </constraints>
+                    </view>
+                    <connections>
+                        <outlet property="stackView" destination="b04-pQ-0pR" id="nsI-xM-cZe"/>
+                    </connections>
+                </viewController>
+                <customObject id="Hmf-j6-h1n" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="766.5" y="831"/>
+        </scene>
     </scenes>
 </document>

+ 58 - 0
Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift

@@ -0,0 +1,58 @@
+//
+//  GIFHeavyViewController.swift
+//  Kingfisher
+//
+//  Created by yeatse on 2024/1/7.
+//
+//  Copyright (c) 2024 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+import Cocoa
+import Kingfisher
+
+class GIFHeavyViewController: NSViewController {
+    @IBOutlet weak var stackView: NSStackView!
+    
+    let imageViews = [
+        AnimatedImageView(),
+        AnimatedImageView(),
+        AnimatedImageView(),
+        AnimatedImageView(),
+    ]
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        let url = URL(string: "https://raw.githubusercontent.com/onevcat/Kingfisher-TestImages/master/DemoAppImage/GIF/GifHeavy.gif")
+        
+        for imageView in imageViews {
+            stackView.addArrangedSubview(imageView)
+            imageView.translatesAutoresizingMaskIntoConstraints = false
+            imageView.widthAnchor.constraint(equalTo: stackView.widthAnchor).isActive = true
+            imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+            imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+            imageView.imageScaling = .scaleProportionallyDown
+        }
+        stackView.layoutSubtreeIfNeeded()
+        for imageView in imageViews {
+            imageView.kf.setImage(with: url)
+        }
+    }
+}

+ 79 - 0
Demo/Demo/Kingfisher-macOS-Demo/SwiftUIViewController.swift

@@ -0,0 +1,79 @@
+//
+//  SwiftUIViewController.swift
+//  Kingfisher
+//
+//  Created by yeatse on 2024/1/8.
+//
+//  Copyright (c) 2024 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+import SwiftUI
+import Kingfisher
+
+@available(macOS 11, *)
+class SwiftUIViewController: NSHostingController<MainView> {
+    required init?(coder: NSCoder) {
+        super.init(coder: coder, rootView: MainView())
+    }
+}
+
+@available(macOS 11, *)
+struct MainView: View {
+    @State private var index = 1
+    
+    static let gifImageURLs: [URL] = {
+        let prefix = "https://raw.githubusercontent.com/onevcat/Kingfisher-TestImages/master/DemoAppImage/GIF"
+        return (1...3).map { URL(string: "\(prefix)/\($0).gif")! }
+    }()
+        
+    var url: URL {
+        MainView.gifImageURLs[index - 1]
+    }
+    
+    var body: some View {
+        VStack {
+            KFAnimatedImage(url)
+                .configure { view in
+                    view.framePreloadCount = 3
+                }
+                .cacheOriginalImage()
+                .onSuccess { r in
+                    print("suc: \(r)")
+                }
+                .onFailure { e in
+                    print("err: \(e)")
+                }
+                .placeholder { p in
+                    ProgressView(p)
+                }
+                .fade(duration: 1)
+                .forceTransition()
+                .aspectRatio(contentMode: .fill)
+                .frame(width: 300, height: 300)
+                .cornerRadius(20)
+                .shadow(radius: 5)
+                .frame(width: 320, height: 320)
+
+            Button(action: {
+                self.index = (self.index % 3) + 1
+            }) { Text("Next Image") }
+        }
+    }
+}

+ 8 - 0
Demo/Kingfisher-Demo.xcodeproj/project.pbxproj

@@ -13,6 +13,8 @@
 		277EAE9D2045B4D500547CD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 277EAE962045B4D500547CD3 /* Assets.xcassets */; };
 		277EAEA12045B52800547CD3 /* InterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277EAE9E2045B52800547CD3 /* InterfaceController.swift */; };
 		277EAEA32045B52800547CD3 /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277EAEA02045B52800547CD3 /* ExtensionDelegate.swift */; };
+		3887E1602B4AD7200062C53C /* GIFHeavyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887E15F2B4AD7200062C53C /* GIFHeavyViewController.swift */; };
+		3887E17B2B4BC04B0062C53C /* SwiftUIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887E17A2B4BC04B0062C53C /* SwiftUIViewController.swift */; };
 		4B120CA726B91BB70060B092 /* TransitionViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B120CA626B91BB70060B092 /* TransitionViewDemo.swift */; };
 		4B1C7A3D21A256E300CE9D31 /* InfinityCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1C7A3C21A256E300CE9D31 /* InfinityCollectionViewController.swift */; };
 		4B4307A51D87E6A700ED2DA9 /* loader.gif in Resources */ = {isa = PBXBuildFile; fileRef = 4B7742461D87E42E0077024E /* loader.gif */; };
@@ -161,6 +163,8 @@
 		277EAE9E2045B52800547CD3 /* InterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InterfaceController.swift; sourceTree = "<group>"; };
 		277EAE9F2045B52800547CD3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		277EAEA02045B52800547CD3 /* ExtensionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = "<group>"; };
+		3887E15F2B4AD7200062C53C /* GIFHeavyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFHeavyViewController.swift; sourceTree = "<group>"; };
+		3887E17A2B4BC04B0062C53C /* SwiftUIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIViewController.swift; sourceTree = "<group>"; };
 		4B120CA626B91BB70060B092 /* TransitionViewDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionViewDemo.swift; sourceTree = "<group>"; };
 		4B1C7A3C21A256E300CE9D31 /* InfinityCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfinityCollectionViewController.swift; sourceTree = "<group>"; };
 		4B2944551C3D03880088C3E7 /* Kingfisher-macOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-macOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -318,6 +322,8 @@
 				4BCCF33A1D5B02F8003387C2 /* Cell.xib */,
 				4BCCF33B1D5B02F8003387C2 /* Info.plist */,
 				4BCCF33C1D5B02F8003387C2 /* ViewController.swift */,
+				3887E15F2B4AD7200062C53C /* GIFHeavyViewController.swift */,
+				3887E17A2B4BC04B0062C53C /* SwiftUIViewController.swift */,
 			);
 			name = "Kingfisher-macOS-Demo";
 			path = "Demo/Kingfisher-macOS-Demo";
@@ -655,7 +661,9 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				3887E17B2B4BC04B0062C53C /* SwiftUIViewController.swift in Sources */,
 				4BCCF3421D5B02F8003387C2 /* ViewController.swift in Sources */,
+				3887E1602B4AD7200062C53C /* GIFHeavyViewController.swift in Sources */,
 				4BCCF33D1D5B02F8003387C2 /* AppDelegate.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 4 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -9,6 +9,7 @@
 /* Begin PBXBuildFile section */
 		07292245263B02F00089E810 /* KFAnimatedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07292244263B02F00089E810 /* KFAnimatedImage.swift */; };
 		22FDCE0E2700078B0044D11E /* CPListItem+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FDCE0D2700078B0044D11E /* CPListItem+Kingfisher.swift */; };
+		388F37382B4D9CDB0089705C /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388F37372B4D9CDB0089705C /* DisplayLink.swift */; };
 		4B10480D216F157000300C61 /* ImageDataProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B10480C216F157000300C61 /* ImageDataProcessor.swift */; };
 		4B46CC5F217449C600D90C4A /* MemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B46CC5E217449C600D90C4A /* MemoryStorage.swift */; };
 		4B46CC64217449E000D90C4A /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B46CC63217449E000D90C4A /* Storage.swift */; };
@@ -149,6 +150,7 @@
 		07292244263B02F00089E810 /* KFAnimatedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFAnimatedImage.swift; sourceTree = "<group>"; };
 		185218B51CC07F8300BD58DE /* NSButtonExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSButtonExtensionTests.swift; sourceTree = "<group>"; };
 		22FDCE0D2700078B0044D11E /* CPListItem+Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CPListItem+Kingfisher.swift"; sourceTree = "<group>"; };
+		388F37372B4D9CDB0089705C /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
 		4B10480C216F157000300C61 /* ImageDataProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataProcessor.swift; sourceTree = "<group>"; };
 		4B164ACE1B8D554200768EC6 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; };
 		4B3E714D1B01FEB200F5AAED /* WatchKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchKit.framework; path = System/Library/Frameworks/WatchKit.framework; sourceTree = SDKROOT; };
@@ -438,6 +440,7 @@
 				D1839844216E333E003927D3 /* Delegate.swift */,
 				4B8351CB217084660081EED8 /* Runtime.swift */,
 				D1BA781C2174D07800C69D7B /* CallbackQueue.swift */,
+				388F37372B4D9CDB0089705C /* DisplayLink.swift */,
 			);
 			path = Utility;
 			sourceTree = "<group>";
@@ -847,6 +850,7 @@
 				D12AB714215D2BB50013BA68 /* ImageCache.swift in Sources */,
 				4B88CEB02646C056009EBB41 /* KFImageProtocol.swift in Sources */,
 				D12AB6D0215D2BB50013BA68 /* ImagePrefetcher.swift in Sources */,
+				388F37382B4D9CDB0089705C /* DisplayLink.swift in Sources */,
 				D12AB6F4215D2BB50013BA68 /* ImageView+Kingfisher.swift in Sources */,
 				D12AB6FC215D2BB50013BA68 /* UIButton+Kingfisher.swift in Sources */,
 				D12AB6E8215D2BB50013BA68 /* GIFAnimatedImage.swift in Sources */,

+ 3 - 2
Sources/Image/Image.swift

@@ -32,7 +32,6 @@ private var durationKey: Void?
 #else
 import UIKit
 import MobileCoreServices
-private var imageSourceKey: Void?
 #endif
 
 #if !os(watchOS)
@@ -48,6 +47,7 @@ import UniformTypeIdentifiers
 
 private var animatedImageDataKey: Void?
 private var imageFrameCountKey: Void?
+private var imageSourceKey: Void?
 
 // MARK: - Image Properties
 extension KingfisherWrapper where Base: KFCrossPlatformImage {
@@ -101,13 +101,13 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
             return frameSource.imageSource
         }
     }
+    #endif
     
     /// The custom frame source of current image.
     public private(set) var frameSource: ImageFrameSource? {
         get { return getAssociatedObject(base, &imageSourceKey) }
         set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) }
     }
-    #endif
 
     // Bitmap memory cost with bytes.
     var cost: Int {
@@ -331,6 +331,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
         }
         image?.kf.animatedImageData = source.data
         image?.kf.imageFrameCount = source.frameCount
+        image?.kf.frameSource = source
         return image
         #else
         

+ 0 - 4
Sources/Image/ImageDrawing.swift

@@ -542,11 +542,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
     ///         For any non-CG-based image or animated image, `base` itself is returned.
     public func decoded(on context: CGContext) -> KFCrossPlatformImage {
         // Prevent animated image (GIF) losing it's images
-        #if os(iOS) || os(visionOS)
         if frameSource != nil { return base }
-        #else
-        if images != nil { return base }
-        #endif
 
         guard let refImage = cgImage,
               let decodedRefImage = refImage.decoded(on: context, scale: scale) else

+ 1 - 1
Sources/SwiftUI/ImageContext.swift

@@ -91,7 +91,7 @@ extension KFImage.Context: Hashable {
     }
 }
 
-#if canImport(UIKit) && !os(watchOS)
+#if !os(watchOS)
 @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
 extension KFAnimatedImage {
     public typealias Context = KFImage.Context

+ 30 - 4
Sources/SwiftUI/KFAnimatedImage.swift

@@ -24,7 +24,7 @@
 //  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 //  THE SOFTWARE.
 
-#if canImport(SwiftUI) && canImport(Combine) && canImport(UIKit) && !os(watchOS)
+#if canImport(SwiftUI) && canImport(Combine) && !os(watchOS)
 import SwiftUI
 import Combine
 
@@ -47,9 +47,17 @@ public struct KFAnimatedImage: KFImageProtocol {
     }
 }
 
+#if os(macOS)
+@available(macOS 11.0, *)
+typealias KFCrossPlatformViewRepresentable = NSViewRepresentable
+#else
+@available(iOS 14.0, tvOS 14.0, watchOS 7.0, *)
+typealias KFCrossPlatformViewRepresentable = UIViewRepresentable
+#endif
+
 /// A wrapped `UIViewRepresentable` of `AnimatedImageView`
 @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
-public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldingView {
+public struct KFAnimatedImageViewRepresenter: KFCrossPlatformViewRepresentable, KFImageHoldingView {
     public typealias RenderingView = AnimatedImageView
     public static func created(from image: KFCrossPlatformImage?, context: KFImage.Context<Self>) -> KFAnimatedImageViewRepresenter {
         KFAnimatedImageViewRepresenter(image: image, context: context)
@@ -58,7 +66,25 @@ public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldin
     var image: KFCrossPlatformImage?
     let context: KFImage.Context<KFAnimatedImageViewRepresenter>
     
+    #if os(macOS)
+    public func makeNSView(context: Context) -> AnimatedImageView {
+        return makeImageView()
+    }
+    
+    public func updateNSView(_ nsView: AnimatedImageView, context: Context) {
+        updateImageView(nsView)
+    }
+    #else
     public func makeUIView(context: Context) -> AnimatedImageView {
+        return makeImageView()
+    }
+    
+    public func updateUIView(_ uiView: AnimatedImageView, context: Context) {
+        updateImageView(uiView)
+    }
+    #endif
+    
+    private func makeImageView() -> AnimatedImageView {
         let view = AnimatedImageView()
         
         self.context.renderConfigurations.forEach { $0(view) }
@@ -71,8 +97,8 @@ public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldin
         return view
     }
     
-    public func updateUIView(_ uiView: AnimatedImageView, context: Context) {
-        uiView.image = image
+    private func updateImageView(_ imageView: AnimatedImageView) {
+        imageView.image = image
     }
 }
 

+ 163 - 0
Sources/Utility/DisplayLink.swift

@@ -0,0 +1,163 @@
+//
+//  DisplayLink.swift
+//  Kingfisher
+//
+//  Created by yeatse on 2024/1/9.
+//
+//  Copyright (c) 2024 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+#if !os(watchOS)
+#if canImport(UIKit)
+import UIKit
+#else
+import AppKit
+import CoreVideo
+#endif
+
+protocol DisplayLinkCompatible: AnyObject {
+    var isPaused: Bool { get set }
+    
+    var preferredFramesPerSecond: NSInteger { get }
+    var timestamp: CFTimeInterval { get }
+    var duration: CFTimeInterval { get }
+    
+    func add(to runLoop: RunLoop, forMode mode: RunLoop.Mode)
+    func remove(from runLoop: RunLoop, forMode mode: RunLoop.Mode)
+    
+    func invalidate()
+}
+
+#if !os(macOS)
+extension UIView {
+    func compatibleDisplayLink(target: Any, selector: Selector) -> DisplayLinkCompatible {
+        return CADisplayLink(target: target, selector: selector)
+    }
+}
+
+extension CADisplayLink: DisplayLinkCompatible {}
+
+#else
+extension NSView {
+    func compatibleDisplayLink(target: Any, selector: Selector) -> DisplayLinkCompatible {
+#if swift(>=5.9) // macOS 14 SDK is included in Xcode 15, which comes with swift 5.9. Add this check to make old compilers happy.
+        if #available(macOS 14.0, *) {
+            return displayLink(target: target, selector: selector)
+        } else {
+            return DisplayLink(target: target, selector: selector)
+        }
+#else
+        return DisplayLink(target: target, selector: selector)
+#endif
+    }
+}
+
+#if swift(>=5.9)
+@available(macOS 14.0, *)
+extension CADisplayLink: DisplayLinkCompatible {
+    var preferredFramesPerSecond: NSInteger { return 0 }
+}
+#endif
+
+class DisplayLink: DisplayLinkCompatible {
+    private var link: CVDisplayLink?
+    private var target: Any?
+    private var selector: Selector?
+    
+    private var schedulers: [RunLoop: [RunLoop.Mode]] = [:]
+    
+    init(target: Any, selector: Selector) {
+        self.target = target
+        self.selector = selector
+        CVDisplayLinkCreateWithActiveCGDisplays(&link)
+        if let link = link {
+            CVDisplayLinkSetOutputHandler(link, displayLinkCallback(_:inNow:inOutputTime:flagsIn:flagsOut:))
+        }
+    }
+    
+    deinit {
+        self.invalidate()
+    }
+    
+    private func displayLinkCallback(_ link: CVDisplayLink,
+                                     inNow: UnsafePointer<CVTimeStamp>,
+                                     inOutputTime: UnsafePointer<CVTimeStamp>,
+                                     flagsIn: CVOptionFlags,
+                                     flagsOut: UnsafeMutablePointer<CVOptionFlags>) -> CVReturn
+    {
+        let outputTime = inOutputTime.pointee
+        DispatchQueue.main.async {
+            guard let selector = self.selector, let target = self.target else { return }
+            if outputTime.videoTimeScale != 0 {
+                self.duration = CFTimeInterval(Double(outputTime.videoRefreshPeriod) / Double(outputTime.videoTimeScale))
+            }
+            if self.timestamp != 0 {
+                for scheduler in self.schedulers {
+                    scheduler.key.perform(selector, target: target, argument: nil, order: 0, modes: scheduler.value)
+                }
+            }
+            self.timestamp = CFTimeInterval(Double(outputTime.hostTime) / 1_000_000_000)
+        }
+        return kCVReturnSuccess
+    }
+    
+    var isPaused: Bool = true {
+        didSet {
+            guard let link = link else { return }
+            if isPaused {
+                if CVDisplayLinkIsRunning(link) {
+                    CVDisplayLinkStop(link)
+                }
+            } else {
+                if !CVDisplayLinkIsRunning(link) {
+                    CVDisplayLinkStart(link)
+                }
+            }
+        }
+    }
+    
+    var preferredFramesPerSecond: NSInteger = 0
+    var timestamp: CFTimeInterval = 0
+    var duration: CFTimeInterval = 0
+    
+    func add(to runLoop: RunLoop, forMode mode: RunLoop.Mode) {
+        assert(runLoop == .main)
+        schedulers[runLoop, default: []].append(mode)
+    }
+    
+    func remove(from runLoop: RunLoop, forMode mode: RunLoop.Mode) {
+        schedulers[runLoop]?.removeAll { $0 == mode }
+        if let modes = schedulers[runLoop], modes.isEmpty {
+            schedulers.removeValue(forKey: runLoop)
+        }
+    }
+    
+    func invalidate() {
+        schedulers = [:]
+        isPaused = true
+        target = nil
+        selector = nil
+        if let link = link {
+            CVDisplayLinkSetOutputHandler(link) { _, _, _, _, _ in kCVReturnSuccess }
+        }
+    }
+}
+#endif
+#endif

+ 145 - 15
Sources/Views/AnimatedImageView.swift

@@ -35,6 +35,11 @@
 #if canImport(UIKit)
 import UIKit
 import ImageIO
+typealias KFCrossPlatformContentMode = UIView.ContentMode
+#elseif canImport(AppKit)
+import AppKit
+typealias KFCrossPlatformContentMode = NSImageScaling
+#endif
 
 /// Protocol of `AnimatedImageView`.
 public protocol AnimatedImageViewDelegate: AnyObject {
@@ -67,7 +72,7 @@ let KFRunLoopModeCommon = RunLoop.Mode.common
 ///
 /// Kingfisher supports setting GIF animated data to either `UIImageView` and `AnimatedImageView` out of box. So
 /// it would be fairly easy to switch between them.
-open class AnimatedImageView: UIImageView {
+open class AnimatedImageView: KFCrossPlatformImageView {
     /// Proxy object for preventing a reference cycle between the `CADDisplayLink` and `AnimatedImageView`.
     class TargetProxy {
         private weak var target: AnimatedImageView?
@@ -141,8 +146,13 @@ open class AnimatedImageView: UIImageView {
         didSet {
             if oldValue != repeatCount {
                 reset()
+                #if os(macOS)
+                needsDisplay = true
+                layer?.setNeedsDisplay()
+                #else
                 setNeedsDisplay()
                 layer.setNeedsDisplay()
+                #endif
             }
         }
     }
@@ -163,9 +173,9 @@ open class AnimatedImageView: UIImageView {
     private var isDisplayLinkInitialized: Bool = false
     
     // A display link that keeps calling the `updateFrame` method on every screen refresh.
-    private lazy var displayLink: CADisplayLink = {
+    private lazy var displayLink: DisplayLinkCompatible = {
         isDisplayLinkInitialized = true
-        let displayLink = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
+        let displayLink = self.compatibleDisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
         displayLink.add(to: .main, forMode: runLoopMode)
         displayLink.isPaused = true
         return displayLink
@@ -177,8 +187,13 @@ open class AnimatedImageView: UIImageView {
             if image != oldValue {
                 reset()
             }
+            #if os(macOS)
+            needsDisplay = true
+            layer?.setNeedsDisplay()
+            #else
             setNeedsDisplay()
             layer.setNeedsDisplay()
+            #endif
         }
     }
     
@@ -217,6 +232,94 @@ open class AnimatedImageView: UIImageView {
         }
     }
     
+#if os(macOS)
+    public override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        commonInit()
+    }
+    
+    public required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        commonInit()
+    }
+    
+    private func commonInit() {
+        super.animates = false
+        wantsLayer = true
+    }
+    
+    open override var animates: Bool {
+        get {
+            if isDisplayLinkInitialized {
+                return !displayLink.isPaused
+            } else {
+                return super.animates
+            }
+        }
+        set {
+            if newValue {
+                startAnimating()
+            } else {
+                stopAnimating()
+            }
+        }
+    }
+    
+    open func startAnimating() {
+        guard let animator = animator else { return }
+        guard !animator.isReachMaxRepeatCount else { return }
+
+        displayLink.isPaused = false
+    }
+    
+    open func stopAnimating() {
+        if isDisplayLinkInitialized {
+            displayLink.isPaused = true
+        }
+    }
+    
+    open override var wantsUpdateLayer: Bool {
+        return true
+    }
+    
+    open override func updateLayer() {
+        if let frame = animator?.currentFrameImage ?? currentFrame, let layer = layer {
+            layer.contents = frame.kf.cgImage
+            layer.contentsScale = frame.kf.scale
+            layer.contentsGravity = determineContentsGravity(for: frame)
+            currentFrame = frame
+        }
+    }
+    
+    private func determineContentsGravity(for image: NSImage) -> CALayerContentsGravity {
+        switch imageScaling {
+            case .scaleProportionallyDown:
+                if image.size.width > bounds.width || image.size.height > bounds.height {
+                    return .resizeAspect
+                } else {
+                    return .center
+                }
+            case .scaleProportionallyUpOrDown:
+                return .resizeAspect
+            case .scaleAxesIndependently:
+                return .resize
+            case .scaleNone:
+                return .center
+            default:
+                return .resizeAspect
+        }
+    }
+    
+    open override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        didMove()
+    }
+    
+    open override func viewDidMoveToSuperview() {
+        super.viewDidMoveToSuperview()
+        didMove()
+    }
+#else
     override open var isAnimating: Bool {
         if isDisplayLinkInitialized {
             return !displayLink.isPaused
@@ -255,6 +358,7 @@ open class AnimatedImageView: UIImageView {
         super.didMoveToSuperview()
         didMove()
     }
+#endif
 
     // This is for back compatibility that using regular `UIImageView` to show animated image.
     override func shouldPreloadAllAnimation() -> Bool {
@@ -264,19 +368,23 @@ open class AnimatedImageView: UIImageView {
     // Reset the animator.
     private func reset() {
         animator = nil
+        currentFrame = nil
         if let image = image, let frameSource = image.kf.frameSource {
             #if os(visionOS)
-            let targetSize = bounds.scaled(UITraitCollection.current.displayScale).size
+            let scale = UITraitCollection.current.displayScale
+            #elseif os(macOS)
+            let scale = image.recommendedLayerContentsScale(window?.backingScaleFactor ?? 0.0)
+            let contentMode = imageScaling
             #else
             var scale: CGFloat = 0
-            
             if #available(iOS 13.0, tvOS 13.0, *) {
                 scale = UITraitCollection.current.displayScale
             } else {
                 scale = UIScreen.main.scale
             }
-            let targetSize = bounds.scaled(scale).size
             #endif
+            currentFrame = image
+            let targetSize = bounds.scaled(scale).size
             let animator = Animator(
                 frameSource: frameSource,
                 contentMode: contentMode,
@@ -305,6 +413,11 @@ open class AnimatedImageView: UIImageView {
         }
     }
     
+    /// If the Animator cannot prepare the next frame in time, `animator.currentFrameImage` will return nil.
+    /// To prevent unexpected blinking in the ImageView, we maintain a cache of the currently displayed frame
+    /// to use as a fallback in such scenarios.
+    private var currentFrame: KFCrossPlatformImage?
+    
     /// Update the current frame with the displayLink duration.
     private func updateFrameIfNeeded() {
         guard let animator = animator else {
@@ -334,7 +447,11 @@ open class AnimatedImageView: UIImageView {
 
         animator.shouldChangeFrame(with: duration) { [weak self] hasNewFrame in
             if hasNewFrame {
+                #if os(macOS)
+                self?.layer?.setNeedsDisplay()
+                #else
                 self?.layer.setNeedsDisplay()
+                #endif
             }
         }
     }
@@ -356,7 +473,7 @@ extension AnimatedImageView {
     struct AnimatedFrame {
 
         // The image to display for this frame. Its value is nil when the frame is removed from the buffer.
-        let image: UIImage?
+        let image: KFCrossPlatformImage?
 
         // The duration that this frame should remain active.
         let duration: TimeInterval
@@ -376,7 +493,7 @@ extension AnimatedImageView {
         //
         // - parameter image: An optional `UIImage` instance to be assigned to the new frame.
         // - returns: An `AnimatedFrame` instance.
-        func makeAnimatedFrame(image: UIImage?) -> AnimatedFrame {
+        func makeAnimatedFrame(image: KFCrossPlatformImage?) -> AnimatedFrame {
             return AnimatedFrame(image: image, duration: duration)
         }
     }
@@ -417,7 +534,7 @@ extension AnimatedImageView {
         var loopDuration: TimeInterval = 0
 
         /// The image of the current frame.
-        public var currentFrameImage: UIImage? {
+        public var currentFrameImage: KFCrossPlatformImage? {
             return frame(at: currentFrameIndex)
         }
 
@@ -461,7 +578,11 @@ extension AnimatedImageView {
             return maxFrameCount < frameCount - 1
         }
 
+        #if os(macOS)
+        var contentMode = NSImageScaling.scaleAxesIndependently
+        #else
         var contentMode = UIView.ContentMode.scaleToFill
+        #endif
 
         private lazy var preloadQueue: DispatchQueue = {
             return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue")
@@ -479,7 +600,7 @@ extension AnimatedImageView {
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         convenience init(imageSource source: CGImageSource,
-                         contentMode mode: UIView.ContentMode,
+                         contentMode mode: KFCrossPlatformContentMode,
                          size: CGSize,
                          imageSize: CGSize,
                          imageScale: CGFloat,
@@ -509,7 +630,7 @@ extension AnimatedImageView {
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         init(frameSource source: ImageFrameSource,
-             contentMode mode: UIView.ContentMode,
+             contentMode mode: KFCrossPlatformContentMode,
              size: CGSize,
              imageSize: CGSize,
              imageScale: CGFloat,
@@ -582,13 +703,24 @@ extension AnimatedImageView {
             animatedFrames.removeAll()
         }
 
-        private func loadFrame(at index: Int) -> UIImage? {
+        private func loadFrame(at index: Int) -> KFCrossPlatformImage? {
             let resize = needsPrescaling && size != .zero
             let maxSize = resize ? size : nil
             guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else {
                 return nil
             }
             
+            #if os(macOS)
+            let image = KFCrossPlatformImage(cgImage: cgImage, size: .zero)
+            if backgroundDecode {
+                guard let context = GraphicsContext.current(size: image.size, scale: image.kf.scale, inverting: false, cgImage: cgImage) else {
+                    return image
+                }
+                return image.kf.decoded(on: context)
+            } else {
+                return image
+            }
+            #else
             if #available(iOS 15, tvOS 15, *) {
                 // From iOS 15, a plain image loading causes iOS calling `-[_UIImageCGImageContent initWithCGImage:scale:]`
                 // in ImageIO, which holds the image ref on the creating thread.
@@ -612,6 +744,7 @@ extension AnimatedImageView {
                     return image
                 }
             }
+            #endif
         }
         
         private func updatePreloadedFrames() {
@@ -725,6 +858,3 @@ class SafeArray<Element> {
     }
 }
 #endif
-#endif
-
-