Explorar o código

Merge pull request #2410 from onevcat/darkbrewx-feature/swiftui-load-transitions

Add SwiftUI native transition support for KFImage
Wei Wang hai 7 meses
pai
achega
fb42ed1cf2

+ 183 - 0
Demo/Demo/Kingfisher-Demo/SwiftUIViews/LoadTransitionDemo.swift

@@ -0,0 +1,183 @@
+//
+//  LoadTransitionDemo.swift
+//  Kingfisher
+//
+//  Copyright (c) 2025 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(iOS 14.0, *)
+struct LoadTransitionDemo: View {
+    @State private var imageIndex = 0
+    @State private var currentTransition: TransitionType = .none
+
+    let columns = [
+        GridItem(.flexible(), spacing: 10),
+        GridItem(.flexible(), spacing: 10),
+        GridItem(.flexible(), spacing: 10)
+    ]
+
+    var body: some View {
+        VStack(spacing: 20) {
+            // Image display area
+            Group {
+                switch currentTransition {
+                case .none:
+                    KFImage(currentTransition.url)
+                        .placeholder { placeholderView }
+                        .contentConfigure { content in
+                            content
+                                .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+                        }
+                        .forceTransition()
+                        .resizable()
+                        .aspectRatio(contentMode: .fit)
+                case .fade:
+                    KFImage(currentTransition.url)
+                        .placeholder { placeholderView }
+                        .contentConfigure { content in
+                            content
+                                .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+                        }
+                        .forceTransition()
+                        .fade(duration: 0.5)
+                        .resizable()
+                        .aspectRatio(contentMode: .fit)
+                default:
+                    KFImage(currentTransition.url)
+                        .placeholder { placeholderView }
+                        .contentConfigure { content in
+                            content
+                                .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+                        }
+                        .forceTransition()
+                        .loadTransition(currentTransition.transition, animation: currentTransition.animation)
+                        .resizable()
+                        .aspectRatio(contentMode: .fit)
+                }
+            }
+            .padding(16)
+            .frame(width: 300, height: 300)
+            .background(Color.gray.opacity(0.3))
+            .cornerRadius(16)
+            .shadow(radius: 5)
+
+            Spacer()
+
+            // Transition buttons
+            LazyVGrid(columns: columns, spacing: 15) {
+                ForEach(TransitionType.allCases, id: \.self) { type in
+                    Button(action: {
+                        // Clear cache to ensure transition is visible
+                        if let currentURL = URL(string: currentTransition.urlString) {
+                            KingfisherManager.shared.cache.removeImage(forKey: currentURL.absoluteString)
+                        }
+                        currentTransition = type
+                    }) {
+                        Text(type.rawValue)
+                            .font(.system(size: 14, weight: .medium))
+                            .frame(maxWidth: .infinity)
+                            .padding(.vertical, 12)
+                            .background(currentTransition == type ? Color.blue : Color.gray)
+                            .foregroundColor(.white)
+                            .cornerRadius(8)
+                    }
+                }
+            }
+            .padding(.horizontal)
+            
+            Spacer()
+        }
+        .navigationBarTitle("Load Transition", displayMode: .inline)
+    }
+    
+    private var placeholderView: some View {
+        RoundedRectangle(cornerRadius: 8)
+            .fill(Color.gray.opacity(0.2))
+            .overlay(ProgressView())
+    }
+
+    enum TransitionType: String, CaseIterable {
+        case none = "None"
+        case fade = "Fade"
+        case slide = "Slide"
+        case scale = "Scale"
+        case opacity = "Opacity"
+        case blurReplace = "Blur"
+
+        @MainActor
+        var transition: AnyTransition {
+            switch self {
+            case .none, .fade:
+                return .identity
+            case .slide:
+                return .slide
+            case .scale:
+                return .scale
+            case .opacity:
+                return .opacity
+            case .blurReplace:
+                if #available(iOS 17.0, *) {
+                    return AnyTransition(.blurReplace())
+                } else {
+                    return .scale  // Fallback for iOS < 17
+                }
+            }
+        }
+        
+        var animation: Animation? {
+            switch self {
+            case .none, .fade:
+                return nil
+            case .slide:
+                return .easeInOut(duration: 0.5)
+            case .scale:
+                return .spring()
+            case .opacity:
+                return .easeInOut(duration: 0.4)
+            case .blurReplace:
+                if #available(iOS 17.0, *) {
+                    return .bouncy(duration: 0.8)
+                } else {
+                    return .spring()
+                }
+            }
+        }
+        
+        var urlString: String {
+            let index = TransitionType.allCases.firstIndex(of: self) ?? 0
+            let urls = ImageLoader.sampleImageURLs
+            return urls[index % urls.count].absoluteString
+        }
+        
+        var url: URL {
+            URL(string: urlString)!
+        }
+    }
+}
+
+@available(iOS 14.0, *)
+struct LoadTransitionDemo_Previews: PreviewProvider {
+    static var previews: some View {
+        LoadTransitionDemo()
+    }
+}

