Snapshot of SwiftUI view is partially cut off

Viewed 1757

I tried to create a UIImage from a SwiftUI view, a snapshot, with the code from HWS: How to convert a SwiftUI view to an image.

I get the following result, which is obviously incorrect because the image is cut-off.

Result

Code:

struct ContentView: View {
    @State private var savedImage: UIImage?

    var textView: some View {
        Text("Hello, SwiftUI")
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .clipShape(Capsule())
    }

    var body: some View {
        ZStack {
            VStack(spacing: 100) {
                textView

                Button("Save to image") {
                    savedImage = textView.snapshot()
                }
            }

            if let savedImage = savedImage {
                Image(uiImage: savedImage)
                    .border(Color.red)
            }
        }
    }
}
extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

It looks like the original view that is snapshot is lower down than it should be, but I'm not sure. How do I fix this?


Edits

We have discovered this problem does not occur on iOS 14, only iOS 15. So the question is... how can this be fixed for iOS 15?

2 Answers

I also recently noticed this issue. I tested on different Simulators (for example, iPhone 8 and iPhone 13 Pro) and realized that the offset seems always half the status bar height. So I suspect that when you call drawHierarchy(in:afterScreenUpdates:), internally SwiftUI always takes safe area insets into account.

Therefore, I modified the snapshot() function in your View extension by using the edgesIgnoringSafeArea(_:) view modifier, and it worked:

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self.edgesIgnoringSafeArea(.all))
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

I also noted this annoying behaviour in iOS15 and think I found a workaround solution until the issue with iOS15 and drawHierarchy(in: afterScreenUpdates:) is solved.

This extension worked (tested on simulator iOS15.0 & iOS14.5 and iPhone XS Max iOS15.0.1) for me in my app, you can set the scale to higher than 1 if you need a higher resolution image:

extension View {
    func snapshotiOS15() -> UIImage {

        let controller = UIHostingController(rootView: self)
        let view = controller.view
    
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        format.opaque = true
            
        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear
            
        let window = UIWindow(frame: view!.bounds)
        window.addSubview(controller.view)
        window.makeKeyAndVisible()
            
        let renderer = UIGraphicsImageRenderer(bounds: view!.bounds, format: format)
        return renderer.image { rendererContext in
                view?.layer.render(in: rendererContext.cgContext)
        }
    }
}

Edit

It turns out that the above solution only works if the view (to be saved as an UIImage) is embedded in a NavigationView for which the view modifier .statusBar(hidden: true) is added. See example code below to reproduce the result:

import SwiftUI

struct StartView: View {
    var body: some View {
        NavigationView {
            TestView()
        }.statusBar(hidden: true)
    }
}

struct TestView: View {
    var testView: some View {
        Text("Hello, World!")
            .font(.system(size: 42.0))
            .foregroundColor(.white)
            .frame(width: 200, height: 200, alignment: .center)
            .background(Color.blue)
    }
    
    var body: some View {
        VStack {
            testView
            Button(action: {
                let imageiOS15   = testView.snapshotiOS15()
            }, label: {
                Text("Take snapshot")
                    .font(.headline)
            })
        }
    }
}
Related