Update the API of AnimatedImage as well

1. Change the placeholder into Web URL init method (placeholder not works for data/bundle init method)
2. Add convenient .progress/.activity syntax for AnimatedImage indicator
This commit is contained in:
DreamPiggy 2023-09-21 18:02:36 +08:00
parent 9ec9e29e14
commit a94221fba0
6 changed files with 66 additions and 110 deletions

View File

@ -96,22 +96,19 @@ struct ContentView: View {
HStack { HStack {
if self.animated { if self.animated {
#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS)
AnimatedImage(url: URL(string:url), isAnimating: .constant(true)) AnimatedImage(url: URL(string:url))
.onViewUpdate { view, context in .onViewUpdate { view, context in
#if os(macOS) #if os(macOS)
view.toolTip = url view.toolTip = url
#endif #endif
} }
.indicator(SDWebImageActivityIndicator.medium) .indicator(.activity)
/**
.placeholder(UIImage(systemName: "photo"))
*/
.transition(.fade) .transition(.fade)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center)
#else #else
WebImage(url: URL(string:url), isAnimating: self.$animated) WebImage(url: URL(string:url))
.resizable() .resizable()
.indicator(.activity) .indicator(.activity)
.transition(.fade(duration: 0.5)) .transition(.fade(duration: 0.5))
@ -119,13 +116,8 @@ struct ContentView: View {
.frame(width: CGFloat(100), height: CGFloat(100), alignment: .center) .frame(width: CGFloat(100), height: CGFloat(100), alignment: .center)
#endif #endif
} else { } else {
WebImage(url: URL(string:url), isAnimating: .constant(true)) WebImage(url: URL(string:url))
.resizable() .resizable()
/**
.placeholder {
Image(systemName: "photo")
}
*/
.indicator(.activity) .indicator(.activity)
.transition(.fade(duration: 0.5)) .transition(.fade(duration: 0.5))
.scaledToFit() .scaledToFit()

View File

@ -95,10 +95,9 @@ struct DetailView: View {
HStack { HStack {
if animated { if animated {
#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS)
AnimatedImage(url: URL(string:url), options: [.progressiveLoad, .delayPlaceholder], isAnimating: $isAnimating) AnimatedImage(url: URL(string:url), options: [.progressiveLoad, .delayPlaceholder], isAnimating: $isAnimating, placeholderImage: .wifiExclamationmark)
.indicator(.progress)
.resizable() .resizable()
.placeholder(.wifiExclamationmark)
.indicator(SDWebImageProgressIndicator.default)
.scaledToFit() .scaledToFit()
#else #else
WebImage(url: URL(string:url), options: [.progressiveLoad, .delayPlaceholder], isAnimating: $isAnimating) { image in WebImage(url: URL(string:url), options: [.progressiveLoad, .delayPlaceholder], isAnimating: $isAnimating) { image in

View File

@ -128,18 +128,16 @@ github "SDWebImage/SDWebImageSwiftUI"
```swift ```swift
var body: some View { var body: some View {
WebImage(url: URL(string: "https://nokiatech.github.io/heif/content/images/ski_jump_1440x960.heic")) WebImage(url: URL(string: "https://nokiatech.github.io/heif/content/images/ski_jump_1440x960.heic")) { image in
image.resizable() // Control layout like SwiftUI.AsyncImage, you must use this modifier or the view will use the image bitmap size
} placeholder: {
Rectangle().foregroundColor(.gray)
}
// Supports options and context, like `.delayPlaceholder` to show placeholder only when error // Supports options and context, like `.delayPlaceholder` to show placeholder only when error
.onSuccess { image, data, cacheType in .onSuccess { image, data, cacheType in
// Success // Success
// Note: Data exist only when queried from disk cache or network. Use `.queryMemoryData` if you really need data // Note: Data exist only when queried from disk cache or network. Use `.queryMemoryData` if you really need data
} }
.resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size
.placeholder(Image(systemName: "photo")) // Placeholder Image
// Supports ViewBuilder as well
.placeholder {
Rectangle().foregroundColor(.gray)
}
.indicator(.activity) // Activity Indicator .indicator(.activity) // Activity Indicator
.transition(.fade(duration: 0.5)) // Fade Transition with duration .transition(.fade(duration: 0.5)) // Fade Transition with duration
.scaledToFit() .scaledToFit()
@ -194,21 +192,21 @@ WebImage(url: url)
```swift ```swift
var body: some View { var body: some View {
Group { Group {
AnimatedImage(url: URL(string: "https://raw.githubusercontent.com/liyong03/YLGIFImage/master/YLGIFImageDemo/YLGIFImageDemo/joy.gif")) AnimatedImage(url: URL(string: "https://raw.githubusercontent.com/liyong03/YLGIFImage/master/YLGIFImageDemo/YLGIFImageDemo/joy.gif"), placeholderImage: .init(systemName: "photo")) // Placeholder Image
// Supports options and context, like `.progressiveLoad` for progressive animation loading // Supports options and context, like `.progressiveLoad` for progressive animation loading
.onFailure { error in .onFailure { error in
// Error // Error
} }
.resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size
.placeholder(UIImage(systemName: "photo")) // Placeholder Image .indicator(.activity) // Activity Indicator
// Supports ViewBuilder as well
.placeholder {
Circle().foregroundColor(.gray)
}
.indicator(SDWebImageActivityIndicator.medium) // Activity Indicator
.transition(.fade) // Fade Transition .transition(.fade) // Fade Transition
.scaledToFit() // Attention to call it on AnimatedImage, but not `some View` after View Modifier (Swift Protocol Extension method is static dispatched) .scaledToFit() // Attention to call it on AnimatedImage, but not `some View` after View Modifier (Swift Protocol Extension method is static dispatched)
// Supports SwiftUI ViewBuilder placeholder as well
AnimatedImage(url: url) {
Circle().foregroundColor(.gray)
}
// Data // Data
AnimatedImage(data: try! Data(contentsOf: URL(fileURLWithPath: "/tmp/foo.webp"))) AnimatedImage(data: try! Data(contentsOf: URL(fileURLWithPath: "/tmp/foo.webp")))
.customLoopCount(1) // Custom loop count .customLoopCount(1) // Custom loop count

View File

@ -31,6 +31,12 @@ final class AnimatedImageModel : ObservableObject {
@Published var url: URL? @Published var url: URL?
@Published var webOptions: SDWebImageOptions = [] @Published var webOptions: SDWebImageOptions = []
@Published var webContext: [SDWebImageContextOption : Any]? = nil @Published var webContext: [SDWebImageContextOption : Any]? = nil
@Published var placeholderImage: PlatformImage?
@Published var placeholderView: PlatformView? {
didSet {
oldValue?.removeFromSuperview()
}
}
/// Name image /// Name image
@Published var name: String? @Published var name: String?
@Published var bundle: Bundle? @Published var bundle: Bundle?
@ -90,12 +96,6 @@ final class AnimatedImageConfiguration: ObservableObject {
// These configurations only useful for web image loading // These configurations only useful for web image loading
var indicator: SDWebImageIndicator? var indicator: SDWebImageIndicator?
var transition: SDWebImageTransition? var transition: SDWebImageTransition?
var placeholder: PlatformImage?
var placeholderView: PlatformView? {
didSet {
oldValue?.removeFromSuperview()
}
}
} }
/// A Image View type to load image from url, data or bundle. Supports animated and static image format. /// A Image View type to load image from url, data or bundle. Supports animated and static image format.
@ -115,13 +115,19 @@ public struct AnimatedImage : PlatformViewRepresentable {
/// True to start animation, false to stop animation. /// True to start animation, false to stop animation.
@Binding public var isAnimating: Bool @Binding public var isAnimating: Bool
/// Create an animated image with url, placeholder, custom options and context. /// Create an animated image with url, placeholder, custom options and context, including animation control binding.
/// - Parameter url: The image url /// - Parameter url: The image url
/// - Parameter placeholder: The placeholder image to show during loading /// - Parameter placeholder: The placeholder image to show during loading
/// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values. /// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values.
/// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold. /// - Parameter 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) { /// - Parameter isAnimating: The binding for animation control
self.init(url: url, options: options, context: context, isAnimating: .constant(true)) public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding<Bool> = .constant(true), placeholderImage: PlatformImage? = nil) {
let imageModel = AnimatedImageModel()
imageModel.url = url
imageModel.webOptions = options
imageModel.webContext = context
imageModel.placeholderImage = placeholderImage
self.init(imageModel: imageModel, isAnimating: isAnimating)
} }
/// Create an animated image with url, placeholder, custom options and context, including animation control binding. /// Create an animated image with url, placeholder, custom options and context, including animation control binding.
@ -130,46 +136,37 @@ public struct AnimatedImage : PlatformViewRepresentable {
/// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values. /// - Parameter options: The options to use when downloading the image. See `SDWebImageOptions` for the possible values.
/// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold. /// - Parameter context: A context contains different options to perform specify changes or processes, see `SDWebImageContextOption`. This hold the extra objects which `options` enum can not hold.
/// - Parameter isAnimating: The binding for animation control /// - Parameter isAnimating: The binding for animation control
public init(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding<Bool>) { public init<T>(url: URL?, options: SDWebImageOptions = [], context: [SDWebImageContextOption : Any]? = nil, isAnimating: Binding<Bool> = .constant(true), @ViewBuilder placeholder: @escaping () -> T) where T : View {
let imageModel = AnimatedImageModel() let imageModel = AnimatedImageModel()
imageModel.url = url imageModel.url = url
imageModel.webOptions = options imageModel.webOptions = options
imageModel.webContext = context imageModel.webContext = context
#if os(macOS)
let hostingView = NSHostingView(rootView: placeholder())
#else
let hostingView = _UIHostingView(rootView: placeholder())
#endif
imageModel.placeholderView = hostingView
self.init(imageModel: imageModel, isAnimating: isAnimating) self.init(imageModel: imageModel, isAnimating: isAnimating)
} }
/// Create an animated image with name and bundle.
/// - Note: Asset Catalog is not supported.
/// - Parameter name: The image name
/// - Parameter bundle: The bundle contains image
public init(name: String, bundle: Bundle? = nil) {
self.init(name: name, bundle: bundle, isAnimating: .constant(true))
}
/// Create an animated image with name and bundle, including animation control binding. /// Create an animated image with name and bundle, including animation control binding.
/// - Note: Asset Catalog is not supported. /// - Note: Asset Catalog is not supported.
/// - Parameter name: The image name /// - Parameter name: The image name
/// - Parameter bundle: The bundle contains image /// - Parameter bundle: The bundle contains image
/// - Parameter isAnimating: The binding for animation control /// - Parameter isAnimating: The binding for animation control
public init(name: String, bundle: Bundle? = nil, isAnimating: Binding<Bool>) { public init(name: String, bundle: Bundle? = nil, isAnimating: Binding<Bool> = .constant(true)) {
let imageModel = AnimatedImageModel() let imageModel = AnimatedImageModel()
imageModel.name = name imageModel.name = name
imageModel.bundle = bundle imageModel.bundle = bundle
self.init(imageModel: imageModel, isAnimating: isAnimating) self.init(imageModel: imageModel, isAnimating: isAnimating)
} }
/// Create an animated image with data and scale.
/// - Parameter data: The image data
/// - Parameter scale: The scale factor
public init(data: Data, scale: CGFloat = 1) {
self.init(data: data, scale: scale, isAnimating: .constant(true))
}
/// Create an animated image with data and scale, including animation control binding. /// Create an animated image with data and scale, including animation control binding.
/// - Parameter data: The image data /// - Parameter data: The image data
/// - Parameter scale: The scale factor /// - Parameter scale: The scale factor
/// - Parameter isAnimating: The binding for animation control /// - Parameter isAnimating: The binding for animation control
public init(data: Data, scale: CGFloat = 1, isAnimating: Binding<Bool>) { public init(data: Data, scale: CGFloat = 1, isAnimating: Binding<Bool> = .constant(true)) {
let imageModel = AnimatedImageModel() let imageModel = AnimatedImageModel()
imageModel.data = data imageModel.data = data
imageModel.scale = scale imageModel.scale = scale
@ -222,7 +219,7 @@ public struct AnimatedImage : PlatformViewRepresentable {
func setupIndicator(_ view: AnimatedImageViewWrapper, context: Context) { func setupIndicator(_ view: AnimatedImageViewWrapper, context: Context) {
view.wrapped.sd_imageIndicator = imageConfiguration.indicator view.wrapped.sd_imageIndicator = imageConfiguration.indicator
view.wrapped.sd_imageTransition = imageConfiguration.transition view.wrapped.sd_imageTransition = imageConfiguration.transition
if let placeholderView = imageConfiguration.placeholderView { if let placeholderView = imageModel.placeholderView {
placeholderView.removeFromSuperview() placeholderView.removeFromSuperview()
placeholderView.isHidden = true placeholderView.isHidden = true
// Placeholder View should below the Indicator View // Placeholder View should below the Indicator View
@ -243,13 +240,13 @@ public struct AnimatedImage : PlatformViewRepresentable {
context.coordinator.imageLoading.isLoading = true context.coordinator.imageLoading.isLoading = true
let webOptions = imageModel.webOptions let webOptions = imageModel.webOptions
if webOptions.contains(.delayPlaceholder) { if webOptions.contains(.delayPlaceholder) {
self.imageConfiguration.placeholderView?.isHidden = true self.imageModel.placeholderView?.isHidden = true
} else { } else {
self.imageConfiguration.placeholderView?.isHidden = false self.imageModel.placeholderView?.isHidden = false
} }
var webContext = imageModel.webContext ?? [:] var webContext = imageModel.webContext ?? [:]
webContext[.animatedImageClass] = SDAnimatedImage.self webContext[.animatedImageClass] = SDAnimatedImage.self
view.wrapped.sd_internalSetImage(with: imageModel.url, placeholderImage: imageConfiguration.placeholder, options: webOptions, context: webContext, setImageBlock: nil, progress: { (receivedSize, expectedSize, _) in view.wrapped.sd_internalSetImage(with: imageModel.url, placeholderImage: imageModel.placeholderImage, options: webOptions, context: webContext, setImageBlock: nil, progress: { (receivedSize, expectedSize, _) in
let progress: Double let progress: Double
if (expectedSize > 0) { if (expectedSize > 0) {
progress = Double(receivedSize) / Double(expectedSize) progress = Double(receivedSize) / Double(expectedSize)
@ -265,10 +262,10 @@ public struct AnimatedImage : PlatformViewRepresentable {
context.coordinator.imageLoading.isLoading = false context.coordinator.imageLoading.isLoading = false
context.coordinator.imageLoading.progress = 1 context.coordinator.imageLoading.progress = 1
if let image = image { if let image = image {
self.imageConfiguration.placeholderView?.isHidden = true self.imageModel.placeholderView?.isHidden = true
self.imageHandler.successBlock?(image, data, cacheType) self.imageHandler.successBlock?(image, data, cacheType)
} else { } else {
self.imageConfiguration.placeholderView?.isHidden = false self.imageModel.placeholderView?.isHidden = false
self.imageHandler.failureBlock?(error ?? NSError()) self.imageHandler.failureBlock?(error ?? NSError())
} }
} }
@ -780,30 +777,19 @@ extension AnimatedImage {
} }
} }
// Convenient indicator dot syntax
extension SDWebImageIndicator where Self == SDWebImageActivityIndicator {
public static var activity: Self { Self() }
}
extension SDWebImageIndicator where Self == SDWebImageProgressIndicator {
public static var progress: Self { Self() }
}
// Web Image convenience, based on UIKit/AppKit API // Web Image convenience, based on UIKit/AppKit API
@available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *)
extension AnimatedImage { extension AnimatedImage {
/// Associate a placeholder when loading image with url
/// - Parameter content: A view that describes the placeholder.
/// - note: The differences between this and placeholder image, it's that placeholder image replace the image for image view, but this modify the View Hierarchy to overlay the placeholder hosting view
public func placeholder<T>(@ViewBuilder content: () -> T) -> AnimatedImage where T : View {
#if os(macOS)
let hostingView = NSHostingView(rootView: content())
#else
let hostingView = _UIHostingView(rootView: content())
#endif
self.imageConfiguration.placeholderView = hostingView
return self
}
/// Associate a placeholder image when loading image with url
/// - Parameter content: A view that describes the placeholder.
public func placeholder(_ image: PlatformImage?) -> AnimatedImage {
self.imageConfiguration.placeholder = image
return self
}
/// Associate a indicator when loading image with url /// Associate a indicator when loading image with url
/// - Note: If you do not need indicator, specify nil. Defaults to nil /// - Note: If you do not need indicator, specify nil. Defaults to nil
/// - Parameter indicator: indicator, see more in `SDWebImageIndicator` /// - Parameter indicator: indicator, see more in `SDWebImageIndicator`
@ -821,23 +807,6 @@ extension AnimatedImage {
} }
} }
// Indicator
@available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *)
extension AnimatedImage {
/// Associate a indicator when loading image with url
/// - Parameter indicator: The indicator type, see `Indicator`
public func indicator<T>(_ indicator: Indicator<T>) -> some View where T : View {
return self.modifier(IndicatorViewModifier(status: indicatorStatus, indicator: indicator))
}
/// Associate a indicator when loading image with url, convenient method with block
/// - Parameter content: A view that describes the indicator.
public func indicator<T>(@ViewBuilder content: @escaping (_ isAnimating: Binding<Bool>, _ progress: Binding<Double>) -> T) -> some View where T : View {
return indicator(Indicator(content: content))
}
}
#if DEBUG #if DEBUG
@available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *) @available(iOS 14.0, OSX 11.0, tvOS 14.0, watchOS 7.0, *)
struct AnimatedImage_Previews : PreviewProvider { struct AnimatedImage_Previews : PreviewProvider {

View File

@ -142,7 +142,9 @@ class AnimatedImageTests: XCTestCase {
func testAnimatedImageModifier() throws { func testAnimatedImageModifier() throws {
let expectation = self.expectation(description: "WebImage modifier") let expectation = self.expectation(description: "WebImage modifier")
let imageUrl = URL(string: "https://assets.sbnation.com/assets/2512203/dogflops.gif") let imageUrl = URL(string: "https://assets.sbnation.com/assets/2512203/dogflops.gif")
let imageView = AnimatedImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) let imageView = AnimatedImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) {
Circle()
}
let introspectView = imageView let introspectView = imageView
.onSuccess { _, _, _ in .onSuccess { _, _, _ in
expectation.fulfill() expectation.fulfill()
@ -161,11 +163,7 @@ class AnimatedImageTests: XCTestCase {
XCTAssert(view.isKind(of: SDAnimatedImageView.self)) XCTAssert(view.isKind(of: SDAnimatedImageView.self))
XCTAssertEqual(context.coordinator.userInfo?["foo"] as? String, "bar") XCTAssertEqual(context.coordinator.userInfo?["foo"] as? String, "bar")
} }
.placeholder(PlatformImage()) .indicator(.activity)
.placeholder {
Circle()
}
.indicator(SDWebImageActivityIndicator.medium)
// Image // Image
.resizable() .resizable()
.renderingMode(.original) .renderingMode(.original)

View File

@ -73,7 +73,11 @@ class WebImageTests: XCTestCase {
func testWebImageModifier() throws { func testWebImageModifier() throws {
let expectation = self.expectation(description: "WebImage modifier") let expectation = self.expectation(description: "WebImage modifier")
let imageUrl = URL(string: "https://raw.githubusercontent.com/ibireme/YYImage/master/Demo/YYImageDemo/mew_baseline.jpg") let imageUrl = URL(string: "https://raw.githubusercontent.com/ibireme/YYImage/master/Demo/YYImageDemo/mew_baseline.jpg")
let imageView = WebImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) let imageView = WebImage(url: imageUrl, options: [.progressiveLoad], context: [.imageScaleFactor: 1]) { image in
image.resizable()
} placeholder: {
Circle()
}
let introspectView = imageView let introspectView = imageView
.onSuccess { _, _, _ in .onSuccess { _, _, _ in
expectation.fulfill() expectation.fulfill()
@ -83,10 +87,6 @@ class WebImageTests: XCTestCase {
} }
.onProgress { _, _ in .onProgress { _, _ in
}
.placeholder(.init(platformImage: PlatformImage()))
.placeholder {
Circle()
} }
// Image // Image
.resizable() .resizable()