Search code examples
swift

How to draw multiple layers in UIGraphicsImageRenderer?


I am migrating some CGContext code to a UIGraphicsImageRenderer to render an image.

The new code wraps around the old one like this:

let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { imageContext in

   let cgContext = imageContext.cgContext

   // Here goes the old code
   let layer = CGLayer(cgContext, size: size, auxiliaryInfo: nil)!
   let layerCGContext = layer.context!

   // Do some drawing operations (just an example, much more complex in reality)
   cgContext.fill([CGRect(x: 100, y: 100, width: 400, height: 200)])
   layerCGContext.fill([CGRect(x: 150, y: 150, width: 200, height: 400)])

   // Combine the layer on top of the CGContext layer
   cgContext.draw(layer, at: .zero) // <- Code breaks here [1]
}

The above code breaks at [1] if something has been drawn into the layerCGContext (an empty layer does not trigger the error). If I remove that line the rest of the code works, the image is generated but without the extra layer.

How can I generate multiple layers and recombine them within a UIGraphicsImageRenderer.image{} closure?


Solution

  • It appears that UIGraphicsImageRenderer does not support the drawing of CGLayer into an image context (it crashes). Even if you wrap this in the draw implementation of a UIView and then try view.drawHierarchy, that avoids the crash, but the CGLayer instances are not rendered from within UIGraphicsImageRenderer (!).

    This seems worthy of a bug report, though that will not help you any time soon. The only workaround that I see is to use the deprecated UIGraphicsBeginImageContext, UIGraphicsGetImageFromCurrentImageContext, and UIGraphicsEndImageContext. The CGLayer approach works when using that legacy API.

    UIGraphicsBeginImageContext(size)
    defer { UIGraphicsEndImageContext() }
    
    guard
        let cgContext = UIGraphicsGetCurrentContext(),
        let layer = CGLayer(cgContext, size: size, auxiliaryInfo: nil),
        let layerContext = layer.context
    else {
        print("unable to get contexts")
        return
    }
    
    layerContext.setFillColor(UIColor.blue.cgColor)
    layerContext.fill([CGRect(x: 150, y: 150, width: 200, height: 400)])
    
    cgContext.draw(layer, at: .zero)
    let image = UIGraphicsGetImageFromCurrentImageContext()
    

    The other alternative (which I presume is not easily contemplated in your particular situation) is to eliminate the use of CGLayer, and just render the graphics directly.

    E.g., if you want to render these two rectangles, you can just just fill on the respective UIBezierPath instances:

    let size = CGSize(width: 600, height: 600)
    
    let image = UIGraphicsImageRenderer(size: size).image { _ in
        UIColor.red.setFill()
        UIBezierPath(rect: CGRect(x: 100, y: 100, width: 400, height: 200))
            .fill()
        
        UIColor.blue.setFill()
        UIBezierPath(rect: CGRect(x: 150, y: 150, width: 200, height: 400))
            .fill()
    }
    

    Or, if you want to use CoreGraphics, you would retrieve the new CGContext to where you are rendering these rectangles and then call the fill method:

    let image = UIGraphicsImageRenderer(size: size).image { context in
        let cgContext = context.cgContext
        
        cgContext.setFillColor(UIColor.red.cgColor)
        cgContext.fill([CGRect(x: 100, y: 100, width: 400, height: 200)])
    
        cgContext.setFillColor(UIColor.blue.cgColor)
        cgContext.fill([CGRect(x: 150, y: 150, width: 200, height: 400)])
    }