+ 1 - 0
Demo/Demo/Kingfisher-Demo/SwiftUIViews/MainView.swift

@@ -52,6 +52,7 @@ struct MainView: View {
                 NavigationLink(destination: AnimatedImageDemo()) { Text("Animated Image") }
                 NavigationLink(destination: GeometryReaderDemo()) { Text("Geometry Reader") }
                 NavigationLink(destination: TransitionViewDemo()) { Text("Transition") }
+                NavigationLink(destination: LoadTransitionDemo()) { Text("Load Transition") }
                 NavigationLink(destination: ProgressiveJPEGDemo()) { Text("Progressive JPEG") }
                 NavigationLink(destination: LoadingFailureDemo()) { Text("Loading Failure") }
             }

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

@@ -80,6 +80,7 @@
 		D1F78A652589F17200930759 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F78A622589F17200930759 /* MainView.swift */; };
 		D1F78A662589F17200930759 /* SingleViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F78A632589F17200930759 /* SingleViewDemo.swift */; };
 		D1FAB06F21A853E600908910 /* HighResolutionCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FAB06E21A853E600908910 /* HighResolutionCollectionViewController.swift */; };
+		F344AEF22E1AD74F00BFE702 /* LoadTransitionDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -235,6 +236,7 @@
 		D1F78A622589F17200930759 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
 		D1F78A632589F17200930759 /* SingleViewDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleViewDemo.swift; sourceTree = "<group>"; };
 		D1FAB06E21A853E600908910 /* HighResolutionCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighResolutionCollectionViewController.swift; sourceTree = "<group>"; };
+		F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadTransitionDemo.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -465,6 +467,7 @@
 				D198F41D25EDC11500C53E0D /* LazyVStackDemo.swift */,
 				D198F41F25EDC34000C53E0D /* SizingAnimationDemo.swift */,
 				072922422638639D0089E810 /* AnimatedImageDemo.swift */,
+				F344AEF12E1AD74F00BFE702 /* LoadTransitionDemo.swift */,
 			);
 			path = SwiftUIViews;
 			sourceTree = "<group>";
@@ -596,7 +599,6 @@
 					};
 					D1ED2D0A1AD2CFA600CFC3EB = {
 						CreatedOnToolsVersion = 6.2;
-						DevelopmentTeam = A4YJ9MRZ66;
 						LastSwiftMigration = 1200;
 					};
 				};
@@ -719,6 +721,7 @@
 				D12EB83E24DD902300329EE1 /* TextAttachmentViewController.swift in Sources */,
 				D12E0C9B1C47F91800AC98AD /* NormalLoadingViewController.swift in Sources */,
 				D1CE1BD021A1AFA300419000 /* TransitionViewController.swift in Sources */,
+				F344AEF22E1AD74F00BFE702 /* LoadTransitionDemo.swift in Sources */,
 				D10AC99821A300C9005F057C /* ProcessorCollectionViewController.swift in Sources */,
 				D1F06F3921AAF1EE000B1C38 /* IndicatorCollectionViewController.swift in Sources */,
 				D1F78A662589F17200930759 /* SingleViewDemo.swift in Sources */,

+ 11 - 0
Sources/Documentation.docc/CommonTasks/CommonTasks.md

