From a31882434ca5edc9a004f20b53386368d0af15bc Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Sun, 7 Jan 2024 23:26:32 +0800 Subject: [PATCH 1/7] support AnimatedImageView for macOS --- .../Base.lproj/Main.storyboard | 50 +++- .../GIFHeavyViewController.swift | 56 +++++ .../Kingfisher-Demo.xcodeproj/project.pbxproj | 4 + Sources/Image/Image.swift | 5 +- Sources/Image/ImageDrawing.swift | 4 - Sources/Views/AnimatedImageView.swift | 227 +++++++++++++++++- 6 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift diff --git a/Demo/Demo/Kingfisher-macOS-Demo/Base.lproj/Main.storyboard b/Demo/Demo/Kingfisher-macOS-Demo/Base.lproj/Main.storyboard index dbd0feb46..b17c42c0f 100644 --- a/Demo/Demo/Kingfisher-macOS-Demo/Base.lproj/Main.storyboard +++ b/Demo/Demo/Kingfisher-macOS-Demo/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -708,7 +708,7 @@ + + @@ -745,11 +755,13 @@ + + @@ -761,6 +773,19 @@ + + + + + + + + + + + + + diff --git a/Demo/Demo/Kingfisher-macOS-Demo/SwiftUIViewController.swift b/Demo/Demo/Kingfisher-macOS-Demo/SwiftUIViewController.swift new file mode 100644 index 000000000..7cf66142e --- /dev/null +++ b/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 +// +// 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 { + 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") } + } + } +} diff --git a/Sources/SwiftUI/ImageContext.swift b/Sources/SwiftUI/ImageContext.swift index 730477b3a..91d3b4341 100644 --- a/Sources/SwiftUI/ImageContext.swift +++ b/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 diff --git a/Sources/SwiftUI/KFAnimatedImage.swift b/Sources/SwiftUI/KFAnimatedImage.swift index ad25eb232..f5a28ccfa 100644 --- a/Sources/SwiftUI/KFAnimatedImage.swift +++ b/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,8 +47,9 @@ public struct KFAnimatedImage: KFImageProtocol { } } +#if !os(macOS) /// A wrapped `UIViewRepresentable` of `AnimatedImageView` -@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +@available(iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldingView { public typealias RenderingView = AnimatedImageView public static func created(from image: KFCrossPlatformImage?, context: KFImage.Context) -> KFAnimatedImageViewRepresenter { @@ -75,6 +76,35 @@ public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldin uiView.image = image } } +#else +@available(macOS 11.0, *) +public struct KFAnimatedImageViewRepresenter: NSViewRepresentable, KFImageHoldingView { + public typealias RenderingView = AnimatedImageView + public static func created(from image: KFCrossPlatformImage?, context: KFImage.Context) -> KFAnimatedImageViewRepresenter { + KFAnimatedImageViewRepresenter(image: image, context: context) + } + + var image: KFCrossPlatformImage? + let context: KFImage.Context + + public func makeNSView(context: Context) -> AnimatedImageView { + let view = AnimatedImageView() + + self.context.renderConfigurations.forEach { $0(view) } + + view.image = image + + // Allow SwiftUI scale (fit/fill) working fine. + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return view + } + + public func updateNSView(_ nsView: AnimatedImageView, context: Context) { + nsView.image = image + } +} +#endif #if DEBUG @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) From 2fce2730174cca824331d56f0596667e4c747107 Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Mon, 8 Jan 2024 21:47:32 +0800 Subject: [PATCH 3/7] fix demo not behaving properly --- Demo/Kingfisher-Demo.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj b/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj index 9a686edd7..54ea6afe7 100644 --- a/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj +++ b/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; @@ -163,6 +164,7 @@ 277EAE9F2045B52800547CD3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 277EAEA02045B52800547CD3 /* ExtensionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; 3887E15F2B4AD7200062C53C /* GIFHeavyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFHeavyViewController.swift; sourceTree = ""; }; + 3887E17A2B4BC04B0062C53C /* SwiftUIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIViewController.swift; sourceTree = ""; }; 4B120CA626B91BB70060B092 /* TransitionViewDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionViewDemo.swift; sourceTree = ""; }; 4B1C7A3C21A256E300CE9D31 /* InfinityCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfinityCollectionViewController.swift; sourceTree = ""; }; 4B2944551C3D03880088C3E7 /* Kingfisher-macOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-macOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -321,6 +323,7 @@ 4BCCF33B1D5B02F8003387C2 /* Info.plist */, 4BCCF33C1D5B02F8003387C2 /* ViewController.swift */, 3887E15F2B4AD7200062C53C /* GIFHeavyViewController.swift */, + 3887E17A2B4BC04B0062C53C /* SwiftUIViewController.swift */, ); name = "Kingfisher-macOS-Demo"; path = "Demo/Kingfisher-macOS-Demo"; @@ -658,6 +661,7 @@ 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 */, From bf5c5859a1d51ff3e6cdd5a9a3c65c82edf0a2af Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Wed, 10 Jan 2024 00:12:49 +0800 Subject: [PATCH 4/7] remove duplicated code in KFAnimatedImage --- Sources/SwiftUI/KFAnimatedImage.swift | 54 +++++++++++++-------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/Sources/SwiftUI/KFAnimatedImage.swift b/Sources/SwiftUI/KFAnimatedImage.swift index f5a28ccfa..08c19e446 100644 --- a/Sources/SwiftUI/KFAnimatedImage.swift +++ b/Sources/SwiftUI/KFAnimatedImage.swift @@ -47,10 +47,17 @@ public struct KFAnimatedImage: KFImageProtocol { } } -#if !os(macOS) -/// A wrapped `UIViewRepresentable` of `AnimatedImageView` +#if os(macOS) +@available(macOS 11.0, *) +typealias KFCrossPlatformViewRepresentable = NSViewRepresentable +#else @available(iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldingView { +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: KFCrossPlatformViewRepresentable, KFImageHoldingView { public typealias RenderingView = AnimatedImageView public static func created(from image: KFCrossPlatformImage?, context: KFImage.Context) -> KFAnimatedImageViewRepresenter { KFAnimatedImageViewRepresenter(image: image, context: context) @@ -59,35 +66,25 @@ public struct KFAnimatedImageViewRepresenter: UIViewRepresentable, KFImageHoldin var image: KFCrossPlatformImage? let context: KFImage.Context - public func makeUIView(context: Context) -> AnimatedImageView { - let view = AnimatedImageView() - - self.context.renderConfigurations.forEach { $0(view) } - - view.image = image - - // Allow SwiftUI scale (fit/fill) working fine. - view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - return view + #if os(macOS) + public func makeNSView(context: Context) -> AnimatedImageView { + return makeImageView() } - public func updateUIView(_ uiView: AnimatedImageView, context: Context) { - uiView.image = image + public func updateNSView(_ nsView: AnimatedImageView, context: Context) { + updateImageView(nsView) } -} -#else -@available(macOS 11.0, *) -public struct KFAnimatedImageViewRepresenter: NSViewRepresentable, KFImageHoldingView { - public typealias RenderingView = AnimatedImageView - public static func created(from image: KFCrossPlatformImage?, context: KFImage.Context) -> KFAnimatedImageViewRepresenter { - KFAnimatedImageViewRepresenter(image: image, context: context) + #else + public func makeUIView(context: Context) -> AnimatedImageView { + return makeImageView() } - var image: KFCrossPlatformImage? - let context: KFImage.Context + public func updateUIView(_ uiView: AnimatedImageView, context: Context) { + updateImageView(uiView) + } + #endif - public func makeNSView(context: Context) -> AnimatedImageView { + private func makeImageView() -> AnimatedImageView { let view = AnimatedImageView() self.context.renderConfigurations.forEach { $0(view) } @@ -100,11 +97,10 @@ public struct KFAnimatedImageViewRepresenter: NSViewRepresentable, KFImageHoldin return view } - public func updateNSView(_ nsView: AnimatedImageView, context: Context) { - nsView.image = image + private func updateImageView(_ imageView: AnimatedImageView) { + imageView.image = image } } -#endif #if DEBUG @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) From 91de98c4875714e0c5c954e872494e5e826c0cfe Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Wed, 10 Jan 2024 00:14:45 +0800 Subject: [PATCH 5/7] extract DisplayLink to separated file --- Kingfisher.xcodeproj/project.pbxproj | 4 + Sources/Utility/DisplayLink.swift | 157 ++++++++++++++++++++++++++ Sources/Views/AnimatedImageView.swift | 100 +--------------- 3 files changed, 163 insertions(+), 98 deletions(-) create mode 100644 Sources/Utility/DisplayLink.swift diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index 860f5b3d4..ea3659780 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/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 = ""; }; 185218B51CC07F8300BD58DE /* NSButtonExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSButtonExtensionTests.swift; sourceTree = ""; }; 22FDCE0D2700078B0044D11E /* CPListItem+Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CPListItem+Kingfisher.swift"; sourceTree = ""; }; + 388F37372B4D9CDB0089705C /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = ""; }; 4B10480C216F157000300C61 /* ImageDataProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataProcessor.swift; sourceTree = ""; }; 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 = ""; @@ -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 */, diff --git a/Sources/Utility/DisplayLink.swift b/Sources/Utility/DisplayLink.swift new file mode 100644 index 000000000..fa7e79386 --- /dev/null +++ b/Sources/Utility/DisplayLink.swift @@ -0,0 +1,157 @@ +// +// DisplayLink.swift +// Kingfisher +// +// Created by yeatse on 2024/1/9. +// +// Copyright (c) 2024 Wei Wang +// +// 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(macOS) +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 #available(macOS 14.0, *) { + return displayLink(target: target, selector: selector) + } else { + return DisplayLink(target: target, selector: selector) + } + } +} + +@available(macOS 14.0, *) +extension CADisplayLink: DisplayLinkCompatible { + var preferredFramesPerSecond: NSInteger { return 0 } +} + +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, + inOutputTime: UnsafePointer, + flagsIn: CVOptionFlags, + flagsOut: UnsafeMutablePointer) -> 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 diff --git a/Sources/Views/AnimatedImageView.swift b/Sources/Views/AnimatedImageView.swift index 9ae1374d4..f8bf50622 100644 --- a/Sources/Views/AnimatedImageView.swift +++ b/Sources/Views/AnimatedImageView.swift @@ -173,110 +173,14 @@ open class AnimatedImageView: KFCrossPlatformImageView { // A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy. private var isDisplayLinkInitialized: Bool = false -#if os(macOS) - class DisplayLink { - private let link: UnsafeMutablePointer - private var target: Any? - private var selector: Selector? - - private var schedulers: [RunLoop: [RunLoop.Mode]] = [:] - - init(target: Any, selector: Selector) { - link = UnsafeMutablePointer.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, - inOutputTime: UnsafePointer, - flagsIn: CVOptionFlags, - flagsOut: UnsafeMutablePointer) -> 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. - 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 }() -#endif // MARK: - Override override open var image: KFCrossPlatformImage? { From dcce98b7821251917f6808dad857f77fc9153bce Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Wed, 10 Jan 2024 01:15:55 +0800 Subject: [PATCH 6/7] Support backgroundDecode for macOS and fix scaling errors --- .../GIFHeavyViewController.swift | 2 + Sources/Views/AnimatedImageView.swift | 41 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift b/Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift index 5a30aa4f7..3238a02cd 100644 --- a/Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift +++ b/Demo/Demo/Kingfisher-macOS-Demo/GIFHeavyViewController.swift @@ -44,6 +44,8 @@ class GIFHeavyViewController: NSViewController { 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 diff --git a/Sources/Views/AnimatedImageView.swift b/Sources/Views/AnimatedImageView.swift index f8bf50622..a3275c399 100644 --- a/Sources/Views/AnimatedImageView.swift +++ b/Sources/Views/AnimatedImageView.swift @@ -35,11 +35,10 @@ #if canImport(UIKit) import UIKit import ImageIO -public typealias KFCrossPlatformContentMode = UIView.ContentMode +typealias KFCrossPlatformContentMode = UIView.ContentMode #elseif canImport(AppKit) import AppKit -import CoreVideo -public typealias KFCrossPlatformContentMode = NSImageScaling +typealias KFCrossPlatformContentMode = NSImageScaling #endif /// Protocol of `AnimatedImageView`. @@ -246,6 +245,7 @@ open class AnimatedImageView: KFCrossPlatformImageView { private func commonInit() { super.animates = false + wantsLayer = true } open override var animates: Bool { @@ -283,12 +283,33 @@ open class AnimatedImageView: KFCrossPlatformImageView { } open override func updateLayer() { - if let frame = animator?.currentFrameImage ?? currentFrame { - layer?.contents = frame.kf.cgImage + 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() @@ -697,7 +718,15 @@ extension AnimatedImageView { } #if os(macOS) - return KFCrossPlatformImage(cgImage: cgImage, size: .zero) + 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:]` From d3a233773f8d3cfd9f51ba62f3f59d6ff04ffdd5 Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Wed, 10 Jan 2024 01:26:15 +0800 Subject: [PATCH 7/7] fix test failure --- Sources/Utility/DisplayLink.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/Utility/DisplayLink.swift b/Sources/Utility/DisplayLink.swift index fa7e79386..801b342c9 100644 --- a/Sources/Utility/DisplayLink.swift +++ b/Sources/Utility/DisplayLink.swift @@ -24,7 +24,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -#if !os(macOS) +#if !os(watchOS) +#if canImport(UIKit) import UIKit #else import AppKit @@ -45,7 +46,6 @@ protocol DisplayLinkCompatible: AnyObject { } #if !os(macOS) - extension UIView { func compatibleDisplayLink(target: Any, selector: Selector) -> DisplayLinkCompatible { return CADisplayLink(target: target, selector: selector) @@ -55,21 +55,26 @@ extension UIView { 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? @@ -155,3 +160,4 @@ class DisplayLink: DisplayLinkCompatible { } } #endif +#endif