From e1c32aea7d21e2a1c997097f95af95eeef1b920a Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Tue, 20 Sep 2022 20:26:35 +0800 Subject: [PATCH 1/8] Fix iOS 13 compatibility Revert back the onPlatformAppear to fix iOS 14+ behavior Use backport for all OSs --- SDWebImageSwiftUI/Classes/AnimatedImage.swift | 16 +--- SDWebImageSwiftUI/Classes/ImageManager.swift | 29 ++----- .../Classes/SwiftUICompatibility.swift | 84 +++++++++++++++++++ SDWebImageSwiftUI/Classes/WebImage.swift | 55 ++++++------ Tests/ImageManagerTests.swift | 4 +- 5 files changed, 122 insertions(+), 66 deletions(-) create mode 100644 SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift diff --git a/SDWebImageSwiftUI/Classes/AnimatedImage.swift b/SDWebImageSwiftUI/Classes/AnimatedImage.swift index acddf186..e454432f 100644 --- a/SDWebImageSwiftUI/Classes/AnimatedImage.swift +++ b/SDWebImageSwiftUI/Classes/AnimatedImage.swift @@ -101,15 +101,7 @@ final class AnimatedImageConfiguration: ObservableObject { /// A Image View type to load image from url, data or bundle. Supports animated and static image format. @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public struct AnimatedImage : PlatformViewRepresentable { - @SwiftUI.StateObject var imageModel_SwiftUI = AnimatedImageModel() - @Backport.StateObject var imageModel_Backport = AnimatedImageModel() - var imageModel: AnimatedImageModel { - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - return imageModel_SwiftUI - } else { - return imageModel_Backport - } - } + @ObservedObject var imageModel: AnimatedImageModel @ObservedObject var imageHandler = AnimatedImageHandler() @ObservedObject var imageLayout = AnimatedImageLayout() @ObservedObject var imageConfiguration = AnimatedImageConfiguration() @@ -186,11 +178,7 @@ public struct AnimatedImage : PlatformViewRepresentable { init(imageModel: AnimatedImageModel, isAnimating: Binding) { self._isAnimating = isAnimating - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - _imageModel_SwiftUI = SwiftUI.StateObject(wrappedValue: imageModel) - } else { - _imageModel_Backport = Backport.StateObject(wrappedValue: imageModel) - } + _imageModel = ObservedObject(wrappedValue: imageModel) } #if os(macOS) diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index 202f7ccf..94ce96e4 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -28,37 +28,24 @@ public final class ImageManager : ObservableObject { /// true means during incremental loading @Published public var isIncremental: Bool = false - var manager: SDWebImageManager? weak var currentOperation: SDWebImageOperation? = nil - var url: URL? - var options: SDWebImageOptions = [] - var context: [SDWebImageContextOption : Any]? = nil var successBlock: ((PlatformImage, Data?, SDImageCacheType) -> Void)? var failureBlock: ((Error) -> Void)? var progressBlock: ((Int, Int) -> Void)? - /// Create a image manager for loading the specify url, with custom options and context. + public init() {} + + /// Start to load the url operation /// - 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.url = url - self.options = options - self.context = context - if let manager = context?[.customManager] as? SDWebImageManager { - self.manager = manager + public func load(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil) { + let manager: SDWebImageManager + if let customManager = context?[.customManager] as? SDWebImageManager { + manager = customManager } else { - self.manager = .shared - } - } - - init() {} - - /// Start to load the url operation - public func load() { - guard let manager = manager else { - return + manager = .shared } if currentOperation != nil { return diff --git a/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift new file mode 100644 index 00000000..6a3b9873 --- /dev/null +++ b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift @@ -0,0 +1,84 @@ +/* + * This file is part of the SDWebImage package. + * (c) DreamPiggy + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import Foundation +import SwiftUI + +#if os(iOS) || os(tvOS) || os(macOS) + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +struct PlatformAppear: PlatformViewRepresentable { + let appearAction: () -> Void + let disappearAction: () -> Void + + #if os(iOS) || os(tvOS) + func makeUIView(context: Context) -> some UIView { + let view = PlatformAppearView() + view.appearAction = appearAction + view.disappearAction = disappearAction + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + #endif + #if os(macOS) + func makeNSView(context: Context) -> some NSView { + let view = PlatformAppearView() + view.appearAction = appearAction + view.disappearAction = disappearAction + return view + } + + func updateNSView(_ nsView: NSViewType, context: Context) {} + #endif +} + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +class PlatformAppearView: PlatformView { + var appearAction: () -> Void = {} + var disappearAction: () -> Void = {} + + #if os(iOS) || os(tvOS) + override func willMove(toWindow newWindow: UIWindow?) { + if newWindow != nil { + appearAction() + } else { + disappearAction() + } + } + #endif + + #if os(macOS) + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow != nil { + appearAction() + } else { + disappearAction() + } + } + #endif +} + +#endif + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +extension View { + /// Used UIKit/AppKit behavior to detect the SwiftUI view's visibility. + /// This hack is because of SwiftUI 1.0/2.0 buggy behavior. The built-in `onAppear` and `onDisappear` is so massive on some cases. Where UIKit/AppKit is solid. + /// - Parameters: + /// - appear: The action when view appears + /// - disappear: The action when view disappears + /// - Returns: Some view + func onPlatformAppear(appear: @escaping () -> Void = {}, disappear: @escaping () -> Void = {}) -> some View { + #if os(iOS) || os(tvOS) || os(macOS) + return self.background(PlatformAppear(appearAction: appear, disappearAction: disappear)) + #else + return self.onAppear(perform: appear).onDisappear(perform: disappear) + #endif + } +} diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 07908861..01704998 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -9,6 +9,15 @@ import SwiftUI import SDWebImage +/// Data Binding Object, only properties in this object can support changes from user with @State and refresh +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +final class WebImageModel : ObservableObject { + /// URL image + @Published var url: URL? + @Published var webOptions: SDWebImageOptions = [] + @Published var webContext: [SDWebImageContextOption : Any]? = nil +} + /// Completion Handler Binding Object, supports dynamic @State changes @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) final class WebImageHandler: ObservableObject { @@ -43,6 +52,9 @@ public struct WebImage : View { /// True to start animation, false to stop animation. @Binding public var isAnimating: Bool + /// A observed object to pass through the image model to manager + @ObservedObject var imageModel: WebImageModel + /// A observed object to pass through the image handler to manager @ObservedObject var imageHandler = WebImageHandler() @@ -52,25 +64,10 @@ public struct WebImage : View { /// A observed object to pass through the image manager loading status to indicator @ObservedObject var indicatorStatus = IndicatorStatus() - @SwiftUI.StateObject var imagePlayer_SwiftUI = ImagePlayer() - @Backport.StateObject var imagePlayer_Backport = ImagePlayer() - var imagePlayer: ImagePlayer { - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - return imagePlayer_SwiftUI - } else { - return imagePlayer_Backport - } - } + @ObservedObject var imagePlayer = ImagePlayer() - @SwiftUI.StateObject var imageManager_SwiftUI = ImageManager() - @Backport.StateObject var imageManager_Backport = ImageManager() - var imageManager: ImageManager { - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - return imageManager_SwiftUI - } else { - return imageManager_Backport - } - } + // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support + @Backport.StateObject var imageManager = ImageManager() /// Create a web image with url, placeholder, custom options and context. Optional can support animated image using Binding. /// - Parameter url: The image url @@ -86,11 +83,11 @@ public struct WebImage : View { context[.animatedImageClass] = SDAnimatedImage.self } } - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - _imageManager_SwiftUI = SwiftUI.StateObject(wrappedValue: ImageManager(url: url, options: options, context: context)) - } else { - _imageManager_Backport = Backport.StateObject(wrappedValue: ImageManager(url: url, options: options, context: context)) - } + let imageModel = WebImageModel() + imageModel.url = url + imageModel.webOptions = options + imageModel.webContext = context + _imageModel = ObservedObject(wrappedValue: imageModel) } /// Create a web image with url, placeholder, custom options and context. @@ -128,24 +125,24 @@ public struct WebImage : View { } } else { setupPlaceholder() - .onAppear { + .onPlatformAppear(appear: { self.imageManager.successBlock = self.imageHandler.successBlock self.imageManager.failureBlock = self.imageHandler.failureBlock self.imageManager.progressBlock = self.imageHandler.progressBlock // Load remote image when first appear - self.imageManager.load() + self.imageManager.load(url: imageModel.url, options: imageModel.webOptions, context: imageModel.webContext) guard self.imageConfiguration.retryOnAppear else { return } // When using prorgessive loading, the new partial image will cause onAppear. Filter this case if self.imageManager.image == nil && !self.imageManager.isIncremental { - self.imageManager.load() + self.imageManager.load(url: imageModel.url, options: imageModel.webOptions, context: imageModel.webContext) } - }.onDisappear { + }, disappear: { guard self.imageConfiguration.cancelOnDisappear else { return } // When using prorgessive loading, the previous partial image will cause onDisappear. Filter this case if self.imageManager.image == nil && !self.imageManager.isIncremental { self.imageManager.cancel() } - }.onReceive(imageManager.objectWillChange) { _ in + }).onReceive(imageManager.objectWillChange) { _ in indicatorStatus.isLoading = imageManager.isLoading indicatorStatus.progress = imageManager.progress } @@ -228,7 +225,7 @@ public struct WebImage : View { // Don't use `Group` because it will trigger `.onAppear` and `.onDisappear` when condition view removed, treat placeholder as an entire component if let placeholder = placeholder { // If use `.delayPlaceholder`, the placeholder is applied after loading failed, hide during loading :) - if imageManager.options.contains(.delayPlaceholder) && imageManager.isLoading { + if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.isLoading { return AnyView(configure(image: .empty)) } else { return placeholder diff --git a/Tests/ImageManagerTests.swift b/Tests/ImageManagerTests.swift index e5d4a099..002ab91a 100644 --- a/Tests/ImageManagerTests.swift +++ b/Tests/ImageManagerTests.swift @@ -18,7 +18,7 @@ class ImageManagerTests: XCTestCase { func testImageManager() throws { let expectation = self.expectation(description: "ImageManager usage with Combine") let imageUrl = URL(string: "https://via.placeholder.com/500x500.jpg") - let imageManager = ImageManager(url: imageUrl) + let imageManager = ImageManager() imageManager.setOnSuccess { image, cacheType, data in XCTAssertNotNil(image) expectation.fulfill() @@ -29,7 +29,7 @@ class ImageManagerTests: XCTestCase { imageManager.setOnProgress { receivedSize, expectedSize in } - imageManager.load() + imageManager.load(url: imageUrl) XCTAssertNotNil(imageManager.currentOperation) let sub = imageManager.objectWillChange .subscribe(on: RunLoop.main) From 6590afdd3a6dd4e1d6b67d45049b4c4020aafe94 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Wed, 21 Sep 2022 20:35:44 +0800 Subject: [PATCH 2/8] Use manager published IndicatorStatus to pass update the indicator Fix warning --- SDWebImageSwiftUI/Classes/ImageManager.swift | 16 +++++++--------- .../Classes/SwiftUICompatibility.swift | 18 +++++++++++++----- SDWebImageSwiftUI/Classes/WebImage.swift | 12 +++--------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index 94ce96e4..283de3c6 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -21,12 +21,10 @@ public final class ImageManager : ObservableObject { @Published public var cacheType: SDImageCacheType = .none /// loading error, you can grab the error code and reason listed in `SDWebImageErrorDomain`, to provide a user interface about the error reason @Published public var error: Error? - /// whether network is loading or cache is querying, should only be used for indicator binding - @Published public var isLoading: Bool = false - /// network progress, should only be used for indicator binding - @Published public var progress: Double = 0 /// true means during incremental loading @Published public var isIncremental: Bool = false + /// A observed object to pass through the image manager loading status to indicator + @Published public var indicatorStatus = IndicatorStatus() weak var currentOperation: SDWebImageOperation? = nil @@ -50,7 +48,7 @@ public final class ImageManager : ObservableObject { if currentOperation != nil { return } - self.isLoading = true + self.indicatorStatus.isLoading = true currentOperation = manager.loadImage(with: url, options: options, context: context, progress: { [weak self] (receivedSize, expectedSize, _) in guard let self = self else { return @@ -62,7 +60,7 @@ public final class ImageManager : ObservableObject { progress = 0 } DispatchQueue.main.async { - self.progress = progress + self.indicatorStatus.progress = progress } self.progressBlock?(receivedSize, expectedSize) }) { [weak self] (image, data, error, cacheType, finished, _) in @@ -82,8 +80,8 @@ public final class ImageManager : ObservableObject { if finished { self.imageData = data self.cacheType = cacheType - self.isLoading = false - self.progress = 1 + self.indicatorStatus.isLoading = false + self.indicatorStatus.progress = 1 if let image = image { self.successBlock?(image, data, cacheType) } else { @@ -98,8 +96,8 @@ public final class ImageManager : ObservableObject { if let operation = currentOperation { operation.cancel() currentOperation = nil - isLoading = false } + indicatorStatus.isLoading = false } } diff --git a/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift index 6a3b9873..f2cb6dd2 100644 --- a/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift +++ b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift @@ -46,9 +46,13 @@ class PlatformAppearView: PlatformView { #if os(iOS) || os(tvOS) override func willMove(toWindow newWindow: UIWindow?) { if newWindow != nil { - appearAction() + DispatchQueue.main.async { + self.appearAction() + } } else { - disappearAction() + DispatchQueue.main.async { + self.disappearAction() + } } } #endif @@ -56,9 +60,13 @@ class PlatformAppearView: PlatformView { #if os(macOS) override func viewWillMove(toWindow newWindow: NSWindow?) { if newWindow != nil { - appearAction() + DispatchQueue.main.async { + self.appearAction() + } } else { - disappearAction() + DispatchQueue.main.async { + self.disappearAction() + } } } #endif @@ -76,7 +84,7 @@ extension View { /// - Returns: Some view func onPlatformAppear(appear: @escaping () -> Void = {}, disappear: @escaping () -> Void = {}) -> some View { #if os(iOS) || os(tvOS) || os(macOS) - return self.background(PlatformAppear(appearAction: appear, disappearAction: disappear)) + return self.overlay(PlatformAppear(appearAction: appear, disappearAction: disappear)) #else return self.onAppear(perform: appear).onDisappear(perform: disappear) #endif diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 01704998..dff6c739 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -61,9 +61,6 @@ public struct WebImage : View { /// A observed object to pass through the image configuration to player @ObservedObject var imageConfiguration = WebImageConfiguration() - /// A observed object to pass through the image manager loading status to indicator - @ObservedObject var indicatorStatus = IndicatorStatus() - @ObservedObject var imagePlayer = ImagePlayer() // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support @@ -142,10 +139,7 @@ public struct WebImage : View { if self.imageManager.image == nil && !self.imageManager.isIncremental { self.imageManager.cancel() } - }).onReceive(imageManager.objectWillChange) { _ in - indicatorStatus.isLoading = imageManager.isLoading - indicatorStatus.progress = imageManager.progress - } + }) } } } @@ -225,7 +219,7 @@ public struct WebImage : View { // Don't use `Group` because it will trigger `.onAppear` and `.onDisappear` when condition view removed, treat placeholder as an entire component if let placeholder = placeholder { // If use `.delayPlaceholder`, the placeholder is applied after loading failed, hide during loading :) - if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.isLoading { + if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.indicatorStatus.isLoading { return AnyView(configure(image: .empty)) } else { return placeholder @@ -352,7 +346,7 @@ extension WebImage { /// 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)) + return self.modifier(IndicatorViewModifier(status: imageManager.indicatorStatus, indicator: indicator)) } /// Associate a indicator when loading image with url, convenient method with block From ce5340fd08ca48ec0f99d03a989c5ad60d83e338 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Wed, 21 Sep 2022 20:45:12 +0800 Subject: [PATCH 3/8] Fix the delayPlaceholder behavior and Player behavior when parent View change state --- SDWebImageSwiftUI/Classes/WebImage.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index dff6c739..6ed6a660 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -61,7 +61,8 @@ public struct WebImage : View { /// A observed object to pass through the image configuration to player @ObservedObject var imageConfiguration = WebImageConfiguration() - @ObservedObject var imagePlayer = ImagePlayer() + // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support + @Backport.StateObject var imagePlayer = ImagePlayer() // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support @Backport.StateObject var imageManager = ImageManager() @@ -219,7 +220,7 @@ public struct WebImage : View { // Don't use `Group` because it will trigger `.onAppear` and `.onDisappear` when condition view removed, treat placeholder as an entire component if let placeholder = placeholder { // If use `.delayPlaceholder`, the placeholder is applied after loading failed, hide during loading :) - if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.indicatorStatus.isLoading { + if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.error == nil { return AnyView(configure(image: .empty)) } else { return placeholder From d281bde03772687fa0bf659ac1c7788ec186c270 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Wed, 21 Sep 2022 23:01:07 +0800 Subject: [PATCH 4/8] Fix the State change behavior again Using the `StateObject` to check and refresh to the latest status, using `currentURL` and `currentAnimatedImage` to check lazily --- .../SDWebImageSwiftUIDemo/ContentView.swift | 36 +++++++++++++++ SDWebImageSwiftUI.xcodeproj/project.pbxproj | 10 ++++ SDWebImageSwiftUI/Classes/ImageManager.swift | 9 ++-- SDWebImageSwiftUI/Classes/ImagePlayer.swift | 5 +- .../Classes/SwiftUICompatibility.swift | 2 +- SDWebImageSwiftUI/Classes/WebImage.swift | 46 ++++++++++++------- 6 files changed, 87 insertions(+), 21 deletions(-) diff --git a/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index 4c6ea01d..fae1c09c 100644 --- a/Example/SDWebImageSwiftUIDemo/ContentView.swift +++ b/Example/SDWebImageSwiftUIDemo/ContentView.swift @@ -34,6 +34,42 @@ extension Indicator where T == ProgressView { } #endif +// Test Switching url using @State +struct ContentView2: View { + @State var imageURLs = [ + "https://raw.githubusercontent.com/recurser/exif-orientation-examples/master/Landscape_1.jpg", + "https://raw.githubusercontent.com/recurser/exif-orientation-examples/master/Landscape_2.jpg", + "http://assets.sbnation.com/assets/2512203/dogflops.gif", + "https://raw.githubusercontent.com/liyong03/YLGIFImage/master/YLGIFImageDemo/YLGIFImageDemo/joy.gif" + ] + @State var animated: Bool = false // You can change between WebImage/AnimatedImage + @State var imageIndex : Int = 0 + var body: some View { + Group { + Text("\(animated ? "AnimatedImage" : "WebImage") - \((imageURLs[imageIndex] as NSString).lastPathComponent)") + Spacer() + if self.animated { + AnimatedImage(url:URL(string: imageURLs[imageIndex])) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + WebImage(url:URL(string: imageURLs[imageIndex])) + .resizable() + .aspectRatio(contentMode: .fit) + } + Spacer() + Button("Next") { + if imageIndex + 1 >= imageURLs.count { + imageIndex = 0 + } else { + imageIndex += 1 + } + } + Toggle("Switch", isOn: $animated) + } + } +} + struct ContentView: View { @State var imageURLs = [ "http://assets.sbnation.com/assets/2512203/dogflops.gif", diff --git a/SDWebImageSwiftUI.xcodeproj/project.pbxproj b/SDWebImageSwiftUI.xcodeproj/project.pbxproj index 9bacddc1..1fbde810 100644 --- a/SDWebImageSwiftUI.xcodeproj/project.pbxproj +++ b/SDWebImageSwiftUI.xcodeproj/project.pbxproj @@ -23,6 +23,10 @@ 326E480B23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E480923431C0F00C633E9 /* ImageViewWrapper.swift */; }; 326E480C23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E480923431C0F00C633E9 /* ImageViewWrapper.swift */; }; 326E480D23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E480923431C0F00C633E9 /* ImageViewWrapper.swift */; }; + 32B79C9528DB40430088C432 /* SwiftUICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */; }; + 32B79C9628DB40430088C432 /* SwiftUICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */; }; + 32B79C9728DB40430088C432 /* SwiftUICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */; }; + 32B79C9828DB40430088C432 /* SwiftUICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */; }; 32B933E523659A1900BB7CAD /* Transition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B933E423659A1900BB7CAD /* Transition.swift */; }; 32B933E623659A1900BB7CAD /* Transition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B933E423659A1900BB7CAD /* Transition.swift */; }; 32B933E723659A1900BB7CAD /* Transition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B933E423659A1900BB7CAD /* Transition.swift */; }; @@ -87,6 +91,7 @@ 326B8486236335110011BDFB /* ActivityIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; 326B848B236335400011BDFB /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; 326E480923431C0F00C633E9 /* ImageViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewWrapper.swift; sourceTree = ""; }; + 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUICompatibility.swift; sourceTree = ""; }; 32B933E423659A1900BB7CAD /* Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transition.swift; sourceTree = ""; }; 32BC086F28D23D35002451BD /* StateObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateObject.swift; sourceTree = ""; }; 32BC087028D23D35002451BD /* OnChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnChange.swift; sourceTree = ""; }; @@ -233,6 +238,7 @@ 32C43DDE22FD54C600BE87F5 /* WebImage.swift */, 32C43DDF22FD54C600BE87F5 /* AnimatedImage.swift */, 32C43E3122FD5DE100BE87F5 /* SDWebImageSwiftUI.swift */, + 32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */, 326E480923431C0F00C633E9 /* ImageViewWrapper.swift */, 32D26A012446B546005905DA /* Image.swift */, ); @@ -457,6 +463,7 @@ 326B84822363350C0011BDFB /* Indicator.swift in Sources */, 32C43E3222FD5DE100BE87F5 /* SDWebImageSwiftUI.swift in Sources */, 326E480A23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */, + 32B79C9528DB40430088C432 /* SwiftUICompatibility.swift in Sources */, 326B8487236335110011BDFB /* ActivityIndicator.swift in Sources */, 32C43E1622FD583700BE87F5 /* ImageManager.swift in Sources */, 32C43E1822FD583700BE87F5 /* AnimatedImage.swift in Sources */, @@ -479,6 +486,7 @@ 326B84832363350C0011BDFB /* Indicator.swift in Sources */, 32C43E3322FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */, 326E480B23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */, + 32B79C9628DB40430088C432 /* SwiftUICompatibility.swift in Sources */, 326B8488236335110011BDFB /* ActivityIndicator.swift in Sources */, 32C43E1922FD583700BE87F5 /* ImageManager.swift in Sources */, 32C43E1B22FD583700BE87F5 /* AnimatedImage.swift in Sources */, @@ -501,6 +509,7 @@ 326B84842363350C0011BDFB /* Indicator.swift in Sources */, 32C43E3422FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */, 326E480C23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */, + 32B79C9728DB40430088C432 /* SwiftUICompatibility.swift in Sources */, 326B8489236335110011BDFB /* ActivityIndicator.swift in Sources */, 32C43E1C22FD583800BE87F5 /* ImageManager.swift in Sources */, 32C43E1E22FD583800BE87F5 /* AnimatedImage.swift in Sources */, @@ -523,6 +532,7 @@ 326B84852363350C0011BDFB /* Indicator.swift in Sources */, 32C43E3522FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */, 326E480D23431C0F00C633E9 /* ImageViewWrapper.swift in Sources */, + 32B79C9828DB40430088C432 /* SwiftUICompatibility.swift in Sources */, 326B848A236335110011BDFB /* ActivityIndicator.swift in Sources */, 32C43E1F22FD583800BE87F5 /* ImageManager.swift in Sources */, 32C43E2122FD583800BE87F5 /* AnimatedImage.swift in Sources */, diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index 283de3c6..b8d5fe49 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -27,7 +27,8 @@ public final class ImageManager : ObservableObject { @Published public var indicatorStatus = IndicatorStatus() weak var currentOperation: SDWebImageOperation? = nil - + + var currentURL: URL? var successBlock: ((PlatformImage, Data?, SDImageCacheType) -> Void)? var failureBlock: ((Error) -> Void)? var progressBlock: ((Int, Int) -> Void)? @@ -45,10 +46,12 @@ public final class ImageManager : ObservableObject { } else { manager = .shared } - if currentOperation != nil { + if (currentOperation != nil && currentURL == url) { return } - self.indicatorStatus.isLoading = true + currentURL = url + indicatorStatus.isLoading = true + indicatorStatus.progress = 0 currentOperation = manager.loadImage(with: url, options: options, context: context, progress: { [weak self] (receivedSize, expectedSize, _) in guard let self = self else { return diff --git a/SDWebImageSwiftUI/Classes/ImagePlayer.swift b/SDWebImageSwiftUI/Classes/ImagePlayer.swift index 4f25f363..e0ceded6 100644 --- a/SDWebImageSwiftUI/Classes/ImagePlayer.swift +++ b/SDWebImageSwiftUI/Classes/ImagePlayer.swift @@ -45,6 +45,8 @@ public final class ImagePlayer : ObservableObject { /// Current playing loop count @Published public var currentLoopCount: UInt = 0 + var currentAnimatedImage: (PlatformImage & SDAnimatedImageProvider)? + /// Whether current player is valid for playing. This will check the internal player exist or not public var isValid: Bool { player != nil @@ -97,10 +99,11 @@ public final class ImagePlayer : ObservableObject { /// Setup the player using Animated Image. /// After setup, you can always check `isValid` status, or call `startPlaying` to play the animation. /// - Parameter image: animated image - public func setupPlayer(animatedImage: SDAnimatedImageProvider) { + public func setupPlayer(animatedImage: PlatformImage & SDAnimatedImageProvider) { if isValid { return } + currentAnimatedImage = animatedImage if let imagePlayer = SDAnimatedImagePlayer(provider: animatedImage) { imagePlayer.animationFrameHandler = { [weak self] (index, frame) in self?.currentFrameIndex = index diff --git a/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift index f2cb6dd2..e7a46393 100644 --- a/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift +++ b/SDWebImageSwiftUI/Classes/SwiftUICompatibility.swift @@ -84,7 +84,7 @@ extension View { /// - Returns: Some view func onPlatformAppear(appear: @escaping () -> Void = {}, disappear: @escaping () -> Void = {}) -> some View { #if os(iOS) || os(tvOS) || os(macOS) - return self.overlay(PlatformAppear(appearAction: appear, disappearAction: disappear)) + return self.background(PlatformAppear(appearAction: appear, disappearAction: disappear)) #else return self.onAppear(perform: appear).onDisappear(perform: disappear) #endif diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 6ed6a660..97b896d1 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -14,8 +14,8 @@ import SDWebImage final class WebImageModel : ObservableObject { /// URL image @Published var url: URL? - @Published var webOptions: SDWebImageOptions = [] - @Published var webContext: [SDWebImageContextOption : Any]? = nil + @Published var options: SDWebImageOptions = [] + @Published var context: [SDWebImageContextOption : Any]? = nil } /// Completion Handler Binding Object, supports dynamic @State changes @@ -61,11 +61,13 @@ public struct WebImage : View { /// A observed object to pass through the image configuration to player @ObservedObject var imageConfiguration = WebImageConfiguration() + @ObservedObject var indicatorStatus : IndicatorStatus + // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support @Backport.StateObject var imagePlayer = ImagePlayer() // FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support - @Backport.StateObject var imageManager = ImageManager() + @Backport.StateObject var imageManager : ImageManager /// Create a web image with url, placeholder, custom options and context. Optional can support animated image using Binding. /// - Parameter url: The image url @@ -83,9 +85,12 @@ public struct WebImage : View { } let imageModel = WebImageModel() imageModel.url = url - imageModel.webOptions = options - imageModel.webContext = context + imageModel.options = options + imageModel.context = context _imageModel = ObservedObject(wrappedValue: imageModel) + let imageManager = ImageManager() + _imageManager = Backport.StateObject(wrappedValue: imageManager) + _indicatorStatus = ObservedObject(wrappedValue: imageManager.indicatorStatus) } /// Create a web image with url, placeholder, custom options and context. @@ -98,7 +103,7 @@ public struct WebImage : View { public var body: some View { return Group { - if let image = imageManager.image { + if imageManager.image != nil && imageModel.url == imageManager.currentURL { if isAnimating && !imageManager.isIncremental { setupPlayer() .onDisappear { @@ -118,7 +123,7 @@ public struct WebImage : View { if let currentFrame = imagePlayer.currentFrame { configure(image: currentFrame) } else { - configure(image: image) + configure(image: imageManager.image!) } } } else { @@ -127,17 +132,19 @@ public struct WebImage : View { self.imageManager.successBlock = self.imageHandler.successBlock self.imageManager.failureBlock = self.imageHandler.failureBlock self.imageManager.progressBlock = self.imageHandler.progressBlock - // Load remote image when first appear - self.imageManager.load(url: imageModel.url, options: imageModel.webOptions, context: imageModel.webContext) + if (self.imageManager.error == nil) { + // Load remote image when first appear + self.imageManager.load(url: imageModel.url, options: imageModel.options, context: imageModel.context) + } guard self.imageConfiguration.retryOnAppear else { return } // When using prorgessive loading, the new partial image will cause onAppear. Filter this case - if self.imageManager.image == nil && !self.imageManager.isIncremental { - self.imageManager.load(url: imageModel.url, options: imageModel.webOptions, context: imageModel.webContext) + if self.imageManager.error != nil && !self.imageManager.isIncremental { + self.imageManager.load(url: imageModel.url, options: imageModel.options, context: imageModel.context) } }, disappear: { guard self.imageConfiguration.cancelOnDisappear else { return } // When using prorgessive loading, the previous partial image will cause onDisappear. Filter this case - if self.imageManager.image == nil && !self.imageManager.isIncremental { + if self.imageManager.error != nil && !self.imageManager.isIncremental { self.imageManager.cancel() } }) @@ -196,18 +203,25 @@ public struct WebImage : View { /// Animated Image Support func setupPlayer() -> some View { - if let currentFrame = imagePlayer.currentFrame { + if let currentFrame = imagePlayer.currentFrame, imagePlayer.currentAnimatedImage == imageManager.image! { return configure(image: currentFrame).onAppear { self.imagePlayer.startPlaying() } } else { return configure(image: imageManager.image!).onAppear { - if let animatedImage = imageManager.image as? SDAnimatedImageProvider { + self.imagePlayer.stopPlaying() + if let animatedImage = imageManager.image as? PlatformImage & SDAnimatedImageProvider { + // Clear previous status + self.imagePlayer.player = nil; + self.imagePlayer.currentFrame = nil; + self.imagePlayer.currentFrameIndex = 0; + self.imagePlayer.currentLoopCount = 0; self.imagePlayer.customLoopCount = self.imageConfiguration.customLoopCount self.imagePlayer.maxBufferSize = self.imageConfiguration.maxBufferSize self.imagePlayer.runLoopMode = self.imageConfiguration.runLoopMode self.imagePlayer.playbackMode = self.imageConfiguration.playbackMode self.imagePlayer.playbackRate = self.imageConfiguration.playbackRate + // Setup new player self.imagePlayer.setupPlayer(animatedImage: animatedImage) self.imagePlayer.startPlaying() } @@ -220,7 +234,7 @@ public struct WebImage : View { // Don't use `Group` because it will trigger `.onAppear` and `.onDisappear` when condition view removed, treat placeholder as an entire component if let placeholder = placeholder { // If use `.delayPlaceholder`, the placeholder is applied after loading failed, hide during loading :) - if imageModel.webOptions.contains(.delayPlaceholder) && imageManager.error == nil { + if imageModel.options.contains(.delayPlaceholder) && imageManager.error == nil { return AnyView(configure(image: .empty)) } else { return placeholder @@ -347,7 +361,7 @@ extension WebImage { /// 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: imageManager.indicatorStatus, indicator: indicator)) + return self.modifier(IndicatorViewModifier(status: indicatorStatus, indicator: indicator)) } /// Associate a indicator when loading image with url, convenient method with block From 1d7efeccc4b9bc0915450ea29aeca69fec97326c Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Thu, 22 Sep 2022 00:00:40 +0800 Subject: [PATCH 5/8] Fix the empty placeholder may cause onAppear does not called Move the reset logic into helper function setupManager --- .../SDWebImageSwiftUIDemo/ContentView.swift | 4 ++ SDWebImageSwiftUI/Classes/ImageManager.swift | 1 + SDWebImageSwiftUI/Classes/WebImage.swift | 65 ++++++++++++------- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index fae1c09c..18b73ce3 100644 --- a/Example/SDWebImageSwiftUIDemo/ContentView.swift +++ b/Example/SDWebImageSwiftUIDemo/ContentView.swift @@ -65,6 +65,10 @@ struct ContentView2: View { imageIndex += 1 } } + Button("Reload") { + SDImageCache.shared.clearMemory() + SDImageCache.shared.clearDisk(onCompletion: nil) + } Toggle("Switch", isOn: $animated) } } diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index b8d5fe49..d9bce8fa 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -101,6 +101,7 @@ public final class ImageManager : ObservableObject { currentOperation = nil } indicatorStatus.isLoading = false + currentURL = nil } } diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 97b896d1..350e2dd7 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -103,22 +103,10 @@ public struct WebImage : View { public var body: some View { return Group { + // Render Logic if imageManager.image != nil && imageModel.url == imageManager.currentURL { if isAnimating && !imageManager.isIncremental { setupPlayer() - .onDisappear { - // Only stop the player which is not intermediate status - if !imagePlayer.isWaiting { - if self.imageConfiguration.pausable { - self.imagePlayer.pausePlaying() - } else { - self.imagePlayer.stopPlaying() - } - if self.imageConfiguration.purgeable { - self.imagePlayer.clearFrameBuffer() - } - } - } } else { if let currentFrame = imagePlayer.currentFrame { configure(image: currentFrame) @@ -127,11 +115,10 @@ public struct WebImage : View { } } } else { + // Load Logic setupPlaceholder() .onPlatformAppear(appear: { - self.imageManager.successBlock = self.imageHandler.successBlock - self.imageManager.failureBlock = self.imageHandler.failureBlock - self.imageManager.progressBlock = self.imageHandler.progressBlock + setupManager() if (self.imageManager.error == nil) { // Load remote image when first appear self.imageManager.load(url: imageModel.url, options: imageModel.options, context: imageModel.context) @@ -201,14 +188,46 @@ public struct WebImage : View { } } + /// Image Manager status + func setupManager() { + self.imageManager.successBlock = self.imageHandler.successBlock + self.imageManager.failureBlock = self.imageHandler.failureBlock + self.imageManager.progressBlock = self.imageHandler.progressBlock + if imageModel.url != imageManager.currentURL { + imageManager.cancel() + imageManager.image = nil + imageManager.imageData = nil + imageManager.cacheType = .none + imageManager.error = nil + imageManager.isIncremental = false + imageManager.indicatorStatus.isLoading = false + imageManager.indicatorStatus.progress = 0 + } + } + /// Animated Image Support func setupPlayer() -> some View { + let disappearAction = { + // Only stop the player which is not intermediate status + if !imagePlayer.isWaiting { + if self.imageConfiguration.pausable { + self.imagePlayer.pausePlaying() + } else { + self.imagePlayer.stopPlaying() + } + if self.imageConfiguration.purgeable { + self.imagePlayer.clearFrameBuffer() + } + } + } if let currentFrame = imagePlayer.currentFrame, imagePlayer.currentAnimatedImage == imageManager.image! { - return configure(image: currentFrame).onAppear { + return configure(image: currentFrame).onPlatformAppear(appear: { self.imagePlayer.startPlaying() - } + }, disappear: { + disappearAction() + }) } else { - return configure(image: imageManager.image!).onAppear { + return configure(image: imageManager.image!).onPlatformAppear(appear: { self.imagePlayer.stopPlaying() if let animatedImage = imageManager.image as? PlatformImage & SDAnimatedImageProvider { // Clear previous status @@ -225,7 +244,9 @@ public struct WebImage : View { self.imagePlayer.setupPlayer(animatedImage: animatedImage) self.imagePlayer.startPlaying() } - } + }, disappear: { + disappearAction() + }) } } @@ -235,12 +256,12 @@ public struct WebImage : View { 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 { - return AnyView(configure(image: .empty)) + return AnyView(configure(image: .empty).id(UUID())) // UUID to avoid SwiftUI engine cache the status and does not call `onAppear` } else { return placeholder } } else { - return AnyView(configure(image: .empty)) + return AnyView(configure(image: .empty).id(UUID())) // UUID to avoid SwiftUI engine cache the status and does not call `onAppear` } } } From d18693909b6f17fe0024478cc582b6d7ac9a8a89 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Thu, 22 Sep 2022 00:13:22 +0800 Subject: [PATCH 6/8] Fix watchOS demo compile issue --- Example/SDWebImageSwiftUIDemo/ContentView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index 18b73ce3..fc17b74d 100644 --- a/Example/SDWebImageSwiftUIDemo/ContentView.swift +++ b/Example/SDWebImageSwiftUIDemo/ContentView.swift @@ -48,6 +48,11 @@ struct ContentView2: View { Group { Text("\(animated ? "AnimatedImage" : "WebImage") - \((imageURLs[imageIndex] as NSString).lastPathComponent)") Spacer() + #if os(watchOS) + WebImage(url:URL(string: imageURLs[imageIndex])) + .resizable() + .aspectRatio(contentMode: .fit) + #else if self.animated { AnimatedImage(url:URL(string: imageURLs[imageIndex])) .resizable() @@ -57,6 +62,7 @@ struct ContentView2: View { .resizable() .aspectRatio(contentMode: .fit) } + #endif Spacer() Button("Next") { if imageIndex + 1 >= imageURLs.count { From abd9102f6b4db9d6b07ed6e2fbc7889120ae6078 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Thu, 22 Sep 2022 15:03:20 +0800 Subject: [PATCH 7/8] Update the readme about when using in List/LazyStack/LazyGrid --- .../SDWebImageSwiftUIDemo/ContentView.swift | 97 +++++++++++-------- README.md | 58 +++++++++-- SDWebImageSwiftUI/Classes/WebImage.swift | 10 +- 3 files changed, 111 insertions(+), 54 deletions(-) diff --git a/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index fc17b74d..a3619c90 100644 --- a/Example/SDWebImageSwiftUIDemo/ContentView.swift +++ b/Example/SDWebImageSwiftUIDemo/ContentView.swift @@ -104,6 +104,58 @@ struct ContentView: View { @State var animated: Bool = false // You can change between WebImage/AnimatedImage @EnvironmentObject var settings: UserSettings + // Used to avoid https://twitter.com/fatbobman/status/1572507700436807683?s=20&t=5rfj6BUza5Jii-ynQatCFA + struct ItemView: View { + @Binding var animated: Bool + @State var url: String + var body: some View { + NavigationLink(destination: DetailView(url: url, animated: self.animated)) { + HStack { + if self.animated { + #if os(macOS) || os(iOS) || os(tvOS) + AnimatedImage(url: URL(string:url), isAnimating: .constant(true)) + .onViewUpdate { view, context in + #if os(macOS) + view.toolTip = url + #endif + } + .indicator(SDWebImageActivityIndicator.medium) + /** + .placeholder(UIImage(systemName: "photo")) + */ + .transition(.fade) + .resizable() + .scaledToFit() + .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) + #else + WebImage(url: URL(string:url), isAnimating: self.$animated) + .resizable() + .indicator(.activity) + .transition(.fade(duration: 0.5)) + .scaledToFit() + .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) + #endif + } else { + WebImage(url: URL(string:url), isAnimating: .constant(true)) + .resizable() + /** + .placeholder { + Image(systemName: "photo") + } + */ + .indicator(.activity) + .transition(.fade(duration: 0.5)) + .scaledToFit() + .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) + } + Text((url as NSString).lastPathComponent) + } + } + .buttonStyle(PlainButtonStyle()) + } + } + + var body: some View { #if os(iOS) return NavigationView { @@ -165,49 +217,8 @@ struct ContentView: View { func contentView() -> some View { List { ForEach(imageURLs, id: \.self) { url in - NavigationLink(destination: DetailView(url: url, animated: self.animated)) { - HStack { - if self.animated { - #if os(macOS) || os(iOS) || os(tvOS) - AnimatedImage(url: URL(string:url), isAnimating: .constant(true)) - .onViewUpdate { view, context in - #if os(macOS) - view.toolTip = url - #endif - } - .indicator(SDWebImageActivityIndicator.medium) - /** - .placeholder(UIImage(systemName: "photo")) - */ - .transition(.fade) - .resizable() - .scaledToFit() - .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) - #else - WebImage(url: URL(string:url), isAnimating: self.$animated) - .resizable() - .indicator(.activity) - .transition(.fade(duration: 0.5)) - .scaledToFit() - .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) - #endif - } else { - WebImage(url: URL(string:url), isAnimating: .constant(true)) - .resizable() - /** - .placeholder { - Image(systemName: "photo") - } - */ - .indicator(.activity) - .transition(.fade(duration: 0.5)) - .scaledToFit() - .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) - } - Text((url as NSString).lastPathComponent) - } - } - .buttonStyle(PlainButtonStyle()) + // Must use top level view instead of inlined view structure + ItemView(animated: $animated, url: url) } .onDelete { indexSet in indexSet.forEach { index in diff --git a/README.md b/README.md index b9007e6e..01c39e8e 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ It looks familiar like `SDWebImageManager`, but it's built for SwiftUI world, wh ```swift struct MyView : View { - @ObservedObject var imageManager: ImageManager + @ObservedObject var imageManager = ImageManager() var body: some View { // Your custom complicated view graph Group { @@ -281,17 +281,11 @@ struct MyView : View { } } // Trigger image loading when appear - .onAppear { self.imageManager.load() } + .onAppear { self.imageManager.load(url: url) } // Cancel image loading when disappear .onDisappear { self.imageManager.cancel() } } } - -struct MyView_Previews: PreviewProvider { - static var previews: some View { - MyView(imageManager: ImageManager(url: URL(string: "https://via.placeholder.com/200x200.jpg")) - } -} ``` ### Customization and configuration setup @@ -337,6 +331,54 @@ For more information, it's really recommended to check our demo, to learn detail ### Common Problems +#### Using WebImage/AnimatedImage in List/LazyStack/LazyGrid and ForEach + +SwiftUI has a known behavior(bug?) when using stateful view in `List/LazyStack/LazyGrid`. +Only the **Top Level** view can hold its own `@State/@StateObject`, but the sub structure will lose state when scroll out of screen. +However, WebImage/Animated is both stateful. To ensure the state keep in sync even when scroll out of screen. you may use some tricks. + +See more: https://twitter.com/fatbobman/status/1572507700436807683?s=21&t=z4FkAWTMvjsgL-wKdJGreQ + +In short, it's not recommanded to do so: + +```swift +struct ContentView { + @State var imageURLs: [String] + var body: some View { + List { + ForEach(imageURLs, id: \.self) { url in + VStack { + WebImage(url) // The top level is `VStack` + } + } + } + } +} +``` + +instead, using this approach: + +```swift +struct ContentView { + struct BodyView { + @State var url: String + var body: some View { + VStack { + WebImage(url) + } + } + } + @State var imageURLs: [String] + var body: some View { + List { + ForEach(imageURLs, id: \.self) { url in + BodyView(url: url) + } + } + } +} +``` + #### Using Image/WebImage/AnimatedImage in Button/NavigationLink SwiftUI's `Button` apply overlay to its content (except `Text`) by default, this is common mistake to write code like this, which cause strange behavior: diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 350e2dd7..5e8f0bc9 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -253,16 +253,20 @@ 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 { - return AnyView(configure(image: .empty).id(UUID())) // UUID to avoid SwiftUI engine cache the status and does not call `onAppear` + result = AnyView(configure(image: .empty)) } else { - return placeholder + result = placeholder } } else { - return AnyView(configure(image: .empty).id(UUID())) // UUID to avoid SwiftUI engine cache the status and does not call `onAppear` + result = AnyView(configure(image: .empty)) } + // UUID 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 `onAppear`, it should be called to sync with state changes :) + return result.id(UUID()) } } From 5a5690e2dcac70f3bfcc93b5729c19ae9b711d2c Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Thu, 22 Sep 2022 16:06:07 +0800 Subject: [PATCH 8/8] Fix the test case project --- .../project.pbxproj | 48 +++++++++++++++++-- Tests/AnimatedImageTests.swift | 2 +- Tests/ImageManagerTests.swift | 2 +- Tests/WebImageTests.swift | 2 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Example/SDWebImageSwiftUI.xcodeproj/project.pbxproj b/Example/SDWebImageSwiftUI.xcodeproj/project.pbxproj index 36071150..c370771a 100644 --- a/Example/SDWebImageSwiftUI.xcodeproj/project.pbxproj +++ b/Example/SDWebImageSwiftUI.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 322E0E2228D332130003A55F /* Images.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 322E0DF228D331A20003A55F /* Images.bundle */; }; 322E0E2328D332130003A55F /* Images.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 322E0DF228D331A20003A55F /* Images.bundle */; }; 326B0D712345C01900D28269 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B0D702345C01900D28269 /* DetailView.swift */; }; + 327B90F228DC4EBB003E8BD9 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 327B90F128DC4EBB003E8BD9 /* ViewInspector */; }; + 327B90F428DC4EC0003E8BD9 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 327B90F328DC4EC0003E8BD9 /* ViewInspector */; }; 32DCFE9528D333E8001A17BF /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 32DCFE9428D333E8001A17BF /* ViewInspector */; }; 32E5290C2348A0C700EA46FF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E5290B2348A0C700EA46FF /* AppDelegate.swift */; }; 32E529102348A0C900EA46FF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32E5290F2348A0C900EA46FF /* Assets.xcassets */; }; @@ -220,6 +222,7 @@ buildActionMask = 2147483647; files = ( 833A61715BAAB31702D867CC /* Pods_SDWebImageSwiftUITests_macOS.framework in Frameworks */, + 327B90F228DC4EBB003E8BD9 /* ViewInspector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -228,6 +231,7 @@ buildActionMask = 2147483647; files = ( 2E3D81A12C757E01A3C420F2 /* Pods_SDWebImageSwiftUITests_tvOS.framework in Frameworks */, + 327B90F428DC4EC0003E8BD9 /* ViewInspector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -517,9 +521,13 @@ buildRules = ( ); dependencies = ( + 327B90EE28DC4EAA003E8BD9 /* PBXTargetDependency */, 322E0E0728D331F00003A55F /* PBXTargetDependency */, ); name = "SDWebImageSwiftUITests macOS"; + packageProductDependencies = ( + 327B90F128DC4EBB003E8BD9 /* ViewInspector */, + ); productName = "SDWebImageSwiftUITests macOS"; productReference = 322E0E0228D331F00003A55F /* SDWebImageSwiftUITests macOS.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -537,9 +545,13 @@ buildRules = ( ); dependencies = ( + 327B90F028DC4EAE003E8BD9 /* PBXTargetDependency */, 322E0E1428D332050003A55F /* PBXTargetDependency */, ); name = "SDWebImageSwiftUITests tvOS"; + packageProductDependencies = ( + 327B90F328DC4EC0003E8BD9 /* ViewInspector */, + ); productName = "SDWebImageSwiftUITests tvOS"; productReference = 322E0E0F28D332050003A55F /* SDWebImageSwiftUITests tvOS.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -698,7 +710,7 @@ ); mainGroup = 607FACC71AFB9204008FA782; packageReferences = ( - 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector.git" */, + 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */, ); productRefGroup = 607FACD11AFB9204008FA782 /* Products */; projectDirPath = ""; @@ -1225,6 +1237,14 @@ target = 32E5291F2348A0D300EA46FF /* SDWebImageSwiftUIDemo-tvOS */; targetProxy = 322E0E1328D332050003A55F /* PBXContainerItemProxy */; }; + 327B90EE28DC4EAA003E8BD9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 327B90ED28DC4EAA003E8BD9 /* ViewInspector */; + }; + 327B90F028DC4EAE003E8BD9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 327B90EF28DC4EAE003E8BD9 /* ViewInspector */; + }; 32DCFE9728D333F1001A17BF /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = 32DCFE9628D333F1001A17BF /* ViewInspector */; @@ -2044,7 +2064,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector.git" */ = { + 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nalexn/ViewInspector.git"; requirement = { @@ -2055,14 +2075,34 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 327B90ED28DC4EAA003E8BD9 /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; + 327B90EF28DC4EAE003E8BD9 /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; + 327B90F128DC4EBB003E8BD9 /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; + 327B90F328DC4EC0003E8BD9 /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; 32DCFE9428D333E8001A17BF /* ViewInspector */ = { isa = XCSwiftPackageProductDependency; - package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector.git" */; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; productName = ViewInspector; }; 32DCFE9628D333F1001A17BF /* ViewInspector */ = { isa = XCSwiftPackageProductDependency; - package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector.git" */; + package = 32DCFE8D28D333B0001A17BF /* XCRemoteSwiftPackageReference "ViewInspector" */; productName = ViewInspector; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Tests/AnimatedImageTests.swift b/Tests/AnimatedImageTests.swift index 60091848..29472033 100644 --- a/Tests/AnimatedImageTests.swift +++ b/Tests/AnimatedImageTests.swift @@ -182,7 +182,7 @@ class AnimatedImageTests: XCTestCase { .animation(.easeInOut) _ = try introspectView.inspect() ViewHosting.host(view: introspectView) - self.waitForExpectations(timeout: 5, handler: nil) + self.waitForExpectations(timeout: 10, handler: nil) ViewHosting.expel() } } diff --git a/Tests/ImageManagerTests.swift b/Tests/ImageManagerTests.swift index 002ab91a..ae3269b9 100644 --- a/Tests/ImageManagerTests.swift +++ b/Tests/ImageManagerTests.swift @@ -38,6 +38,6 @@ class ImageManagerTests: XCTestCase { print(value) } sub.cancel() - self.waitForExpectations(timeout: 5, handler: nil) + self.waitForExpectations(timeout: 10, handler: nil) } } diff --git a/Tests/WebImageTests.swift b/Tests/WebImageTests.swift index 28fcbefb..fce4d24c 100644 --- a/Tests/WebImageTests.swift +++ b/Tests/WebImageTests.swift @@ -23,7 +23,7 @@ class WebImageTests: XCTestCase { let imageView = WebImage(url: imageUrl) let introspectView = imageView.onSuccess { image, data, cacheType in #if os(macOS) - let displayImage = try? imageView.inspect().group().image(0).actualImage.nsImage() + let displayImage = try? imageView.inspect().group().image(0).actualImage().nsImage() #else let displayImage = try? imageView.inspect().group().image(0).actualImage().cgImage() #endif