Search code examples
iosswiftswiftuisdwebimagesdwebimageswiftui

Why does my snapshot() in SwiftUI not capture downloaded images from WebImage (SDWebImageSwiftUI)?


I’m trying to take a snapshot of a SwiftUI view that includes an image downloaded using WebImage from the SDWebImageSwiftUI library. However, when I call the snapshot function, the resulting image includes only the placeholder (e.g., a ProgressView), not the actual downloaded image.

Here’s my WebImage implementation:

WebImage(url: URL(string: url)) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}
.aspectRatio(contentMode: .fit)

This is my snapshot function:

func snapshot<T: View>(of view: T) -> UIImage {
        let controller = UIHostingController(rootView: view)
        let hostingView = controller.view
        
        let targetSize = hostingView?.intrinsicContentSize ?? .zero
        hostingView?.bounds = CGRect(origin: .zero, size: targetSize)
        hostingView?.backgroundColor = .clear
        
        let renderer = UIGraphicsImageRenderer(size: targetSize)
        
        return renderer.image { _ in
            hostingView?.drawHierarchy(in: hostingView!.bounds, afterScreenUpdates: true)
        }
    }

Observations:

  1. The snapshot works perfectly for other SwiftUI components.
  2. If I wait for a long time to ensure the image is downloaded, the placeholder is still included in the snapshot instead of the actual image.

Questions:

  1. Why is the downloaded image not being included in the snapshot?
  2. How can I ensure the WebImage content is fully loaded and included in the snapshot?
  3. Is there a better way to capture a snapshot of SwiftUI views that use WebImage?

Any help would be greatly appreciated! I have added a repo with reproducible code https://github.com/nauvtechnologies/sdwebimagesnapshot

The issue is the same with AsyncImage which I have used in the repo.


Solution

  • You say you "waited for a long time" for the image to be downloaded, but if you did do that correctly, you would not have this problem in the first place.

    If you just have a UIHostingController and just call drawHierarchy, you are not waiting at all. drawHierarchy will cause SwiftUI will only run its lifecycle for a very short time, just enough that something is drawn.

    To actually wait for the image to be downloaded, you need to add the UIHostingController to a UIWindow, and only then can the SwiftUI lifecycle run for an extended period of time. If you do a Task.sleep during this time, you can wait for the image to be downloaded.

    Here is some code that does this. This is modified from ViewHosting.swift in ViewInspector. You probably can further simplify this depending on your needs.

    @MainActor
    public enum ViewHosting { }
    
    public extension ViewHosting {
        
        struct ViewId: Hashable, Sendable {
            let function: String
            var key: String { function }
        }
    
        @MainActor
        static func host<V, R>(_ view: V,
                            function: String = #function,
                            whileHosted: @MainActor (UIViewController) async throws -> R
        ) async rethrows -> R where V: View {
            let viewId = ViewId(function: function)
            let vc = host(view: view, viewId: viewId)
            let result = try await whileHosted(vc)
            expel(viewId: viewId)
            return result
        }
    
        @MainActor
        private static func host<V>(view: V, viewId: ViewId) -> UIViewController where V: View {
            let parentVC = rootViewController
            let childVC = hostVC(view)
            store(Hosted(viewController: childVC), viewId: viewId)
            childVC.view.translatesAutoresizingMaskIntoConstraints = false
            childVC.view.frame = parentVC.view.frame
            willMove(childVC, to: parentVC)
            parentVC.addChild(childVC)
            parentVC.view.addSubview(childVC.view)
            NSLayoutConstraint.activate([
                childVC.view.leadingAnchor.constraint(equalTo: parentVC.view.leadingAnchor),
                childVC.view.topAnchor.constraint(equalTo: parentVC.view.topAnchor),
            ])
            didMove(childVC, to: parentVC)
            window.layoutIfNeeded()
            return childVC
        }
    
        static func expel(function: String = #function) {
            let viewId = ViewId(function: function)
            MainActor.assumeIsolated {
                expel(viewId: viewId)
            }
        }
    
        @MainActor
        private static func expel(viewId: ViewId) {
            guard let hosted = expelHosted(viewId: viewId) else { return }
            let childVC = hosted.viewController
            willMove(childVC, to: nil)
            childVC.view.removeFromSuperview()
            childVC.removeFromParent()
            didMove(childVC, to: nil)
        }
    }
    
    @MainActor
    private extension ViewHosting {
        
        struct Hosted {
            let viewController: UIViewController
        }
        private static var hosted: [ViewId: Hosted] = [:]
        static let window: UIWindow = makeWindow()
        static func makeWindow() -> UIWindow {
            let frame = UIScreen.main.bounds
            let window = UIWindow(frame: frame)
            installRootViewController(window)
            window.makeKeyAndVisible()
            window.layoutIfNeeded()
            return window
        }
        @discardableResult
        static func installRootViewController(_ window: UIWindow) -> UIViewController {
            let vc = UIViewController()
            window.rootViewController = vc
            vc.view.translatesAutoresizingMaskIntoConstraints = false
            return vc
        }
        
        static var rootViewController: UIViewController {
            window.rootViewController ?? installRootViewController(window)
        }
        static func hostVC<V>(_ view: V) -> UIHostingController<V> where V: View {
            UIHostingController(rootView: view)
        }
        
        // MARK: - WillMove & DidMove
        
        static func willMove(_ child: UIViewController, to parent: UIViewController?) {
            child.willMove(toParent: parent)
        }
        static func didMove(_ child: UIViewController, to parent: UIViewController?) {
            child.didMove(toParent: parent)
        }
        
        // MARK: - ViewController identification
        
        static func store(_ hosted: Hosted, viewId: ViewId) {
            self.hosted[viewId] = hosted
        }
        
        static func expelHosted(viewId: ViewId) -> Hosted? {
            return hosted.removeValue(forKey: viewId)
        }
    }
    
    private extension NSLayoutConstraint {
        func priority(_ value: UILayoutPriority) -> NSLayoutConstraint {
            priority = value
            return self
        }
    }
    

    Here is an example usage:

    struct ContentView: View {
        @State private var img: UIImage?
        var body: some View {
            Group {
                if let img {
                    Image(uiImage: img)
                } else {
                    Text("Waiting...")
                }
            }.task {
                try? await Task.sleep(for: .seconds(1))
                print("Begin snapshot")
                img = await snapshot(of: WebImage(url: URL(string: "https://picsum.photos/200/300"), content: \.self) {
                    ProgressView()
                })
            }
        }
        
        func snapshot(of view: some View) async -> UIImage {
            await ViewHosting.host(view) { vc in
                try? await Task.sleep(for: .seconds(2)) // wait for the image to download
                vc.view.sizeToFit() // resize the view to be an appropriate size
                let renderer = UIGraphicsImageRenderer(size: vc.view.bounds.size)
                return renderer.image { _ in
                    vc.view.drawHierarchy(in: vc.view.bounds, afterScreenUpdates: true)
                }
            }
        }
    }