diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8be7306b..58fa29f1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -46,7 +46,7 @@ jobs: matrix: iosDestination: ["name=iPhone 13 Pro"] tvOSDestination: ["name=Apple TV"] - watchOSDestination: ["platform=watchOS Simulator,name=Apple Watch Series 7 - 45mm"] + watchOSDestination: ["platform=watchOS Simulator,name=Apple Watch Series 7 (45mm)"] macOSDestination: ["platform=macOS"] macCatalystDestination: ["platform=macOS,arch=x86_64,variant=Mac Catalyst"] steps: diff --git a/Example/SDWebImageSwiftUI.xcodeproj/xcshareddata/xcschemes/SDWebImageSwiftUIDemo-watchOS WatchKit App.xcscheme b/Example/SDWebImageSwiftUI.xcodeproj/xcshareddata/xcschemes/SDWebImageSwiftUIDemo-watchOS WatchKit App.xcscheme index 65df20bc..58b52ed0 100644 --- a/Example/SDWebImageSwiftUI.xcodeproj/xcshareddata/xcschemes/SDWebImageSwiftUIDemo-watchOS WatchKit App.xcscheme +++ b/Example/SDWebImageSwiftUI.xcodeproj/xcshareddata/xcschemes/SDWebImageSwiftUIDemo-watchOS WatchKit App.xcscheme @@ -53,7 +53,8 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" - allowLocationSimulation = "YES"> + allowLocationSimulation = "YES" + notificationPayloadFile = "PushNotificationPayload.apns"> = .constant(true), placeholderImage: PlatformImage? = nil) { + let imageModel = AnimatedImageModel() + imageModel.url = url + imageModel.webOptions = options + imageModel.webContext = context + imageModel.placeholderImage = placeholderImage + self.init(imageModel: imageModel, isAnimating: isAnimating) } /// Create an animated image with url, placeholder, custom options and context, including animation control binding. @@ -130,46 +136,37 @@ public struct AnimatedImage : PlatformViewRepresentable { /// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values. /// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold. /// - Parameter isAnimating: The binding for animation control - public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding) { + public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding = .constant(true), @ViewBuilder placeholder: @escaping () -> T) where T : View { let imageModel = AnimatedImageModel() imageModel.url = url imageModel.webOptions = options imageModel.webContext = context + #if os(macOS) + let hostingView = NSHostingView(rootView: placeholder()) + #else + let hostingView = _UIHostingView(rootView: placeholder()) + #endif + imageModel.placeholderView = hostingView self.init(imageModel: imageModel, isAnimating: isAnimating) } - /// Create an animated image with name and bundle. - /// - Note: Asset Catalog is not supported. - /// - Parameter name: The image name - /// - Parameter bundle: The bundle contains image - public init(name: String, bundle: Bundle? = nil) { - self.init(name: name, bundle: bundle, isAnimating: .constant(true)) - } - /// Create an animated image with name and bundle, including animation control binding. /// - Note: Asset Catalog is not supported. /// - Parameter name: The image name /// - Parameter bundle: The bundle contains image /// - Parameter isAnimating: The binding for animation control - public init(name: String, bundle: Bundle? = nil, isAnimating: Binding) { + public init(name: String, bundle: Bundle? = nil, isAnimating: Binding = .constant(true)) { let imageModel = AnimatedImageModel() imageModel.name = name imageModel.bundle = bundle self.init(imageModel: imageModel, isAnimating: isAnimating) } - /// Create an animated image with data and scale. - /// - Parameter data: The image data - /// - Parameter scale: The scale factor - public init(data: Data, scale: CGFloat = 1) { - self.init(data: data, scale: scale, isAnimating: .constant(true)) - } - /// Create an animated image with data and scale, including animation control binding. /// - Parameter data: The image data /// - Parameter scale: The scale factor /// - Parameter isAnimating: The binding for animation control - public init(data: Data, scale: CGFloat = 1, isAnimating: Binding) { + public init(data: Data, scale: CGFloat = 1, isAnimating: Binding = .constant(true)) { let imageModel = AnimatedImageModel() imageModel.data = data imageModel.scale = scale @@ -222,7 +219,7 @@ public struct AnimatedImage : PlatformViewRepresentable { func setupIndicator(_ view: AnimatedImageViewWrapper, context: Context) { view.wrapped.sd_imageIndicator = imageConfiguration.indicator view.wrapped.sd_imageTransition = imageConfiguration.transition - if let placeholderView = imageConfiguration.placeholderView { + if let placeholderView = imageModel.placeholderView { placeholderView.removeFromSuperview() placeholderView.isHidden = true // Placeholder View should below the Indicator View @@ -243,13 +240,13 @@ public struct AnimatedImage : PlatformViewRepresentable { context.coordinator.imageLoading.isLoading = true let webOptions = imageModel.webOptions if webOptions.contains(.delayPlaceholder) { - self.imageConfiguration.placeholderView?.isHidden = true + self.imageModel.placeholderView?.isHidden = true } else { - self.imageConfiguration.placeholderView?.isHidden = false + self.imageModel.placeholderView?.isHidden = false } var webContext = imageModel.webContext ?? [:] webContext[.animatedImageClass] = SDAnimatedImage.self - view.wrapped.sd_internalSetImage(with: imageModel.url, placeholderImage: imageConfiguration.placeholder, options: webOptions, context: webContext, setImageBlock: nil, progress: { (receivedSize, expectedSize, _) in + view.wrapped.sd_internalSetImage(with: imageModel.url, placeholderImage: imageModel.placeholderImage, options: webOptions, context: webContext, setImageBlock: nil, progress: { (receivedSize, expectedSize, _) in let progress: Double if (expectedSize > 0) { progress = Double(receivedSize) / Double(expectedSize) @@ -265,10 +262,10 @@ public struct AnimatedImage : PlatformViewRepresentable { context.coordinator.imageLoading.isLoading = false context.coordinator.imageLoading.progress = 1 if let image = image { - self.imageConfiguration.placeholderView?.isHidden = true + self.imageModel.placeholderView?.isHidden = true self.imageHandler.successBlock?(image, data, cacheType) } else { - self.imageConfiguration.placeholderView?.isHidden = false + self.imageModel.placeholderView?.isHidden = false self.imageHandler.failureBlock?(error ?? NSError()) } } @@ -780,30 +777,19 @@ extension AnimatedImage { } } +// Convenient indicator dot syntax +extension SDWebImageIndicator where Self == SDWebImageActivityIndicator { + public static var activity: Self { Self() } +} + +extension SDWebImageIndicator where Self == SDWebImageProgressIndicator { + public static var progress: Self { Self() } +} + // Web Image convenience, based on UIKit/AppKit API @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) extension AnimatedImage { - /// Associate a placeholder when loading image with url - /// - Parameter content: A view that describes the placeholder. - /// - note: The differences between this and placeholder image, it's that placeholder image replace the image for image view, but this modify the View Hierarchy to overlay the placeholder hosting view - public func placeholder(@ViewBuilder content: () -> T) -> AnimatedImage where T : View { - #if os(macOS) - let hostingView = NSHostingView(rootView: content()) - #else - let hostingView = _UIHostingView(rootView: content()) - #endif - self.imageConfiguration.placeholderView = hostingView - return self - } - - /// Associate a placeholder image when loading image with url - /// - Parameter content: A view that describes the placeholder. - public func placeholder(_ image: PlatformImage?) -> AnimatedImage { - self.imageConfiguration.placeholder = image - return self - } - /// Associate a indicator when loading image with url /// - Note: If you do not need indicator, specify nil. Defaults to nil /// - Parameter indicator: indicator, see more in `SDWebImageIndicator` @@ -821,23 +807,6 @@ extension AnimatedImage { } } -// Indicator -@available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) -extension AnimatedImage { - - /// Associate a indicator when loading image with url - /// - Parameter indicator: The indicator type, see `Indicator` - public func indicator(_ indicator: Indicator) -> some View where T : View { - return self.modifier(IndicatorViewModifier(status: indicatorStatus, indicator: indicator)) - } - - /// Associate a indicator when loading image with url, convenient method with block - /// - Parameter content: A view that describes the indicator. - public func indicator(@ViewBuilder content: @escaping (_ isAnimating: Binding, _ progress: Binding) -> T) -> some View where T : View { - return indicator(Indicator(content: content)) - } -} - #if DEBUG @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) struct AnimatedImage_Previews : PreviewProvider { diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index bc6a7ef9..457b6e63 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -9,6 +9,43 @@ import SwiftUI import SDWebImage +public enum WebImagePhase { + /// No image is loaded. + case empty + + /// An image succesfully loaded. + case success(Image) + + /// An image failed to load with an error. + case failure(Error) + + /// The loaded image, if any. + /// + /// If this value isn't `nil`, the image load operation has finished, + /// and you can use the image to update the view. You can use the image + /// directly, or you can modify it in some way. For example, you can add + /// a ``Image/resizable(capInsets:resizingMode:)`` modifier to make the + /// image resizable. + public var image: Image? { + switch self { + case let .success(image): + return image + case .empty, .failure: + return nil + } + } + + /// The error that occurred when attempting to load an image, if any. + public var error: Error? { + switch self { + case .empty, .success: + return nil + case let .failure(error): + return error + } + } +} + /// Data Binding Object, only properties in this object can support changes from user with @State and refresh @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) final class WebImageModel : ObservableObject { @@ -43,10 +80,12 @@ final class WebImageConfiguration: ObservableObject { /// A Image View type to load image from url. Supports static/animated image format. @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) -public struct WebImage : View { +public struct WebImage : View where Content: View { + var transaction: Transaction + var configurations: [(Image) -> Image] = [] - var placeholder: AnyView? + var content: (WebImagePhase) -> Content /// A Binding to control the animation. You can bind external logic to control the animation status. /// True to start animation, false to stop animation. @@ -72,7 +111,23 @@ public struct WebImage : View { /// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values. /// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold. /// - Parameter isAnimating: The binding for animation control. The binding value should be `true` when initialized to setup the correct animated image class. If not, you must provide the `.animatedImageClass` explicitly. When the animation started, this binding can been used to start / stop the animation. - public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding) { + public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding = .constant(true)) where Content == Image { + self.init(url: url, options: options, context: context, isAnimating: isAnimating) { phase in + phase.image ?? Image(platformImage: .empty) + } + } + + public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding = .constant(true), @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I: View, P: View { + self.init(url: url, options: options, context: context, isAnimating: isAnimating) { phase in + if let i = phase.image { + content(i) + } else { + placeholder() + } + } + } + + public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding = .constant(true), transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (WebImagePhase) -> Content) { self._isAnimating = isAnimating var context = context ?? [:] // provide animated image class if the initialized `isAnimating` is true, user can still custom the image class if they want @@ -89,27 +144,16 @@ public struct WebImage : View { let imageManager = ImageManager() _imageManager = StateObject(wrappedValue: imageManager) _indicatorStatus = ObservedObject(wrappedValue: imageManager.indicatorStatus) - } - - /// Create a web image with url, placeholder, custom options and context. - /// - Parameter url: The image url - /// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values. - /// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold. - public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil) { - self.init(url: url, options: options, context: context, isAnimating: .constant(true)) + + self.transaction = transaction + self.content = { phase in + content(phase) + } } public var body: some View { // Container return ZStack { - // This empty Image is used to receive container's level appear/disappear to start/stop player, reduce CPU usage - Image(platformImage: .empty) - .onAppear { - self.appearAction() - } - .onDisappear { - self.disappearAction() - } // Render Logic for actual animated image frame or static image if imageManager.image != nil && imageModel.url == imageManager.currentURL { if isAnimating && !imageManager.isIncremental { @@ -118,8 +162,8 @@ public struct WebImage : View { displayImage() } } else { + content((imageManager.error != nil) ? .failure(imageManager.error!) : .empty) // Load Logic - setupPlaceholder() .onPlatformAppear(appear: { self.setupManager() if (self.imageManager.error == nil) { @@ -145,7 +189,7 @@ public struct WebImage : View { /// Configure the platform image into the SwiftUI rendering image func configure(image: PlatformImage) -> some View { // Actual rendering SwiftUI image - let result: Image + var result: Image // NSImage works well with SwiftUI, include Vector and EXIF images. #if os(macOS) result = Image(nsImage: image) @@ -188,9 +232,12 @@ public struct WebImage : View { // Should not use `EmptyView`, which does not respect to the container's frame modifier // Using a empty image instead for better compatible - return configurations.reduce(result) { (previous, configuration) in + let i = configurations.reduce(result) { (previous, configuration) in configuration(previous) } + + // Apply view builder + return content(.success(i)) } /// Image Manager status @@ -279,25 +326,6 @@ public struct WebImage : View { } } } - - /// Placeholder View Support - func setupPlaceholder() -> some View { - // Don't use `Group` because it will trigger `.onAppear` and `.onDisappear` when condition view removed, treat placeholder as an entire component - let result: AnyView - if let placeholder = placeholder { - // If use `.delayPlaceholder`, the placeholder is applied after loading failed, hide during loading :) - if imageModel.options.contains(.delayPlaceholder) && imageManager.error == nil { - result = AnyView(configure(image: .empty)) - } else { - result = placeholder - } - } else { - result = AnyView(configure(image: .empty)) - } - // Custom ID to avoid SwiftUI engine cache the status, and does not call `onAppear` when placeholder not changed (See `ContentView.swift/ContentView2` case) - // Because we load the image url in placeholder's `onAppear`, it should be called to sync with state changes :) - return result.id(imageModel.url) - } } // Layout @@ -373,27 +401,6 @@ extension WebImage { // WebImage Modifier @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) extension WebImage { - - /// Associate a placeholder when loading image with url - /// - note: The differences between Placeholder and Indicator, is that placeholder does not supports animation, and return type is different - /// - Parameter content: A view that describes the placeholder. - public func placeholder(@ViewBuilder content: () -> T) -> WebImage where T : View { - var result = self - result.placeholder = AnyView(content()) - return result - } - - /// Associate a placeholder image when loading image with url - /// - note: This placeholder image will apply the same size and resizable from WebImage for convenience. If you don't want this, use the ViewBuilder one above instead - /// - Parameter image: A Image view that describes the placeholder. - public func placeholder(_ image: Image) -> WebImage { - return placeholder { - configurations.reduce(image) { (previous, configuration) in - configuration(previous) - } - } - } - /// Control the behavior to retry the failed loading when view become appears again /// - Parameter flag: Whether or not to retry the failed loading public func retryOnAppear(_ flag: Bool) -> WebImage { diff --git a/Tests/AnimatedImageTests.swift b/Tests/AnimatedImageTests.swift index 29472033..613539e8 100644 --- a/Tests/AnimatedImageTests.swift +++ b/Tests/AnimatedImageTests.swift @@ -142,7 +142,9 @@ class AnimatedImageTests: XCTestCase { func testAnimatedImageModifier() throws { let expectation = self.expectation(description: "WebImage modifier") let imageUrl = URL(string: "https://assets.sbnation.com/assets/2512203/dogflops.gif") - let imageView = AnimatedImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) + let imageView = AnimatedImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) { + Circle() + } let introspectView = imageView .onSuccess { _, _, _ in expectation.fulfill() @@ -161,11 +163,7 @@ class AnimatedImageTests: XCTestCase { XCTAssert(view.isKind(of: SDAnimatedImageView.self)) XCTAssertEqual(context.coordinator.userInfo?["foo"] as? String, "bar") } - .placeholder(PlatformImage()) - .placeholder { - Circle() - } - .indicator(SDWebImageActivityIndicator.medium) + .indicator(.activity) // Image .resizable() .renderingMode(.original) diff --git a/Tests/WebImageTests.swift b/Tests/WebImageTests.swift index d51efdbc..36090720 100644 --- a/Tests/WebImageTests.swift +++ b/Tests/WebImageTests.swift @@ -73,7 +73,11 @@ class WebImageTests: XCTestCase { func testWebImageModifier() throws { let expectation = self.expectation(description: "WebImage modifier") let imageUrl = URL(string: "https://raw.githubusercontent.com/ibireme/YYImage/master/Demo/YYImageDemo/mew_baseline.jpg") - let imageView = WebImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) + let imageView = WebImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) { image in + image.resizable() + } placeholder: { + Circle() + } let introspectView = imageView .onSuccess { _, _, _ in expectation.fulfill() @@ -83,10 +87,6 @@ class WebImageTests: XCTestCase { } .onProgress { _, _ in - } - .placeholder(.init(platformImage: PlatformImage())) - .placeholder { - Circle() } // Image .resizable()