30

Similar to this thread: How to convert a UIView to an image.

I would like to convert a SwiftUI View rather than a UIView to an image.

pawello2222
  • 46,897
  • 22
  • 145
  • 209
JHack
  • 301
  • 1
  • 3
  • 4
  • Probably impossible. Remember, in `UIKit` (a totally different stack) you aren't converting a `UIView` to "an image" you are converting it to a `UIImage`. Since (a) that is 100% UIKit and (b) SwiftUI is a few months from being GM version 1, I think your best bet is to figure out how to use `UIKit` and `UIViewControllerRepresentable`. (Maybe kick off a function? That's why I'm suggesting a `UIViewController`.) –  Jul 25 '19 at 11:28

4 Answers4

40

Although SwiftUI does not provide a direct method to convert a view into an image, you still can do it. It is a little bit of a hack, but it works just fine.

In the example below, the code captures the image of two VStacks whenever they are tapped. Their contents are converted into a UIImage (that you can later save to a file if you need). In this case, I am just displaying it below.

Note that the code can be improved, but it provides the basics to get you started. I use GeometryReader to get the coordinates of the VStack to capture, but it could be improved with Preferences to make it more robust. Check the links provided, if you need to learn more about it.

Also, in order to convert an area of the screen to an image, we do need a UIView. The code uses UIApplication.shared.windows[0].rootViewController.view to get the top view, but depending on your scenario you may need to get it from somewhere else.

Good luck!

enter image description here

And this is the code (tested on iPhone Xr simulator, Xcode 11 beta 4):

import SwiftUI

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

struct ContentView: View {
    @State private var rect1: CGRect = .zero
    @State private var rect2: CGRect = .zero
    @State private var uiimage: UIImage? = nil

    var body: some View {
        VStack {
            HStack {
                VStack {
                    Text("LEFT")
                    Text("VIEW")
                }
                .padding(20)
                .background(Color.green)
                .border(Color.blue, width: 5)
                .background(RectGetter(rect: $rect1))
                .onTapGesture { self.uiimage = UIApplication.shared.windows[0].rootViewController?.view.asImage(rect: self.rect1) }

                VStack {
                    Text("RIGHT")
                    Text("VIEW")
                }
                .padding(40)
                .background(Color.yellow)
                .border(Color.green, width: 5)
                .background(RectGetter(rect: $rect2))
                .onTapGesture { self.uiimage = UIApplication.shared.windows[0].rootViewController?.view.asImage(rect: self.rect2) }

            }

            if uiimage != nil {
                VStack {
                    Text("Captured Image")
                    Image(uiImage: self.uiimage!).padding(20).border(Color.black)
                }.padding(20)
            }

        }

    }
}

struct RectGetter: View {
    @Binding var rect: CGRect

    var body: some View {
        GeometryReader { proxy in
            self.createView(proxy: proxy)
        }
    }

    func createView(proxy: GeometryProxy) -> some View {
        DispatchQueue.main.async {
            self.rect = proxy.frame(in: .global)
        }

        return Rectangle().fill(Color.clear)
    }
}
Rom4in
  • 532
  • 4
  • 13
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • Very nice hack. One question - using `UIKit` and `CoreGraphics` (not part of the linked question) you can take a "screenshot" of a `UIView`, which includes it's rendered subviews. Can this do that? Or will it take... well since it's `GeometryReader`, which is parent view, do you have to be sure to prevent... reverse-recursion? –  Jul 25 '19 at 19:09
  • 2
    Hi @dfd I'm afraid we are well inside the realm of experimentation ;-) For what I've seen, all SwiftUI views are just wrapped UIKit views. Lists are backed by UITableView, TextField is backed by UITextField, and so on. That why I though this approach would work. I don't foresee any recursion problems, but more testing may be required. – kontiki Jul 25 '19 at 19:21
  • This works. But how would I extend this to get the whole view (not just the current viewable area)? – JHack Jul 30 '19 at 14:06
  • I'm afraid this approach will only work for the visible area. :-( – kontiki Jul 30 '19 at 14:07
  • 1
    @kontiki I see that your code is rendering a portion of the rootViewController. Is there any way to capture the topViewController? (e.g. a modal sheet?) – Anthony Dec 02 '19 at 01:33
  • Great, the best part is your post about [Preferences](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/) . I'm using its ideas a lot. thank you @kontiki – FRIDDAY Jan 03 '20 at 13:35
  • Great answer maybe use `UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.rootViewController.view` as compared to `UIApplication.shared.windows[0].rootViewController.view` – DvixExtract Jun 25 '20 at 04:29
  • @kontiki - any idea on how to create this UIImage with a transparent background? – Kayla tucker Jul 14 '20 at 00:47
  • `self.rect = proxy.frame(in: CoordinateSpace.global)` crashes canvas. How to avoid that? – orkenstein Jul 29 '20 at 15:28
  • Is there a way to round corners of the images? – connorvo Aug 27 '20 at 23:01
  • This doesn't work if the view is presented in a sheet – DanielZanchi Dec 13 '20 at 16:36
  • If you want to capture a sheet you can use: UIApplication.shared.windows[0].rootViewController?.presentedViewController?.view.asImage(rect: self.captureRect) – i4guar Jul 28 '21 at 06:49
  • 1
    `UIApplication.shared.windows` is deprecated in iOS15. Any idea how to update this answer so that the deprecation warning goes away? The warning says to "use UIWindowScene.windows on a relevant window scene instead". I have yet to figure out how to do that – K. Shores Jan 24 '22 at 00:37