@@ -91,10 +91,21 @@ This shows a `UIActivityIndicatorView` in center of image view while downloading
 
 ### Fading in Downloaded Image
 
+For UIKit/AppKit:
 ```swift
 imageView.kf.setImage(with: url, options: [.transition(.fade(0.2))])
 ```
 
+For SwiftUI (recommended):
+```swift
+KFImage(url)
+    .fade(duration: 0.2)
+    // Or use native SwiftUI transitions:
+    .loadTransition(.opacity, animation: .easeInOut(duration: 0.2))
+```
+
+> Note: In SwiftUI applications, use `loadTransition` for native SwiftUI transitions instead of the options-based `transition`. This provides better integration with the SwiftUI animation system.
+
 ### Completion Handler
 
 ```swift

+ 3 - 0
Sources/General/KingfisherOptionsInfo.swift

@@ -72,6 +72,9 @@ public enum KingfisherOptionsInfoItem: Sendable {
     /// By default, the transition does not occur when the image is retrieved from either memory or disk cache. To
     /// force the transition even when the image is retrieved from the cache, also set
     /// ``KingfisherOptionsInfoItem/forceTransition``.
+    ///
+    /// - Important: This option is designed for UIKit/AppKit transitions. For SwiftUI applications, use the
+    /// ``KFImageProtocol/loadTransition(_:animation:)`` method instead, which provides native SwiftUI transition support.
     case transition(ImageTransition)
     
     /// The associated `Float` value to be set as the priority of the image download task.

+ 9 - 1
Sources/SwiftUI/ImageBinder.swift

@@ -110,7 +110,15 @@ extension KFImage {
                         switch result {
                         case .success(let value):
                             CallbackQueueMain.currentOrAsync {
-                                if let fadeDuration = context.fadeTransitionDuration(cacheType: value.cacheType) {
+                                if context.swiftUITransition != nil,
+                                   context.shouldApplyFade(cacheType: value.cacheType) {
+                                    // Apply SwiftUI loadTransition with custom animation (higher priority than fade)
+                                    self.animating = true
+                                    let animation = context.swiftUIAnimation ?? .default
+                                    withAnimation(animation) {
+                                        self.markLoaded(sendChangeEvent: true)
+                                    }
+                                } else if let fadeDuration = context.fadeTransitionDuration(cacheType: value.cacheType) {
                                     self.animating = true
                                     let animation = Animation.linear(duration: fadeDuration)
                                     withAnimation(animation) {

+ 13 - 0
Sources/SwiftUI/ImageContext.swift

@@ -90,6 +90,19 @@ extension KFImage {
             get { propertyQueue.sync { _startLoadingBeforeViewAppear } }
             set { propertyQueue.sync { _startLoadingBeforeViewAppear = newValue } }
         }
+        
+        // SwiftUI transition support
+        var _swiftUITransition: AnyTransition? = nil
+        var swiftUITransition: AnyTransition? {
+            get { propertyQueue.sync { _swiftUITransition } }
+            set { propertyQueue.sync { _swiftUITransition = newValue } }
+        }
+        
+        var _swiftUIAnimation: Animation? = nil
+        var swiftUIAnimation: Animation? {
+            get { propertyQueue.sync { _swiftUIAnimation } }
+            set { propertyQueue.sync { _swiftUIAnimation = newValue } }
+        }
 
         let onFailureDelegate = Delegate<KingfisherError, Void>()
         let onSuccessDelegate = Delegate<RetrieveImageResult, Void>()

+ 57 - 0
Sources/SwiftUI/KFImageOptions.swift

@@ -207,5 +207,62 @@ extension KFImageProtocol {
         context.startLoadingBeforeViewAppear = flag
         return self
     }
+    
+    /// Sets a SwiftUI transition for the image loading.
+    ///
+    /// - Parameters:
+    ///   - transition: The SwiftUI transition to apply when the image appears.
+    ///   - animation: The animation to use with the transition. Defaults to `.default`.
+    /// - Returns: A Kingfisher-compatible image view with the applied transition.
+    ///
+    /// This is the recommended way to apply transitions in SwiftUI applications. Unlike the UIKit-based
+    /// ``KingfisherOptionsInfoItem/transition(_:)`` option, this method uses native SwiftUI transitions,
+    /// providing better integration with the SwiftUI animation system and access to all SwiftUI transition types.
+    ///
+    /// Available transitions include `.slide`, `.scale`, `.opacity`, `.move`, `.offset`, and custom transitions.
+    /// The transition will be applied when the image is loaded from the network, following the same
+    /// rules as the fade transition regarding cache behavior and `forceTransition`.
+    /// 
+    /// When both `loadTransition` and `fade` are set, `loadTransition` takes precedence.
+    ///
+    /// Example:
+    /// ```swift
+    /// KFImage(url)
+    ///     .loadTransition(.slide, animation: .easeInOut(duration: 0.5))
+    /// ```
+    ///
+    /// - Note: For UIKit/AppKit applications, use ``KingfisherOptionsInfoItem/transition(_:)`` instead.
+    public func loadTransition(_ transition: AnyTransition, animation: Animation? = .default) -> Self {
+        context.swiftUITransition = transition
+        context.swiftUIAnimation = animation
+        return self
+    }
+    
+    /// Sets a SwiftUI transition for the image loading (iOS 17.0+).
+    ///
+    /// - Parameters:
+    ///   - transition: The SwiftUI transition conforming to the Transition protocol.
+    ///   - animation: The animation to use with the transition. Defaults to `.default`.
+    /// - Returns: A Kingfisher-compatible image view with the applied transition.
+    ///
+    /// This method provides access to newer SwiftUI transitions available in iOS 17.0+,
+    /// such as `BlurReplaceTransition`, `PushTransition`, and other transitions conforming to the `Transition` protocol.
+    /// This is the recommended approach for SwiftUI applications on iOS 17.0+.
+    /// 
+    /// When both `loadTransition` and `fade` are set, `loadTransition` takes precedence.
+    ///
+    /// Example:
+    /// ```swift
+    /// KFImage(url)
+    ///     .loadTransition(.blurReplace(.downUp), animation: .bouncy)
+    /// ```
+    ///
+    /// - Note: For UIKit/AppKit applications, use ``KingfisherOptionsInfoItem/transition(_:)`` instead.
+    @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+    public func loadTransition<T: Transition>(_ transition: T, animation: Animation? = .default) -> Self {
+        context.swiftUITransition = AnyTransition(transition)
+        context.swiftUIAnimation = animation
+        return self
+    }
 }
 #endif

