12

Suppose I have a simple SwiftUI View that is not the ContentView such as this:

struct Test: View {        
    var body: some View {
        VStack {
            Text("Test 1")
            Text("Test 2")
        }
    }
}

How can I render this view as a UIImage?

I've looked into solutions such as :

extension UIView {
    func asImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: bounds)
        return renderer.image { rendererContext in
            layer.render(in: rendererContext.cgContext)
        }
    }
}

But it seems that solutions like that only work on UIView, not a SwiftUI View.

user12533955
  • 489
  • 8
  • 20

2 Answers2

22

Here is the approach that works for me, as I needed to get image exactly sized as it is when placed alongside others. Hope it would be helpful for some else.

Demo: above divider is SwiftUI rendered, below is image (in border to show size)

Update: re-tested with Xcode 13.4 / iOS 15.5

Test module in project is here

enter image description here

extension View {
    func asImage() -> UIImage {
        let controller = UIHostingController(rootView: self)

        // locate far out of screen
        controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)

        let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
        controller.view.bounds = CGRect(origin: .zero, size: size)
        controller.view.sizeToFit()
        UIApplication.shared.windows.first?.rootViewController?.view.addSubview(controller.view)

        let image = controller.view.asImage()
        controller.view.removeFromSuperview()
        return image
    }
}
extension UIView {
    func asImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: bounds)
        return renderer.image { rendererContext in
// [!!] Uncomment to clip resulting image
//             rendererContext.cgContext.addPath(
//                UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath)
//            rendererContext.cgContext.clip()

// As commented by @MaxIsom below in some cases might be needed
// to make this asynchronously, so uncomment below DispatchQueue
// if you'd same met crash
//            DispatchQueue.main.async {
                 layer.render(in: rendererContext.cgContext)
//            }
        }
    }
}


// TESTING
struct TestableView: View {
    var body: some View {
        VStack {
            Text("Test 1")
            Text("Test 2")
        }
    }
}

struct TestBackgroundRendering: View {
    var body: some View {
        VStack {
            TestableView()
            Divider()
            Image(uiImage: render())
                .border(Color.black)
        }
    }
    
    private func render() -> UIImage {
        TestableView().asImage()
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • It works! Now if I needed specifically to use a UIImage type inside my view instead of Image would that be possible? Since in the code here we have `Image(uiImage: render())`, is there an equivalent UIImage expression with the render? And can we even use UIImage in a SwiftUI View? – user12533955 Dec 18 '19 at 15:55
  • `render()` returns UIImage and `Image(uiImage:)` is exactly usage of UIImage in SwiftUI. – Asperi Dec 18 '19 at 17:10
  • Needed a UIImage type variable for a 3rd party SDK integration, so I've just used `.asImage().pngData()` to convert the resulting UIImage from `render()` to data so that I can use `UIImage(data: ___ )`. Not sure if this is best practice but works fine! Small follow-up: is there any possible way to add a cornerRadius to the CGRect that is used to capture the View? So that the resulting image of the View has rounded corners and is not a perfect rectangle? – user12533955 Dec 18 '19 at 17:50
  • Added in comment the way how to clip rendering image – Asperi Dec 18 '19 at 18:52
  • 2
    @Asperi Apparently it does not work if `TestableView` contains dynamic data which is passed down to it using `@Binding`. Getting `Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)` in `layer.render` line of UIView extension. – AnaghSharma Apr 19 '20 at 19:50
  • @Asperi would it be possible to render a View as an Image the way you've implemented it, on tap of a Button inside that View? – user12533955 May 01 '20 at 06:27
  • 1
    I'm having the same issue with dynamic data. @AnaghSharma did you find a way to solve it? – Thiago Jul 30 '20 at 12:37
  • 2
    I was able to prevent it from crashing by wrapping `self.layer.render(in: rendererContext.cgContext)` with `DispatchQueue.main.async {}` – Max Isom Aug 17 '20 at 22:38
  • This doesn't work if the view contains a map view in it. – user Sep 23 '20 at 02:11
  • This crashes for me, even with the DispatchQueue.main.async – Brett Nov 01 '20 at 01:35
  • How might you go about using this in an extension? (Messages extension, I'm using SwiftUI rather than UIKit). Xcode complains that "UIApplication.shared" is "unavailable in application extensions for iOS: Use view controller based solutions where appropriate instead. ". In a SwiftUI app, this works perfectly. (Thank you for that!) – tarasis Nov 19 '20 at 00:54
  • Does this only works if the (parent) view is rendered on screen? When I try to use this off screen all I get is an empty image. – MatzeLoCal Nov 19 '20 at 08:40
  • 2
    I get empty image with DispatchQueue.main.async { [weak self] in layer.render }, but correct image without – zdravko zdravkin Jan 29 '21 at 10:35
  • Doesnt Work in Xcode 12.4. no particular error. just would not display in preview. – AndiAna Feb 19 '21 at 09:17
  • @Asperi, This solution works perfect, until I use it for single Views, but if I use this solution inside of Foreach, it crashes with error "AttributeGraph precondition failure: setting value during update: 2072". What could be the solution? – Konstantin.Efimenko Feb 01 '22 at 15:18
  • @Asperi this is not working when You try to convert swiftUI View inside ViewController of storyboard – Arslan Kaleem Apr 02 '22 at 11:52
3

Solution of Asperi works, but if you need image without white background you have to add this line:

controller.view.backgroundColor = .clear

And your View extension will be:

extension View {
    func asImage() -> UIImage {
        let controller = UIHostingController(rootView: self)
        
        // locate far out of screen
        controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
        UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
        
        let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
        controller.view.bounds = CGRect(origin: .zero, size: size)
        controller.view.sizeToFit()
        controller.view.backgroundColor = .clear
        let image = controller.view.asImage()
        controller.view.removeFromSuperview()
        return image
    }
}
Saurabh Prajapati
  • 2,348
  • 1
  • 24
  • 42
Denis Markov
  • 157
  • 1
  • 4