Merge pull request #232 from SDWebImage/fix_backport_ios13

Fix iOS 13 compatibility && State changes
This commit is contained in:
DreamPiggy 2022-09-22 16:28:09 +08:00 committed by GitHub
commit 9a82da2a1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 423 additions and 171 deletions

View File

@ -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 */

View File

@ -34,6 +34,52 @@ extension Indicator where T == ProgressView<EmptyView, EmptyView> {
}
#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 os(watchOS)
WebImage(url:URL(string: imageURLs[imageIndex]))
.resizable()
.aspectRatio(contentMode: .fit)
#else
if self.animated {
AnimatedImage(url:URL(string: imageURLs[imageIndex]))
.resizable()
.aspectRatio(contentMode: .fit)
} else {
WebImage(url:URL(string: imageURLs[imageIndex]))
.resizable()
.aspectRatio(contentMode: .fit)
}
#endif
Spacer()
Button("Next") {
if imageIndex + 1 >= imageURLs.count {
imageIndex = 0
} else {
imageIndex += 1
}
}
Button("Reload") {
SDImageCache.shared.clearMemory()
SDImageCache.shared.clearDisk(onCompletion: nil)
}
Toggle("Switch", isOn: $animated)
}
}
}
struct ContentView: View {
@State var imageURLs = [
"http://assets.sbnation.com/assets/2512203/dogflops.gif",
@ -58,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 {
@ -119,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

View File

@ -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:

View File

@ -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 = "<group>"; };
326B848B236335400011BDFB /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; };
326E480923431C0F00C633E9 /* ImageViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewWrapper.swift; sourceTree = "<group>"; };
32B79C9428DB40430088C432 /* SwiftUICompatibility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUICompatibility.swift; sourceTree = "<group>"; };
32B933E423659A1900BB7CAD /* Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transition.swift; sourceTree = "<group>"; };
32BC086F28D23D35002451BD /* StateObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateObject.swift; sourceTree = "<group>"; };
32BC087028D23D35002451BD /* OnChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnChange.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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<Bool>) {
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)

View File

@ -21,49 +21,37 @@ 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()
var manager: SDWebImageManager?
weak var currentOperation: SDWebImageOperation? = nil
var url: URL?
var options: SDWebImageOptions = []
var context: [SDWebImageContextOption : Any]? = nil
var currentURL: URL?
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
manager = .shared
}
}
init() {}
/// Start to load the url operation
public func load() {
guard let manager = manager else {
if (currentOperation != nil && currentURL == url) {
return
}
if currentOperation != nil {
return
}
self.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
@ -75,7 +63,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
@ -95,8 +83,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 {
@ -111,8 +99,9 @@ public final class ImageManager : ObservableObject {
if let operation = currentOperation {
operation.cancel()
currentOperation = nil
isLoading = false
}
indicatorStatus.isLoading = false
currentURL = nil
}
}

View File

@ -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

View File

@ -0,0 +1,92 @@
/*
* This file is part of the SDWebImage package.
* (c) DreamPiggy <lizhuoli1126@126.com>
*
* 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 {
DispatchQueue.main.async {
self.appearAction()
}
} else {
DispatchQueue.main.async {
self.disappearAction()
}
}
}
#endif
#if os(macOS)
override func viewWillMove(toWindow newWindow: NSWindow?) {
if newWindow != nil {
DispatchQueue.main.async {
self.appearAction()
}
} else {
DispatchQueue.main.async {
self.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
}
}

View File

@ -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 options: SDWebImageOptions = []
@Published var context: [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,34 +52,22 @@ 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()
/// 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 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
}
}
// FIXME: Use SwiftUI StateObject and remove onPlatformAppear once drop iOS 13 support
@Backport.StateObject 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,14 @@ 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.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.
@ -103,52 +103,38 @@ public struct WebImage : View {
public var body: some View {
return Group {
if let image = imageManager.image {
// 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)
} else {
configure(image: image)
configure(image: imageManager.image!)
}
}
} else {
// Load Logic
setupPlaceholder()
.onAppear {
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()
.onPlatformAppear(appear: {
setupManager()
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()
if self.imageManager.error != nil && !self.imageManager.isIncremental {
self.imageManager.load(url: imageModel.url, options: imageModel.options, context: imageModel.context)
}
}.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 {
if self.imageManager.error != nil && !self.imageManager.isIncremental {
self.imageManager.cancel()
}
}.onReceive(imageManager.objectWillChange) { _ in
indicatorStatus.isLoading = imageManager.isLoading
indicatorStatus.progress = imageManager.progress
}
})
}
}
}
@ -202,40 +188,85 @@ 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 {
if let currentFrame = imagePlayer.currentFrame {
return configure(image: currentFrame).onAppear {
self.imagePlayer.startPlaying()
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).onPlatformAppear(appear: {
self.imagePlayer.startPlaying()
}, disappear: {
disappearAction()
})
} else {
return configure(image: imageManager.image!).onAppear {
if let animatedImage = imageManager.image as? SDAnimatedImageProvider {
return configure(image: imageManager.image!).onPlatformAppear(appear: {
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()
}
}
}, disappear: {
disappearAction()
})
}
}
/// 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 imageManager.options.contains(.delayPlaceholder) && imageManager.isLoading {
return AnyView(configure(image: .empty))
if imageModel.options.contains(.delayPlaceholder) && imageManager.error == nil {
result = AnyView(configure(image: .empty))
} else {
return placeholder
result = placeholder
}
} else {
return AnyView(configure(image: .empty))
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())
}
}

View File

@ -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()
}
}

View File

@ -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)
@ -38,6 +38,6 @@ class ImageManagerTests: XCTestCase {
print(value)
}
sub.cancel()
self.waitForExpectations(timeout: 5, handler: nil)
self.waitForExpectations(timeout: 10, handler: nil)
}
}

View File

@ -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