How to Convert a Uiview to an Image

How to convert a UIView to an image

For example if I have a view of size: 50 50 at 100,100. I can use the following to take a screenshot:

    UIGraphicsBeginImageContextWithOptions(CGSizeMake(100, 100), false, 0);
self.view.drawViewHierarchyInRect(CGRectMake(-50,-5-,view.bounds.size.width,view.bounds.size.height), afterScreenUpdates: true)
var image:UIImage = UIGraphicsGetImageFromCurrentImageContext();

Can you convert uiview that is not currently being displayed to uiimage

You don't have to have a view in a window/on-screen to be able to render it into an image. I've done exactly this in PixelTest:

extension UIView {

/// Creates an image from the view's contents, using its layer.
///
/// - Returns: An image, or nil if an image couldn't be created.
func image() -> UIImage? {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)
guard let context = UIGraphicsGetCurrentContext() else { return nil }
context.saveGState()
layer.render(in: context)
context.restoreGState()
guard let image = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
UIGraphicsEndImageContext()
return image
}

}

This will render a view's layer into an image as it currently looks if it was to be rendered on-screen. That is to say, if the view hasn't been laid out yet, then it won't look the way you expect. PixelTest does this by force-laying out the view beforehand when verifying a view for snapshot testing.

How to convert UIView to UIImage with high resolution?

You need to set the correct content scale on each subview.

extension UIView {   
func scale(by scale: CGFloat) {
self.contentScaleFactor = scale
for subview in self.subviews {
subview.scale(by: scale)
}
}

func getImage(scale: CGFloat? = nil) -> UIImage {
let newScale = scale ?? UIScreen.main.scale
self.scale(by: newScale)

let format = UIGraphicsImageRendererFormat()
format.scale = newScale

let renderer = UIGraphicsImageRenderer(size: self.bounds.size, format: format)

let image = renderer.image { rendererContext in
self.layer.render(in: rendererContext.cgContext)
}

return image
}
}

To create your image:

let image = yourView.getImage()

How to create an image of specific size from UIView

So actually yes, before capturing image I need to scale whole view and it's subviews. Here are my findings (maybe obvious things but it took me a while to realize that – I'll be glad for any improvements)

Rendering image of the same size

When you want to capture UIView as an image, you can simply use this function. Resulted image will have a same size as a view (scaled 2x / 3x depending on actual device)

 func makeImageFrom(_ desiredView: MyView) -> UIImage {
let size = CGSize(width: desiredView.bounds.width, height: desiredView.bounds.height)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { (ctx) in
desiredView.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
}
return image
}

Rendering view to image – same size

Rendering image of the different size

But what to do, when you want a specific size for your exported image?
So from my use-case I wanted to render image of final size (1080 x 1920), but a view I wanted to capture had a smaller size (in my case 275 x 487). If you do such a rendering without anything, there must be a loss in quality.

If you want to avoid that and preserve sharp labels and other subviews, you need to try to scale the view ideally to the desired size. In my case, make it from 275 x 487 to 1080 x 1920.

func makeImageFrom(_ desiredView: MyView) -> UIImage {
let format = UIGraphicsImageRendererFormat()
// We need to divide desired size with renderer scale, otherwise you get output size larger @2x or @3x
let size = CGSize(width: 1080 / format.scale, height: 1920 / format.scale)

let renderer = UIGraphicsImageRenderer(size: size, format: format)
let image = renderer.image { (ctx) in
// remake constraints or change size of desiredView to 1080 x 1920
// handle it's subviews (update font size etc.)
// ...
desiredView.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
// undo the size changes
// ...
}
return image
}

Rendering view to image – bigger size

My approach

But because I didn't want to mess with a size of a view displayed to the user, I took a different way and used second view which isn't shown to the user. That means that just before I want to capture image, I prepare "duplicated" view with the same content but bigger size. I don't add it to the view controller's view hierarchy, so it's not visible.

Important note!

You really need to take care of subviews. That means, that you have to increase the font size, update position of moved subviews (for example their center) etc.!
Here is just a few lines to illustrate that:

        // 1. Create bigger view
let hdView = MyView()
hdView.frame = CGRect(x: 0, y: 0, width: 1080, height: 1920)

// 2. Load content according to the original view (desiredView)
// set text, images...

// 3. Scale subviews
// Find out what scale we need
let scaleMultiplier: CGFloat = 1080 / desiredView.bounds.width // 1080 / 275 = 3.927 ...

// Scale everything, for examples label's font size
[label1, label2].forEach { $0.font = UIFont.systemFont(ofSize: $0.font.pointSize * scaleMultiplier, weight: .bold) }
// or subview's center
subview.center = subview.center.applying(.init(scaleX: scaleMultiplier, y: scaleMultiplier))

// 4. Render image from hdView
let hdImage = makeImageFrom(hdView)

Rendering view to image – bigger size with second view
Difference in quality from real usage – zoomed to the label:
Difference: After / Before

How to convert SwiftUI View body to UIImage in ViewController

You can do this... but not in viewDidLoad() -- you have to wait at least until viewDidLayoutSubviews().