9

Solution

Here is a possible solution that uses a UIHostingController that is inserted in the background of the rootViewController:

func convertViewToData<V>(view: V, size: CGSize, completion: @escaping (Data?) -> Void) where V: View {
    guard let rootVC = UIApplication.shared.windows.first?.rootViewController else {
        completion(nil)
        return
    }
    let imageVC = UIHostingController(rootView: view.edgesIgnoringSafeArea(.all))
    imageVC.view.frame = CGRect(origin: .zero, size: size)
    DispatchQueue.main.async {
        rootVC.view.insertSubview(imageVC.view, at: 0)
        let uiImage = imageVC.view.asImage(size: size)
        imageVC.view.removeFromSuperview()
        completion(uiImage.pngData())
    }
}

You also need a modified version of the asImage extension proposed here by kontiki (setting UIGraphicsImageRendererFormat is necessary as new devices can have 2x or 3x scale):

extension UIView {
    func asImage(size: CGSize) -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        return UIGraphicsImageRenderer(size: size, format: format).image { context in
            layer.render(in: context.cgContext)
        }
    }
}

Usage

Assuming you have some test view:

var testView: some View {
    ZStack {
        Color.blue
        Circle()
            .fill(Color.red)
    }
}

you can convert this View to Data which can be used to return an Image (or UIImage):

convertViewToData(view: testView, size: CGSize(width: 300, height: 300)) {
    guard let imageData = $0, let uiImage = UIImage(data: imageData) else { return }
    return Image(uiImage: uiImage)
}

The Data object can also be saved to file, shared...


Demo

struct ContentView: View {
    @State var imageData: Data?

    var body: some View {
        VStack {
            testView
                .frame(width: 50, height: 50)
            if let imageData = imageData, let uiImage = UIImage(data: imageData) {
                Image(uiImage: uiImage)
            }
        }
        .onAppear {
            convertViewToData(view: testView, size: .init(width: 300, height: 300)) {
                imageData = $0
            }
        }
    }

