黄仁康
黄仁康

Reputation: 11

UIGraphicsImageRenderer returned blank image in SwiftUI

In SwiftUI, I would like to take a screenshot of a subview called ExtractedView. I'm using UIGraphicsImageRenderer to render the view as an image. However, when ExtractedView is too long, I encounter an error message. How can I fix this?

In SwiftUI, I want to take a screenshot of the child view ExtractedView. I use UIGraphicsImageRenderer to render the view as an Image. When the ExtractedView is too long, it outputs a blank Image.(But I need a long picture), and I get the following error message. How can I fix it

Run log:

Render server returned error for view (0x102804310, _TtGC7SwiftUI14_UIHostingViewV5Kylin13ExtractedView_).

imageSize : (462.0, 2803.3333333333335)

Body

var body: some View {
        VStack {
            Button(action: {
                renderToImage()
            }, label: {
                Text("Snapshot")
            })
            ExtractedView()
        }
    }

ExtractedView

struct ExtractedView: View {
    var body: some View {
        ScrollView {
            let contentStr = "About 2000 word long piece of text"
            Text(contentStr)
                .frame(maxWidth: UIScreen.main.bounds.size.width, maxHeight: .infinity)
                .padding()
        }
    }
}

renderToImage:

@MainActor func renderToImage() -> UIImage? {
        return ExtractedView()
            .saveToImage()
    }

saveToImage

extension View {
    func saveToImage() -> 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)
        let image = renderer.image { render in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
        
        return image
    }
}

I tried to use the ImageRenderer for screen capture, but it only supports a limited set of components and does not support ScrollView or TextView, so I gave up on it

Upvotes: 1

Views: 564

Answers (1)

cedricbahirwe
cedricbahirwe

Reputation: 1396

Using ImageRenderer, I achieved a pretty neat image.

As you already mentioned, ImageRenderer is limited in many ways (ScrollView, WebView, etc), my approach is to create a computed property of the content you want to screenshot without the ScrollView, and then pass the content to ImageRenderer.

  1. First, in ExtractedView
struct ExtractedView: View {
    let contentStr = twokwords // I created some at https://ctxt.io/2/AABIE5oBFg
    // 1. create a computed property for the content without the scroll view (text in this case)
    private var myContentView: some View {
        Text(contentStr)
    }
    
    var body: some View {
        ScrollView {
            // 2. Here, you can use the content as you previously did
            myContentView
                .frame(maxWidth: UIScreen.main.bounds.size.width, maxHeight: .infinity)
                .padding()
        }
    }

    // 3. This is just a helper to retrieve the content to screenshot
    // You can remove `private` modifier on `myContentView` property and access it directly
    func getViewToScreenshot() -> some View {
        myContentView
    }
}
  1. Finally
struct ContentView: View {
    @State private var screenshot: UIImage?
    @Environment(\.displayScale) private var displayScale
    
    var body: some View {
        VStack {
            if let screenshot {
                Image(uiImage: screenshot)
                    .resizable()
                    .scaledToFit()
                    .frame(width: UIScreen.main.bounds.width)
                    .frame(maxHeight: .infinity)
                    .onTapGesture {
                        self.screenshot = nil
                    }
            } else {
                Button(action: {
                    if let img = renderToImage() {
                        self.screenshot = img
                    }
                }, label: {
                    Text("Snapshot")
                })
                extractedView
            }
        }
    }
    
    
    private var extractedView: ExtractedView {
        ExtractedView()
    }
    
    @MainActor func renderToImage() -> UIImage? {
        // 1. Get the view you want and add any modifier you want (padding in my case)
        let screenshot = extractedView.getViewToScreenshot().padding()
        let renderer = ImageRenderer(content: screenshot)
        
        // 2. make sure to use the correct display scale for the device
        // Learn more at https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image
        renderer.scale =  displayScale

        // 3. Set the proposedViewSize you want
        var size = ProposedViewSize.infinity
        size.width = 400 // 4. set the width you want for better result
        renderer.proposedSize = size
        
        if let uiImage = renderer.uiImage {
            return uiImage
        }
        return nil
        
    }
}

After some Trial and error, I was able to make the UIGraphicsImageRenderer work as well, basically the approach is to use fixedSize modifier to ensure the view does not compress or truncate vertically

extension View {
    func saveToImage() -> UIImage {
        // Apply the fixedSize modifier to ensure this view is at its ideal size (does not compress or truncate vertically in this case)
        let controller = UIHostingController(rootView: self.fixedSize(horizontal: false, vertical: true))
        guard let view = controller.view else { return .init() }
        
        // Calculate the target size based on the intrinsic content size of the view
        let targetSize = controller.sizeThatFits(in: .init(width: view.intrinsicContentSize.width, height: .greatestFiniteMagnitude))
        view.bounds = CGRect(origin: .zero, size: targetSize)
        view.backgroundColor = .clear
        
        let renderer = UIGraphicsImageRenderer(size: targetSize)
    
        let image = renderer.image { render in
            view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
        
        return image
    }
}

Let me know how it works out for you!

Upvotes: 1

Related Questions