Просмотр исходного кода

support AnimatedImageView for macOS

Yang Chao 2 лет назад
Родитель
Сommit
a31882434c

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

@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-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>
     <dependencies>
         <deployment identifier="macosx"/>
         <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"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
     </dependencies>
     </dependencies>
     <scenes>
     <scenes>
@@ -708,7 +708,7 @@
                                 </scroller>
                                 </scroller>
                             </scrollView>
                             </scrollView>
                             <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="yIr-uo-Quc">
                             <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">
                                 <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"/>
                                     <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
                                     <font key="font" metaFont="system"/>
                                     <font key="font" metaFont="system"/>
@@ -717,8 +717,18 @@
                                     <action selector="clearCachePressedWithSender:" target="XfG-lQ-9wD" id="lfl-RU-nX5"/>
                                     <action selector="clearCachePressedWithSender:" target="XfG-lQ-9wD" id="lfl-RU-nX5"/>
                                 </connections>
                                 </connections>
                             </button>
                             </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">
                             <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">
                                 <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"/>
                                     <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
                                     <font key="font" metaFont="system"/>
                                     <font key="font" metaFont="system"/>
@@ -731,12 +741,15 @@
                         <constraints>
                         <constraints>
                             <constraint firstAttribute="trailing" secondItem="MlO-xV-qug" secondAttribute="trailing" id="18w-Qc-Jr6"/>
                             <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="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="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="MlO-xV-qug" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="30" id="Bzu-9v-r7G"/>
                             <constraint firstAttribute="bottom" secondItem="MlO-xV-qug" secondAttribute="bottom" id="HY0-vM-k0l"/>
                             <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="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 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="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="xnX-II-7iN"/>
                             <constraint firstItem="Ejh-qu-qmy" firstAttribute="top" secondItem="m2S-Jp-Qdl" secondAttribute="top" constant="5" id="xnX-II-7iN"/>
                         </constraints>
                         </constraints>
                     </view>
                     </view>
@@ -748,5 +761,32 @@
             </objects>
             </objects>
             <point key="canvasLocation" x="75" y="831"/>
             <point key="canvasLocation" x="75" y="831"/>
         </scene>
         </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>
     </scenes>
 </document>
 </document>

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

@@ -0,0 +1,56 @@
+//
+//  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.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+            imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+            imageView.imageScaling = .scaleProportionallyDown
+        }
+        stackView.layoutSubtreeIfNeeded()
+        for imageView in imageViews {
+            imageView.kf.setImage(with: url)
+        }
+    }
+}

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

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

+ 3 - 2
Sources/Image/Image.swift

@@ -32,7 +32,6 @@ private var durationKey: Void?
 #else
 #else
 import UIKit
 import UIKit
 import MobileCoreServices
 import MobileCoreServices
-private var imageSourceKey: Void?
 #endif
 #endif
 
 
 #if !os(watchOS)
 #if !os(watchOS)
@@ -48,6 +47,7 @@ import UniformTypeIdentifiers
 
 
 private var animatedImageDataKey: Void?
 private var animatedImageDataKey: Void?
 private var imageFrameCountKey: Void?
 private var imageFrameCountKey: Void?
+private var imageSourceKey: Void?
 
 
 // MARK: - Image Properties
 // MARK: - Image Properties
 extension KingfisherWrapper where Base: KFCrossPlatformImage {
 extension KingfisherWrapper where Base: KFCrossPlatformImage {
@@ -101,13 +101,13 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
             return frameSource.imageSource
             return frameSource.imageSource
         }
         }
     }
     }
+    #endif
     
     
     /// The custom frame source of current image.
     /// The custom frame source of current image.
     public private(set) var frameSource: ImageFrameSource? {
     public private(set) var frameSource: ImageFrameSource? {
         get { return getAssociatedObject(base, &imageSourceKey) }
         get { return getAssociatedObject(base, &imageSourceKey) }
         set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) }
         set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) }
     }
     }
-    #endif
 
 
     // Bitmap memory cost with bytes.
     // Bitmap memory cost with bytes.
     var cost: Int {
     var cost: Int {
@@ -331,6 +331,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
         }
         }
         image?.kf.animatedImageData = source.data
         image?.kf.animatedImageData = source.data
         image?.kf.imageFrameCount = source.frameCount
         image?.kf.imageFrameCount = source.frameCount
