-1

I am trying to take a SwiftUI view and render it to an image for my application. Currently I am using the following code https://www.hackingwithswift.com/example-code/media/how-to-render-a-uiview-to-a-uiimage and a UIHostingController, however I get weird results with the proper view looking correct, but the generated image not looking correct.

Here is a picture of what I see,

Screenshot of simulator showing the normal view rendering properly, a version where none of the view seems to render, and one where the view is shifted down.

Zimm3r
  • 3,369
  • 5
  • 35
  • 53

1 Answers1

1

iOS 16 Update

Note: iOS 16 has ImageRenderer now see https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image . I do not know if it has the same issues but the below code should work with versions of iOS before 16.

Original Post

The first failure (where the view does not render at all) is because the drawHierarchy fails, seemingly because it isn't run after onAppear (I think).

The second one is because the UIHostingController introduces safe area inset padding (see Cannot place SwiftUI view outside the SafeArea when embedded in UIHostingController )

Here is a sample image with a working example (see code below):

Screenshot of simulator showing the original view, the two bad images, and a final image that is working correctly

Here is an example code with the last one working:

import SwiftUI

struct ContentView: View {
    
    // normal view version using Text
    @State
    var textView:  AnyView
    
    // uiImage created before onAppear
    @State
    var imageBeforeOnAppear:     UIImage?
    
    // uiImage displayed using image without safe area insets
    @State
    var imageWithoutSafeArea:     UIImage?
    
    // uiImage displayed using image with safe area insets
    @State
    var imageWithSafeArea: UIImage?
    
    @State
    var show = false
    
    var body: some View{
        VStack{
            if (!show){
                Button(action: {
                    self.render()
                    self.show.toggle()
                }, label: {Text("Show")})
            }
            if (show){
                Text("Normal View")
                self.textView.border(Color.red, width: 5)
                Text("Image created in init (drawHierarchy fails)")
                Image(uiImage: self.imageBeforeOnAppear!).border(Color.red, width: 5)
                Text("Image created without SafeArea being disabled")
                Image(uiImage: self.imageWithoutSafeArea!).border(Color.red, width: 5)
                Text("Image created with SafeArea being disabled")
                Image(uiImage: self.imageWithSafeArea!).border(Color.red, width: 5)
            }
        }
    }
    
    init(){
        print("Creating content view...")
        
        // generate a nice looking date for our text
        let localDateFormatter = DateFormatter();
        localDateFormatter.dateStyle = DateFormatter.Style.short
        localDateFormatter.timeStyle = DateFormatter.Style.medium
        localDateFormatter.string(from: Date())
        
        let textString = "Generated at \(localDateFormatter.string(from: Date()))"
        print(textString)

        self.textView = AnyView(Text(textString)
            .foregroundColor(Color.white)
            .padding()
            .background(Color.purple))
        
        let iboa = ContentView.toImage(view: textView, disableSafeArea: true)
        print("Image size \(iboa.size)")
        
        // see https://stackoverflow.com/questions/56691630/swiftui-state-var-initialization-issue
        // force this to be initialized in init
        // this will NOT work:
        //     self.imageBeforeOnAppear = iboa
        // it will still be null
        self._imageBeforeOnAppear = State(initialValue: iboa)
    }
    
    // Need to call this after the ContentView is showing!
    // That's why we have a button to delay this (and not
    // run it on init for example) otherwise drawHierarchy
    // will fail if you set afterScreenUpdates to true!
    // (and it won't render anything if you set afterScreenUpdates
    //  to false)
    func render(){
        
        // generate image version with safe area disabled
        self.imageWithSafeArea = ContentView.toImage(view: textView)
        print("Image size \(self.imageWithSafeArea!.size)")
        
        
        // generate image version without safe area disabled
        self.imageWithoutSafeArea = ContentView.toImage(view: textView, disableSafeArea: false)
        print("Image size \(self.imageWithoutSafeArea!.size)")
        
        
    }
    
    static func toImage(view: AnyView, disableSafeArea: Bool = true) -> UIImage{
        print("Thread info  \(Thread.current)")
        print("Thread main? \(Thread.current.isMainThread)")

        let controller = UIHostingController(rootView:view)
        
        if (disableSafeArea){
            // otherwise there is a buffer at the top of the frame
            controller.disableSafeArea()
        }
        
        controller.view.setNeedsLayout()
        controller.view.layoutIfNeeded()
        
        let targetSize = controller.view.intrinsicContentSize
        print("Image Target Size \(targetSize)")
        let rect = CGRect(x: 0,
                          y: 0,
                          width: targetSize.width,
                          height:targetSize.height)
        
        controller.view.bounds = rect
        controller.view.frame  = rect
        // so we at least see something if the SwiftUI view
        // fails to render
        controller.view.backgroundColor = UIColor.green

        let renderer = UIGraphicsImageRenderer(size: targetSize)
        
        let image = renderer.image(actions: { rendererContext in
            controller.view.layer.render(in: rendererContext.cgContext)
            let result = controller.view.drawHierarchy(in: controller.view.bounds,
                                                       afterScreenUpdates: true)

            print("drawHierarchy successful? \(result)")
        })
        
        return image
        
    }
    
   
}

// see https://stackoverflow.com/questions/70156299/cannot-place-swiftui-view-outside-the-safearea-when-embedded-in-uihostingcontrol
extension UIHostingController {
    convenience public init(rootView: Content, ignoreSafeArea: Bool) {
        self.init(rootView: rootView)
        
        if ignoreSafeArea {
            disableSafeArea()
        }
    }
    
    func disableSafeArea() {
        guard let viewClass = object_getClass(view) else { return }
        
        let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        }
        else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
            guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
            
            if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
            }
            
            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}





struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Zimm3r
  • 3,369
  • 5
  • 35
  • 53