    var testView: some View {
        ZStack {
            Color.blue
            Circle()
                .fill(Color.red)
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • how exactly is this line `let imageData = $0` is converting a view to data? – Duck Jun 26 '21 at 14:10
  • @Duck First you pass `testView` to `convertViewToData`. Then this function calls the completion handler passed as a parameter: `completion: @escaping (Data?) -> Void`. In other words, `let imageData = $0` is just assigning the result of `convertViewToData` to a variable. – pawello2222 Jun 30 '21 at 20:54
6

Following kontiki answer, here is the Preferences way

import SwiftUI

struct ContentView: View {
    @State private var uiImage: UIImage? = nil
    @State private var rect1: CGRect = .zero
    @State private var rect2: CGRect = .zero

    var body: some View {
        VStack {
            HStack {
                VStack {
                    Text("LEFT")
                    Text("VIEW")
                }
                .padding(20)
                .background(Color.green)
                .border(Color.blue, width: 5)
                .getRect($rect1)
                .onTapGesture {
                    self.uiImage =  self.rect1.uiImage
                }

                VStack {
                    Text("RIGHT")
                    Text("VIEW")
                }
                .padding(40)
                .background(Color.yellow)
                .border(Color.green, width: 5)
                .getRect($rect2)
                .onTapGesture {
                    self.uiImage =  self.rect2.uiImage
                }
            }

            if uiImage != nil {
                VStack {
                    Text("Captured Image")
                    Image(uiImage: self.uiImage!).padding(20).border(Color.black)
                }.padding(20)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension CGRect {
    var uiImage: UIImage? {
        UIApplication.shared.windows
            .filter{ $0.isKeyWindow }
            .first?.rootViewController?.view
            .asImage(rect: self)
    }
}

extension View {
    func getRect(_ rect: Binding<CGRect>) -> some View {
        self.modifier(GetRect(rect: rect))
    }
}

struct GetRect: ViewModifier {

    @Binding var rect: CGRect

    var measureRect: some View {
        GeometryReader { proxy in
            Rectangle().fill(Color.clear)
                .preference(key: RectPreferenceKey.self, value:  proxy.frame(in: .global))
        }
    }

    func body(content: Content) -> some View {
        content
            .background(measureRect)
            .onPreferenceChange(RectPreferenceKey.self) { (rect) in
                if let rect = rect {
                    self.rect = rect
                }
            }

    }
}

extension GetRect {
    struct RectPreferenceKey: PreferenceKey {
        static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
            value = nextValue()
        }

        typealias Value = CGRect?

        static var defaultValue: CGRect? = nil
    }
}

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

kumar shivang
  • 174
  • 2
  • 9
2

I came up with a solution when you can save to UIImage a SwiftUI View that is not on the screen. The solution looks a bit weird, but works fine.

First create a class that serves as connection between UIHostingController and your SwiftUI. In this class, define a function that you can call to copy your "View's" image. After you do this, simply "Publish" new value to update your views.

class Controller:ObservableObject {
     
    @Published var update=false
    
    var img:UIImage?
    
    var hostingController:MySwiftUIViewHostingController?
    
    init() {

    }
    
    func copyImage() {
        img=hostingController?.copyImage()
        update=true
    }
}

Then wrap your SwiftUI View that you want to copy via UIHostingController

class MySwiftUIViewHostingController: UIHostingController<TestView> {
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    func copyImage()->UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: self.view.bounds)
        
        return renderer.image(actions: { (c) in
            self.view.layer.render(in: c.cgContext)
        })
    }
    
}

The copyImage() function returns the controller's view as UIImage

Now you need to present UIHostingController:

struct MyUIViewController:UIViewControllerRepresentable {
    
    @ObservedObject var cntrl:Controller
    
    func makeUIViewController(context: Context) -> MySwiftUIViewHostingController {
        let controller=MySwiftUIViewHostingController(rootView: TestView())
        cntrl.hostingController=controller
        return controller
    }
    
    func updateUIViewController(_ uiViewController: MySwiftUIViewHostingController, context: Context) {
        
    }
    
}

And the rest as follows:

struct TestView:View {
    
    var body: some View {
        VStack {
            Text("Title")
            Image("img2")
                .resizable()
                .aspectRatio(contentMode: .fill)
            Text("foot note")
        }
    }
}

import SwiftUI

struct ContentView: View {
    @ObservedObject var cntrl=Controller()
    var body: some View {
        ScrollView {
            VStack {
                HStack {
                    Image("img1")
                        .resizable()
                        .scaledToFit()
                        .border(Color.black, width: 2.0)
                        .onTapGesture(count: 2) {
                            print("tap registered")
                            self.cntrl.copyImage()
                    }
                    Image("img1")
                        .resizable()
                        .scaledToFit()
                        .border(Color.black, width: 2.0)
                }
                
                TextView()
                ImageCopy(cntrl: cntrl)
                    .border(Color.red, width: 2.0)
                TextView()
                TextView()
                TextView()
                TextView()
                TextView()
                MyUIViewController(cntrl: cntrl)
                    .aspectRatio(contentMode: .fit)
            }
            
        }
    }
}

struct ImageCopy:View {
    @ObservedObject var cntrl:Controller
    var body: some View {
        VStack {
            Image(uiImage: cntrl.img ?? UIImage())
                .resizable()
                .frame(width: 200, height: 200, alignment: .center)
        }
        
    }
}

struct TextView:View {
    
    var body: some View {
        VStack {
            Text("Bla Bla Bla Bla Bla ")
            Text("Bla Bla Bla Bla Bla ")
            Text("Bla Bla Bla Bla Bla ")
            Text("Bla Bla Bla Bla Bla ")
            Text("Bla Bla Bla Bla Bla ")
            
        }
        
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

You need img1 and img2 (the one that gets copied). I put everything into a scrollview so that one can see that the image copies fine even when not on the screen.

matyasl
  • 295
  • 2
  • 11
  • This only seems to work if you actually display `MyUIViewController`. If you want to save an image to a png for instance, so the UIView is not displayed, `makeUIViewController` is never called. Any ideas to force it to render ? – Guy Brooker Jun 07 '20 at 02:20
  • Hello Guy, I don't know. I needed a solution that would save a view as an image after being added to a scrollview. I also needed this to work even if the view itself was not displayed yet and one would have to scroll down to see it. Fortunately my code works well enough for my needs and I haven't been looking for other solution yet. Regards Libor. – matyasl Jun 08 '20 at 09:00
  • Thanks for the clarification. I was trying it in a tvOS Top Shelf extension, I wanted to create an image to display using swiftUI to layout, but although I could make it work with UILabels, no matter how I tried, I could not get the SwiftUIHostingViewContainer to render. – Guy Brooker Jun 09 '20 at 11:44
  • If I create a new project and use this, it works, but when I try to use it in a bigger project I already have, it doesn't copy the image, any ideas? – gexhange Jun 22 '20 at 23:25
  • Gexhange, you would need to be a bit more specific. I can only guess what can be wrong. I use it throughout my project and for the moment works fine. Try to step-through and see if the func copyImage()->UIImage {...} gets called. Anyway, may be this function will be available in the new SwiftUI :). Regards Libor. – matyasl Jun 24 '20 at 08:54