+        image?.kf.frameSource = source
         return image
         return image
         #else
         #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.
     ///         For any non-CG-based image or animated image, `base` itself is returned.
     public func decoded(on context: CGContext) -> KFCrossPlatformImage {
     public func decoded(on context: CGContext) -> KFCrossPlatformImage {
         // Prevent animated image (GIF) losing it's images
         // Prevent animated image (GIF) losing it's images
-        #if os(iOS) || os(visionOS)
         if frameSource != nil { return base }
         if frameSource != nil { return base }
-        #else
-        if images != nil { return base }
-        #endif
 
 
         guard let refImage = cgImage,
         guard let refImage = cgImage,
               let decodedRefImage = refImage.decoded(on: context, scale: scale) else
               let decodedRefImage = refImage.decoded(on: context, scale: scale) else

+ 215 - 12
Sources/Views/AnimatedImageView.swift

@@ -35,6 +35,12 @@
 #if canImport(UIKit)
 #if canImport(UIKit)
 import UIKit
 import UIKit
 import ImageIO
 import ImageIO
+public typealias KFCrossPlatformContentMode = UIView.ContentMode
+#elseif canImport(AppKit)
+import AppKit
+import CoreVideo
+public typealias KFCrossPlatformContentMode = NSImageScaling
+#endif
 
 
 /// Protocol of `AnimatedImageView`.
 /// Protocol of `AnimatedImageView`.
 public protocol AnimatedImageViewDelegate: AnyObject {
 public protocol AnimatedImageViewDelegate: AnyObject {
@@ -67,7 +73,7 @@ let KFRunLoopModeCommon = RunLoop.Mode.common
 ///
 ///
 /// Kingfisher supports setting GIF animated data to either `UIImageView` and `AnimatedImageView` out of box. So
 /// Kingfisher supports setting GIF animated data to either `UIImageView` and `AnimatedImageView` out of box. So
 /// it would be fairly easy to switch between them.
 /// 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`.
     /// Proxy object for preventing a reference cycle between the `CADDisplayLink` and `AnimatedImageView`.
     class TargetProxy {
     class TargetProxy {
         private weak var target: AnimatedImageView?
         private weak var target: AnimatedImageView?
@@ -141,8 +147,13 @@ open class AnimatedImageView: UIImageView {
         didSet {
         didSet {
             if oldValue != repeatCount {
             if oldValue != repeatCount {
                 reset()
                 reset()
+                #if os(macOS)
+                needsDisplay = true
+                layer?.setNeedsDisplay()
+                #else
                 setNeedsDisplay()
                 setNeedsDisplay()
                 layer.setNeedsDisplay()
                 layer.setNeedsDisplay()
+                #endif
             }
             }
         }
         }
     }
     }
@@ -162,6 +173,101 @@ open class AnimatedImageView: UIImageView {
     // A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy.
     // A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy.
     private var isDisplayLinkInitialized: Bool = false
     private var isDisplayLinkInitialized: Bool = false
     
     
+#if os(macOS)
+    class DisplayLink {
+        private let link: UnsafeMutablePointer<CVDisplayLink?>
+        private var target: Any?
+        private var selector: Selector?
+        
+        private var schedulers: [RunLoop: [RunLoop.Mode]] = [:]
+        
+        init(target: Any, selector: Selector) {
+            link = UnsafeMutablePointer<CVDisplayLink?>.allocate(capacity: 1)
+            self.target = target
+            self.selector = selector
+            CVDisplayLinkCreateWithActiveCGDisplays(link)
+            if let link = link.pointee {
+                CVDisplayLinkSetOutputHandler(link, displayLinkCallback(_:inNow:inOutputTime:flagsIn:flagsOut:))
+            }
+        }
+        
+        deinit {
+            self.invalidate()
+            link.deallocate()
+        }
+        
+        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.pointee 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.pointee {
+                CVDisplayLinkSetOutputHandler(link) { _, _, _, _, _ in kCVReturnSuccess }
+            }
+        }
+    }
+    // A display link that keeps calling the `updateFrame` method on every screen refresh.
+    private lazy var displayLink: DisplayLink = {
+        isDisplayLinkInitialized = true
+        let displayLink = DisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
+        displayLink.add(to: .main, forMode: runLoopMode)
+        displayLink.isPaused = true
+        return displayLink
+    }()
+#else
     // A display link that keeps calling the `updateFrame` method on every screen refresh.
     // A display link that keeps calling the `updateFrame` method on every screen refresh.
     private lazy var displayLink: CADisplayLink = {
     private lazy var displayLink: CADisplayLink = {
         isDisplayLinkInitialized = true
         isDisplayLinkInitialized = true
@@ -170,6 +276,7 @@ open class AnimatedImageView: UIImageView {
         displayLink.isPaused = true
         displayLink.isPaused = true
         return displayLink
         return displayLink
     }()
     }()
+#endif
     
     
     // MARK: - Override
     // MARK: - Override
     override open var image: KFCrossPlatformImage? {
     override open var image: KFCrossPlatformImage? {
@@ -177,8 +284,13 @@ open class AnimatedImageView: UIImageView {
             if image != oldValue {
             if image != oldValue {
                 reset()
                 reset()
             }
             }
+            #if os(macOS)
+            needsDisplay = true
+            layer?.setNeedsDisplay()
+            #else
             setNeedsDisplay()
             setNeedsDisplay()
             layer.setNeedsDisplay()
             layer.setNeedsDisplay()
+            #endif
         }
         }
     }
     }
     
     
@@ -217,6 +329,72 @@ 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
+    }
+    
+    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 {
+            layer?.contents = frame.kf.cgImage
+            currentFrame = frame
+        }
+    }
+    
+    open override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        didMove()
+    }
+    
+    open override func viewDidMoveToSuperview() {
+        super.viewDidMoveToSuperview()
+        didMove()
+    }
+#else
     override open var isAnimating: Bool {
     override open var isAnimating: Bool {
         if isDisplayLinkInitialized {
         if isDisplayLinkInitialized {
             return !displayLink.isPaused
             return !displayLink.isPaused
@@ -255,6 +433,7 @@ open class AnimatedImageView: UIImageView {
         super.didMoveToSuperview()
         super.didMoveToSuperview()
         didMove()
         didMove()
     }
     }
+#endif
 
 
     // This is for back compatibility that using regular `UIImageView` to show animated image.
     // This is for back compatibility that using regular `UIImageView` to show animated image.
     override func shouldPreloadAllAnimation() -> Bool {
     override func shouldPreloadAllAnimation() -> Bool {
@@ -264,19 +443,23 @@ open class AnimatedImageView: UIImageView {
     // Reset the animator.
     // Reset the animator.
     private func reset() {
     private func reset() {
         animator = nil
         animator = nil
+        currentFrame = nil
         if let image = image, let frameSource = image.kf.frameSource {
         if let image = image, let frameSource = image.kf.frameSource {
             #if os(visionOS)
             #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
             #else
             var scale: CGFloat = 0
             var scale: CGFloat = 0
-            
             if #available(iOS 13.0, tvOS 13.0, *) {
             if #available(iOS 13.0, tvOS 13.0, *) {
                 scale = UITraitCollection.current.displayScale
                 scale = UITraitCollection.current.displayScale
             } else {
             } else {
                 scale = UIScreen.main.scale
                 scale = UIScreen.main.scale
             }
             }
-            let targetSize = bounds.scaled(scale).size
             #endif
             #endif
+            currentFrame = image
+            let targetSize = bounds.scaled(scale).size
             let animator = Animator(
             let animator = Animator(
                 frameSource: frameSource,
                 frameSource: frameSource,
                 contentMode: contentMode,
                 contentMode: contentMode,
@@ -305,6 +488,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.
     /// Update the current frame with the displayLink duration.
     private func updateFrameIfNeeded() {
     private func updateFrameIfNeeded() {
         guard let animator = animator else {
         guard let animator = animator else {
@@ -334,7 +522,11 @@ open class AnimatedImageView: UIImageView {
 
 
         animator.shouldChangeFrame(with: duration) { [weak self] hasNewFrame in
         animator.shouldChangeFrame(with: duration) { [weak self] hasNewFrame in
             if hasNewFrame {
             if hasNewFrame {
+                #if os(macOS)
+                self?.layer?.setNeedsDisplay()
+                #else
                 self?.layer.setNeedsDisplay()
                 self?.layer.setNeedsDisplay()
+                #endif
             }
             }
         }
         }
     }
     }
@@ -356,7 +548,7 @@ extension AnimatedImageView {
     struct AnimatedFrame {
     struct AnimatedFrame {
 
 
         // The image to display for this frame. Its value is nil when the frame is removed from the buffer.
         // 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.
         // The duration that this frame should remain active.
         let duration: TimeInterval
         let duration: TimeInterval
@@ -376,7 +568,7 @@ extension AnimatedImageView {
         //
         //
         // - parameter image: An optional `UIImage` instance to be assigned to the new frame.
         // - parameter image: An optional `UIImage` instance to be assigned to the new frame.
         // - returns: An `AnimatedFrame` instance.
         // - returns: An `AnimatedFrame` instance.
-        func makeAnimatedFrame(image: UIImage?) -> AnimatedFrame {
+        func makeAnimatedFrame(image: KFCrossPlatformImage?) -> AnimatedFrame {
             return AnimatedFrame(image: image, duration: duration)
             return AnimatedFrame(image: image, duration: duration)
         }
         }
     }
     }
@@ -417,7 +609,7 @@ extension AnimatedImageView {
         var loopDuration: TimeInterval = 0
         var loopDuration: TimeInterval = 0
 
 
         /// The image of the current frame.
         /// The image of the current frame.
-        public var currentFrameImage: UIImage? {
+        public var currentFrameImage: KFCrossPlatformImage? {
             return frame(at: currentFrameIndex)
             return frame(at: currentFrameIndex)
         }
         }
 
 
@@ -461,7 +653,11 @@ extension AnimatedImageView {
             return maxFrameCount < frameCount - 1
             return maxFrameCount < frameCount - 1
         }
         }
 
 
+        #if os(macOS)
+        var contentMode = NSImageScaling.scaleAxesIndependently
+        #else
         var contentMode = UIView.ContentMode.scaleToFill
         var contentMode = UIView.ContentMode.scaleToFill
+        #endif
 
 
         private lazy var preloadQueue: DispatchQueue = {
         private lazy var preloadQueue: DispatchQueue = {
             return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue")
             return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue")
@@ -479,7 +675,7 @@ extension AnimatedImageView {
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         convenience init(imageSource source: CGImageSource,
         convenience init(imageSource source: CGImageSource,
-                         contentMode mode: UIView.ContentMode,
+                         contentMode mode: KFCrossPlatformContentMode,
                          size: CGSize,
                          size: CGSize,
                          imageSize: CGSize,
                          imageSize: CGSize,
                          imageScale: CGFloat,
                          imageScale: CGFloat,
@@ -509,7 +705,7 @@ extension AnimatedImageView {
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - repeatCount: The repeat count should this animator uses.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         ///   - preloadQueue: Dispatch queue used for preloading images.
         init(frameSource source: ImageFrameSource,
         init(frameSource source: ImageFrameSource,
-             contentMode mode: UIView.ContentMode,
+             contentMode mode: KFCrossPlatformContentMode,
              size: CGSize,
              size: CGSize,
              imageSize: CGSize,
              imageSize: CGSize,
              imageScale: CGFloat,
              imageScale: CGFloat,
@@ -530,7 +726,11 @@ extension AnimatedImageView {
         
         
         deinit {
         deinit {
             resetAnimatedFrames()
             resetAnimatedFrames()
-            GraphicsContext.end()
+            // Sometimes the Animator instance may deallocate on a non-main thread.
+            // Dispatch it to main thread if needed to avoid potential crashes.
+            CallbackQueue.mainCurrentOrAsync.execute {
+                GraphicsContext.end()
+            }
         }
         }
 
 
         /// Gets the image frame of a given index.
         /// Gets the image frame of a given index.
@@ -585,13 +785,16 @@ extension AnimatedImageView {
             animatedFrames.removeAll()
             animatedFrames.removeAll()
         }
         }
 
 
-        private func loadFrame(at index: Int) -> UIImage? {
+        private func loadFrame(at index: Int) -> KFCrossPlatformImage? {
             let resize = needsPrescaling && size != .zero
             let resize = needsPrescaling && size != .zero
             let maxSize = resize ? size : nil
             let maxSize = resize ? size : nil
             guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else {
             guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else {
                 return nil
                 return nil
             }
             }
             
             
+            #if os(macOS)
+            return KFCrossPlatformImage(cgImage: cgImage, size: .zero)
+            #else
             if #available(iOS 15, tvOS 15, *) {
             if #available(iOS 15, tvOS 15, *) {
                 // From iOS 15, a plain image loading causes iOS calling `-[_UIImageCGImageContent initWithCGImage:scale:]`
                 // From iOS 15, a plain image loading causes iOS calling `-[_UIImageCGImageContent initWithCGImage:scale:]`
                 // in ImageIO, which holds the image ref on the creating thread.
                 // in ImageIO, which holds the image ref on the creating thread.
@@ -616,6 +819,7 @@ extension AnimatedImageView {
                     return image
                     return image
                 }
                 }
             }
             }
+            #endif
         }
         }
         
         
         private func updatePreloadedFrames() {
         private func updatePreloadedFrames() {
@@ -729,4 +933,3 @@ class SafeArray<Element> {
     }
     }
 }
 }
 #endif
 #endif
-#endif