From 88e4deab4863de0e4d4d0e92f7aeec69f92bbab6 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Tue, 24 Mar 2020 17:14:20 +0800 Subject: [PATCH 1/4] Make the ImageManager obseable --- SDWebImageSwiftUI/Classes/ImageManager.swift | 70 ++++++++++++++++---- SDWebImageSwiftUI/Classes/WebImage.swift | 5 +- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index e54f145..eccb21d 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -9,16 +9,21 @@ import SwiftUI import SDWebImage +/// A Image observable object for handle image load process. This drive the Source of Truth for image loading status. +/// You can use `@ObservedObject` to associate each instance of manager to your View type, which update your view's body from SwiftUI framework when image was loaded. @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) -class ImageManager : ObservableObject, IndicatorReportable { - @Published var image: PlatformImage? // loaded image, note when progressive loading, this will published multiple times with different partial image - @Published var isLoading: Bool = false // whether network is loading or cache is querying, should only be used for indicator binding - @Published var progress: Double = 0 // network progress, should only be used for indicator binding +public final class ImageManager : ObservableObject { + /// loaded image, note when progressive loading, this will published multiple times with different partial image + @Published public var image: PlatformImage? + /// 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 var manager: SDWebImageManager weak var currentOperation: SDWebImageOperation? = nil - var isSuccess: Bool = false // true means request for this URL is ended forever, load() do nothing - var isIncremental: Bool = false // true means during incremental loading var isFirstLoad: Bool = true // false after first call `load()` var url: URL? @@ -28,7 +33,11 @@ class ImageManager : ObservableObject, IndicatorReportable { var failureBlock: ((Error) -> Void)? var progressBlock: ((Int, Int) -> Void)? - init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil) { + /// Create a image manager for loading the specify url, with 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.url = url self.options = options self.context = context @@ -39,7 +48,8 @@ class ImageManager : ObservableObject, IndicatorReportable { } } - func load() { + /// Start to load the url operation + public func load() { isFirstLoad = false if currentOperation != nil { return @@ -76,7 +86,6 @@ class ImageManager : ObservableObject, IndicatorReportable { self.isLoading = false self.progress = 1 if let image = image { - self.isSuccess = true self.successBlock?(image, cacheType) } else { self.failureBlock?(error ?? NSError()) @@ -85,9 +94,46 @@ class ImageManager : ObservableObject, IndicatorReportable { } } - func cancel() { - currentOperation?.cancel() - currentOperation = nil + /// Cancel the current url loading + public func cancel() { + if let operation = currentOperation { + operation.cancel() + currentOperation = nil + isLoading = false + } } } + +// Completion Handler +extension ImageManager { + /// Provide the action when image load fails. + /// - Parameters: + /// - action: The action to perform. The first arg is the error during loading. If `action` is `nil`, the call has no effect. + /// - Returns: A view that triggers `action` when this image load fails. + public func onFailure(perform action: ((Error) -> Void)? = nil) -> ImageManager { + self.failureBlock = action + return self + } + + /// Provide the action when image load successes. + /// - Parameters: + /// - action: The action to perform. The first arg is the loaded image, the second arg is the cache type loaded from. If `action` is `nil`, the call has no effect. + /// - Returns: A view that triggers `action` when this image load successes. + public func onSuccess(perform action: ((PlatformImage, SDImageCacheType) -> Void)? = nil) -> ImageManager { + self.successBlock = action + return self + } + + /// Provide the action when image load progress changes. + /// - Parameters: + /// - action: The action to perform. The first arg is the received size, the second arg is the total size, all in bytes. If `action` is `nil`, the call has no effect. + /// - Returns: A view that triggers `action` when this image load successes. + public func onProgress(perform action: ((Int, Int) -> Void)? = nil) -> ImageManager { + self.progressBlock = action + return self + } +} + +// Indicator Reportor +extension ImageManager: IndicatorReportable {} diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 96d4a14..5262f84 100644 --- a/SDWebImageSwiftUI/Classes/WebImage.swift +++ b/SDWebImageSwiftUI/Classes/WebImage.swift @@ -120,14 +120,15 @@ public struct WebImage : View { .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .onAppear { guard self.retryOnAppear else { return } - if !self.imageManager.isSuccess { + // 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() } } .onDisappear { guard self.cancelOnDisappear else { return } // When using prorgessive loading, the previous partial image will cause onDisappear. Filter this case - if !self.imageManager.isSuccess && !self.imageManager.isIncremental { + if self.imageManager.image == nil && !self.imageManager.isIncremental { self.imageManager.cancel() } } From 7373b50154a715bb95b243428f83a6892787a819 Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Tue, 24 Mar 2020 17:28:56 +0800 Subject: [PATCH 2/4] Update the readme about the imageManager usage for custom view type --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 1d7ed5c..aedce94 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,38 @@ If you need powerful animated image, `AnimatedImage` is the one to choose. Remem But, because `AnimatedImage` use `UIViewRepresentable` and driven by UIKit, currently there may be some small incompatible issues between UIKit and SwiftUI layout and animation system, or bugs related to SwiftUI itself. We try our best to match SwiftUI behavior, and provide the same API as `WebImage`, which make it easy to switch between these two types if needed. +### Use `ImageManager` for your own View type + +The `ImageManager` is a class which conforms to Combine's `ObservableObject` protocol. Which is the core fetching data source of `WebImage` we provided. + +For advanced use case, like loading image into the complicated View graph which you don't want to use `WebImage`. You can directly bind your own View type with the Manager, which provide the Source of Truth of loading images. + +```swift +struct MyView : View { + @ObservedObject var imageManager: ImageManager + var body: some View { + // Your custom complicated view graph + Group { + if imageManager.image != nil { + Image(uiImage: imageManager.image!) + } else { + Rectangle().fill(Color.gray) + } + } + // Trigger image loading when appear + .onAppear { self.imageManager.load() } + // 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 This framework is based on SDWebImage, which supports advanced customization and configuration to meet different users' demand. From 80bc8325045802326b9f7cb0c23793875b70aced Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Tue, 24 Mar 2020 17:38:59 +0800 Subject: [PATCH 3/4] Update the README and CHANGELOG --- CHANGELOG.md | 2 ++ README.md | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2920053..0559c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `ImageManager` now public. Which allows advanced usage for custom View type. Use `@ObservedObject` to bind the manager with your own View and update the image. ## [1.0.0] - 2020-03-03 ### Added diff --git a/README.md b/README.md index aedce94..3b207aa 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,11 @@ But, because `AnimatedImage` use `UIViewRepresentable` and driven by UIKit, curr ### Use `ImageManager` for your own View type -The `ImageManager` is a class which conforms to Combine's `ObservableObject` protocol. Which is the core fetching data source of `WebImage` we provided. +The `ImageManager` is a class which conforms to Combine's [ObservableObject](https://developer.apple.com/documentation/combine/observableobject) protocol. Which is the core fetching data source of `WebImage` we provided. -For advanced use case, like loading image into the complicated View graph which you don't want to use `WebImage`. You can directly bind your own View type with the Manager, which provide the Source of Truth of loading images. +For advanced use case, like loading image into the complicated View graph which you don't want to use `WebImage`. You can directly bind your own View type with the Manager. + +It looks familiar like `SDWebImageManager`, but it's built for SwiftUI world, which provide the Source of Truth for loading images. You'd better use SwiftUI's `@ObservedObject` to bind each single manager instance for your View instance, which automatically update your View's body when image status changed. ```swift struct MyView : View { From 72c7c8d693c83d4717a4e614ba4fecb65d985bfc Mon Sep 17 00:00:00 2001 From: DreamPiggy Date: Tue, 24 Mar 2020 18:47:35 +0800 Subject: [PATCH 4/4] Change the API of completionHandler for ImageManager, without return value. Add test cases --- .../SDWebImageSwiftUIDemo/ContentView.swift | 2 +- SDWebImageSwiftUI.xcodeproj/project.pbxproj | 8 ++++ SDWebImageSwiftUI/Classes/ImageManager.swift | 15 +++---- Tests/ImageManagerTests.swift | 43 +++++++++++++++++++ Tests/TestUtils.swift | 2 +- 5 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 Tests/ImageManagerTests.swift diff --git a/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index 6399473..8cae887 100644 --- a/Example/SDWebImageSwiftUIDemo/ContentView.swift +++ b/Example/SDWebImageSwiftUIDemo/ContentView.swift @@ -54,7 +54,7 @@ struct ContentView: View { "https://www.sample-videos.com/img/Sample-png-image-1mb.png", "https://nr-platform.s3.amazonaws.com/uploads/platform/published_extension/branding_icon/275/AmazonS3.png", "https://raw.githubusercontent.com/ibireme/YYImage/master/Demo/YYImageDemo/mew_baseline.jpg", - "http://via.placeholder.com/200x200.jpg", + "https://via.placeholder.com/200x200.jpg", "https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/w3c.svg", "https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/wikimedia.svg", "https://raw.githubusercontent.com/icons8/flat-color-icons/master/pdf/stack_of_photos.pdf", diff --git a/SDWebImageSwiftUI.xcodeproj/project.pbxproj b/SDWebImageSwiftUI.xcodeproj/project.pbxproj index bded687..359251c 100644 --- a/SDWebImageSwiftUI.xcodeproj/project.pbxproj +++ b/SDWebImageSwiftUI.xcodeproj/project.pbxproj @@ -79,6 +79,9 @@ 32C43E3322FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C43E3122FD5DE100BE87F5 /* SDWebImageSwiftUI.swift */; }; 32C43E3422FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C43E3122FD5DE100BE87F5 /* SDWebImageSwiftUI.swift */; }; 32C43E3522FD5DF400BE87F5 /* SDWebImageSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C43E3122FD5DE100BE87F5 /* SDWebImageSwiftUI.swift */; }; + 32ED4826242A13030053338E /* ImageManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ED4825242A13030053338E /* ImageManagerTests.swift */; }; + 32ED4827242A13030053338E /* ImageManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ED4825242A13030053338E /* ImageManagerTests.swift */; }; + 32ED4828242A13030053338E /* ImageManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ED4825242A13030053338E /* ImageManagerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -181,6 +184,7 @@ 32C43E2922FD586200BE87F5 /* SDWebImage.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SDWebImage.framework; path = Carthage/Build/tvOS/SDWebImage.framework; sourceTree = ""; }; 32C43E2D22FD586E00BE87F5 /* SDWebImage.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SDWebImage.framework; path = Carthage/Build/watchOS/SDWebImage.framework; sourceTree = ""; }; 32C43E3122FD5DE100BE87F5 /* SDWebImageSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDWebImageSwiftUI.swift; sourceTree = ""; }; + 32ED4825242A13030053338E /* ImageManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageManagerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -257,6 +261,7 @@ 3211F84623DE984D00FC757F /* AnimatedImageTests.swift */, 3211F84F23DE98E300FC757F /* WebImageTests.swift */, 32BD9C4623E03B08008D5F6A /* IndicatorTests.swift */, + 32ED4825242A13030053338E /* ImageManagerTests.swift */, 322E0F4723E57F09006836DC /* TestUtils.swift */, ); path = Tests; @@ -707,6 +712,7 @@ 32BD9C4723E03B08008D5F6A /* IndicatorTests.swift in Sources */, 3211F84723DE984D00FC757F /* AnimatedImageTests.swift in Sources */, 322E0F4823E57F09006836DC /* TestUtils.swift in Sources */, + 32ED4826242A13030053338E /* ImageManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -718,6 +724,7 @@ 32BD9C4823E03B08008D5F6A /* IndicatorTests.swift in Sources */, 321C1D6A23DEDB98009CF62A /* AnimatedImageTests.swift in Sources */, 322E0F4923E57F09006836DC /* TestUtils.swift in Sources */, + 32ED4827242A13030053338E /* ImageManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -729,6 +736,7 @@ 32BD9C4923E03B08008D5F6A /* IndicatorTests.swift in Sources */, 321C1D6C23DEDB98009CF62A /* AnimatedImageTests.swift in Sources */, 322E0F4A23E57F09006836DC /* TestUtils.swift in Sources */, + 32ED4828242A13030053338E /* ImageManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SDWebImageSwiftUI/Classes/ImageManager.swift b/SDWebImageSwiftUI/Classes/ImageManager.swift index eccb21d..7726769 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -15,6 +15,8 @@ import SDWebImage public final class ImageManager : ObservableObject { /// loaded image, note when progressive loading, this will published multiple times with different partial image @Published public var image: PlatformImage? + /// 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 @@ -81,6 +83,7 @@ public final class ImageManager : ObservableObject { return } self.image = image + self.error = error self.isIncremental = !finished if finished { self.isLoading = false @@ -110,28 +113,22 @@ extension ImageManager { /// Provide the action when image load fails. /// - Parameters: /// - action: The action to perform. The first arg is the error during loading. If `action` is `nil`, the call has no effect. - /// - Returns: A view that triggers `action` when this image load fails. - public func onFailure(perform action: ((Error) -> Void)? = nil) -> ImageManager { + public func setOnFailure(perform action: ((Error) -> Void)? = nil) { self.failureBlock = action - return self } /// Provide the action when image load successes. /// - Parameters: /// - action: The action to perform. The first arg is the loaded image, the second arg is the cache type loaded from. If `action` is `nil`, the call has no effect. - /// - Returns: A view that triggers `action` when this image load successes. - public func onSuccess(perform action: ((PlatformImage, SDImageCacheType) -> Void)? = nil) -> ImageManager { + public func setOnSuccess(perform action: ((PlatformImage, SDImageCacheType) -> Void)? = nil) { self.successBlock = action - return self } /// Provide the action when image load progress changes. /// - Parameters: /// - action: The action to perform. The first arg is the received size, the second arg is the total size, all in bytes. If `action` is `nil`, the call has no effect. - /// - Returns: A view that triggers `action` when this image load successes. - public func onProgress(perform action: ((Int, Int) -> Void)? = nil) -> ImageManager { + public func setOnProgress(perform action: ((Int, Int) -> Void)? = nil) { self.progressBlock = action - return self } } diff --git a/Tests/ImageManagerTests.swift b/Tests/ImageManagerTests.swift new file mode 100644 index 0000000..44173c0 --- /dev/null +++ b/Tests/ImageManagerTests.swift @@ -0,0 +1,43 @@ +import XCTest +import SwiftUI +import ViewInspector +@testable import SDWebImageSwiftUI + +class ImageManagerTests: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + 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) + imageManager.setOnSuccess { image, cacheType in + XCTAssertNotNil(image) + expectation.fulfill() + } + imageManager.setOnFailure { error in + XCTFail() + } + imageManager.setOnProgress { receivedSize, expectedSize in + + } + imageManager.load() + XCTAssertNotNil(imageManager.currentOperation) + let sub = imageManager.objectWillChange + .subscribe(on: RunLoop.main) + .receive(on: RunLoop.main) + .sink { value in + print(value) + } + sub.cancel() + self.waitForExpectations(timeout: 5, handler: nil) + } +} diff --git a/Tests/TestUtils.swift b/Tests/TestUtils.swift index 661d119..04ad86d 100644 --- a/Tests/TestUtils.swift +++ b/Tests/TestUtils.swift @@ -3,7 +3,7 @@ import SwiftUI import ViewInspector @testable import SDWebImageSwiftUI -public extension PlatformViewRepresentable where Self: Inspectable { +extension PlatformViewRepresentable where Self: Inspectable { func platformView() throws -> PlatformViewType { #if os(macOS)