And, the view must be added to the view hierarchy -- but it can be removed as soon as we generate the image so it's never seen "on-screen."

Note: all "result" images here use:

  • a 240 x 200 image view
  • .contentMode = .center
  • green background so we can see the frame

and we give the UIImage generate from the SwiftUI ContentView a yellow background, because we will need to address some layout quirks.

So, to generate the image and set it to a UIImageView, we can do this:

// we will generate the image in viewDidLayoutSubview()
// but that can be (and usually is) called more than once
// so we'll use this to make sure we only generate the image once
var firstTime: Bool = true

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

// we only want this to run once
if firstTime {
firstTime = false
if let img = imageFromContentView() {
imgView.image = img
}
}

}

using this imageFromContentView() func:

func imageFromContentView() -> UIImage? {

let swiftUIView = UIHostingController(rootView: ContentView())

// add as chlld controller
addChild(swiftUIView)

// make sure we can get its view (safely unwrap its view)
guard let v = swiftUIView.view else {
swiftUIView.willMove(toParent: nil)
swiftUIView.removeFromParent()
return nil
}

view.addSubview(v)
swiftUIView.didMove(toParent: self)

// size the view to its content
v.sizeToFit()

// force it to layout its subviews
v.setNeedsLayout()
v.layoutIfNeeded()

// if we want to see the background
v.backgroundColor = .systemYellow

// get it as a UIImage
let img = v.asImage()

// we're done with it, so get rid of it
v.removeFromSuperview()
swiftUIView.willMove(toParent: nil)
swiftUIView.removeFromParent()

return img

}

Result #1:

Sample Image

Notice the 20-pt yellow band at the top, and the content is not vertically centered... that's because the UIHostingController applies a safe area layout guide.

Couple options to get around that...

If we add this line:

    view.addSubview(v)
swiftUIView.didMove(toParent: self)

// add same bottom safe area inset as top
swiftUIView.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: v.safeAreaInsets.top, right: 0)

// size the view to its content
v.sizeToFit()

we get this result:

Sample Image

the rendered image now has 20-pts Top and Bottom "safe area" insets.

If we don't want any safe area insets, we can use this extension:

// extension to remove safe area from UIHostingController
// source: https://stackoverflow.com/a/70339424/6257435
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)
}
}
}

and change the first line in our func to:

let swiftUIView = UIHostingController(rootView: ContentView(), ignoreSafeArea: true)

and we get this result:

Sample Image

Because the SwiftUI ContentView layout is using a zStack where its content (the "ring") exceeds its vertical bounds, the top and bottom of the ring is "clipped."

We can fix that either by changing the framing in ContentView:

Sample Image

or by increasing the frame height of the loaded view, like this for example:

    // size the view to its content
v.sizeToFit()

// for this explicit example, the "ring" extends vertically
// outside the bounds of the zStack
// so we'll add 10-pts height
v.frame.size.height += 10.0

Sample Image


Here's a complete implementation (using your unmodified ContentView):

class ViewController: UIViewController {

let imgView = UIImageView()

override func viewDidLoad() {
super.viewDidLoad()

imgView.contentMode = .center

imgView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imgView)

let g = view.safeAreaLayoutGuide

NSLayoutConstraint.activate([
// let's put the imageView 40-pts from Top
imgView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
// centered horizontally
imgView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// width: 240
imgView.widthAnchor.constraint(equalToConstant: 240.0),
// height: 200
imgView.heightAnchor.constraint(equalToConstant: 200.0),
])

// show the image view background so we
// can see its frame
imgView.backgroundColor = .systemGreen

}

// we will generate the image in viewDidLayoutSubview()
// but that can be (and usually is) called more than once
// so we'll use this to make sure we only generate the image once
var firstTime: Bool = true

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

// we only want this to run once
if firstTime {
firstTime = false
if let img = imageFromContentView() {
imgView.image = img
}
}

}

func imageFromContentView() -> UIImage? {

let swiftUIView = UIHostingController(rootView: ContentView(), ignoreSafeArea: true)

// add as chlld controller
addChild(swiftUIView)

// make sure we can get its view (safely unwrap its view)
guard let v = swiftUIView.view else {
swiftUIView.willMove(toParent: nil)
swiftUIView.removeFromParent()
return nil
}

view.addSubview(v)
swiftUIView.didMove(toParent: self)

// size the view to its content
v.sizeToFit()

// for this explicit example, the "ring" extends vertically
// outside the bounds of the zStack
// so we'll add 10-pts height
v.frame.size.height += 10.0

// force it to layout its subviews
v.setNeedsLayout()
v.layoutIfNeeded()

// if we want to see the background
v.backgroundColor = .systemYellow

// get it as a UIImage
let img = v.asImage()

// we're done with it, so get rid of it
v.removeFromSuperview()
swiftUIView.willMove(toParent: nil)
swiftUIView.removeFromParent()

return img

}
}

// extension to remove safe area from UIHostingController
// source: https://stackoverflow.com/a/70339424/6257435
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)
}
}
}

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


Related Topics



Leave a reply



Submit