diff --git a/CHANGELOG.md b/CHANGELOG.md index 29200532..0559c433 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/Example/SDWebImageSwiftUIDemo/ContentView.swift b/Example/SDWebImageSwiftUIDemo/ContentView.swift index 6399473d..8cae8876 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/README.md b/README.md index 1d7ed5c4..3b207aad 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,40 @@ 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](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. + +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 { + @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. diff --git a/SDWebImageSwiftUI.xcodeproj/project.pbxproj b/SDWebImageSwiftUI.xcodeproj/project.pbxproj index bded6877..359251ca 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 e54f145c..77267698 100644 --- a/SDWebImageSwiftUI/Classes/ImageManager.swift +++ b/SDWebImageSwiftUI/Classes/ImageManager.swift @@ -9,16 +9,23 @@ 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? + /// 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 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 +35,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 +50,8 @@ class ImageManager : ObservableObject, IndicatorReportable { } } - func load() { + /// Start to load the url operation + public func load() { isFirstLoad = false if currentOperation != nil { return @@ -71,12 +83,12 @@ class ImageManager : ObservableObject, IndicatorReportable { return } self.image = image + self.error = error self.isIncremental = !finished if finished { self.isLoading = false self.progress = 1 if let image = image { - self.isSuccess = true self.successBlock?(image, cacheType) } else { self.failureBlock?(error ?? NSError()) @@ -85,9 +97,40 @@ 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. + public func setOnFailure(perform action: ((Error) -> Void)? = nil) { + self.failureBlock = action + } + + /// 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. + public func setOnSuccess(perform action: ((PlatformImage, SDImageCacheType) -> Void)? = nil) { + self.successBlock = action + } + + /// 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. + public func setOnProgress(perform action: ((Int, Int) -> Void)? = nil) { + self.progressBlock = action + } +} + +// Indicator Reportor +extension ImageManager: IndicatorReportable {} diff --git a/SDWebImageSwiftUI/Classes/WebImage.swift b/SDWebImageSwiftUI/Classes/WebImage.swift index 96d4a14b..5262f84e 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() } } diff --git a/Tests/ImageManagerTests.swift b/Tests/ImageManagerTests.swift new file mode 100644 index 00000000..44173c04 --- /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 661d1197..04ad86df 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)