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:
Questions:
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.
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)
}
}
}
}