+ 22 - 1
Sources/SwiftUI/KFImageRenderer.swift

@@ -43,7 +43,16 @@ struct KFImageRenderer<HoldingView> : View where HoldingView: KFImageHoldingView
         }
         
         return ZStack {
-            renderedImage().opacity(binder.loaded ? 1.0 : 0.0)
+            if context.swiftUITransition != nil {
+                // SwiftUI loadTransition: insert/remove view for proper transition behavior
+                if binder.loaded {
+                    renderedImage()
+                }
+            } else {
+                // Fade transition or no transition: use opacity control
+                renderedImage()
+                    .opacity(binder.loaded ? 1.0 : 0.0)
+            }
             if binder.loadedImage == nil {
                 ZStack {
                     // Priority: failureView > placeholder > Color.clear
@@ -94,10 +103,22 @@ struct KFImageRenderer<HoldingView> : View where HoldingView: KFImageHoldingView
     
     @ViewBuilder
     private func renderedImage() -> some View {
+        if let swiftUITransition = context.swiftUITransition {
+            // Apply SwiftUI loadTransition as the last step for correct rendering order
+            configuredImage.transition(swiftUITransition)
+        } else {
+            configuredImage
+        }
+    }
+    
+    @ViewBuilder
+    private var configuredImage: some View {
         let configuredImage = context.configurations
             .reduce(HoldingView.created(from: binder.loadedImage, context: context)) {
                 current, config in config(current)
             }
+        
+        // Apply contentConfiguration first, then loadTransition as the final step
         if let contentConfiguration = context.contentConfiguration {
             contentConfiguration(configuredImage)
         } else {