Search code examples
iosswiftuibezierpathcgaffinetransform

Save and Redraw in a loop Swift 4


I am trying to draw gauge ticks as in the below image in a loop.

Gauge Ticks

I followed the below steps:

1. create a CAShapeLayer and CALayer
2. for loop 
3. draw center top line with UIBezier
4. rotate the layer in the loop
5. add CAShapeLayer to the CALayer with .addSubLayer
5. end loop

Code

let angle = CGFloat(Double.pi / 180 * 10)

turnLayer.frame            = self.bounds
self.layer.addSublayer(turnLayer)
let strokeColor            = UIColor.black.cgColor
thickLayer.frame           = frame
thickLayer.strokeColor     = strokeColor
thickLayer.fillColor       = UIColor.clear.cgColor
thickLayer.lineWidth       = 8.0


for idx in -10...10 {
    var p: CGPoint = CGPoint.init(x: bounds.width/2, y: bounds.height/8)
    let path = UIBezierPath()
    path.move(to: p)
    p = CGPoint.init(x: bounds.width/2, y: bounds.height/8 + 32)
    path.addLine(to: p)
    thickLayer.path = path.cgPath
    thickLayer.setAffineTransform(CGAffineTransform(rotationAngle: angle * CGFloat(idx)))
    turnLayer.addSublayer(thickLayer)

}

Problem

I am lost about how to save and redraw the center top line


Solution

  • Rather than adding a bunch of rotated layers, it might be easier to have a single CAShapeLayer with the strokes for all the ticks, e.g.,

    let shapeLayer = CAShapeLayer()
    shapeLayer.lineWidth = 3
    shapeLayer.fillColor = UIColor.clear.cgColor
    shapeLayer.strokeColor = UIColor.lightGray.cgColor
    view.layer.addSublayer(shapeLayer)
    
    let maxRadius = min(view.bounds.width, view.bounds.height) / 2
    let center = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
    
    let path = UIBezierPath()
    
    for i in 0 ... 16 {
        let angle = -(CGFloat.pi / 2.0) + CGFloat.pi / 2.0 * (CGFloat(i - 8)) / 9.0
        let outerRadius = maxRadius
        let innerRadius = maxRadius - (i % 2 == 0 ? 20 : 10)
        path.move(to: point(angle: angle, from: center, radius: outerRadius))
        path.addLine(to: point(angle: angle, from: center, radius: innerRadius))
    }
    
    shapeLayer.path = path.cgPath
    

    Where

    func point(angle: CGFloat, from center: CGPoint, radius: CGFloat) -> CGPoint {
        return CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle))
    }
    

    That yields:

    enter image description here

    Obviously, tweak the angle, innerRadius and outerRadius to suit your purposes, but this illustrates an easier way to render all of those tick marks.


    If comments, you seemed to object to the above approach, and effectively asked if it is possible to save a snapshot of a view (presumably so you can later show this image in an image view). Yes, it is:

    extension UIView {
        func snapshot(afterScreenUpdates: Bool = false) -> UIImage {
            UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)
            drawHierarchy(in: bounds, afterScreenUpdates: afterScreenUpdates)
            let image = UIGraphicsGetImageFromCurrentImageContext()!
            UIGraphicsEndImageContext()
    
            return image
        }
    }
    

    So, following up with your question of "can I add the shapelayer and save a snapshot", the answer is yes, you can

    let shapeLayer = ...
    view.layer.addSublayer(shapeLayer)
    let image = view.snapshot(afterScreenUpdates: true)
    

    Then, if you wanted to use that snapshot, you'd have to remove the CAShapeLayer and then show the snapshot in some image view:

    shapeLayer.removeFromSuperlayer()
    imageView.image = image
    

    Obviously, that's an incredibly inefficient approach (particularly because you have to use afterScreenUpdates value of true if you want recently added content to be included in the snapshot).

    If you really wanted to efficiently create an image of all the ticks, you'd probably just stroke the UIBezierPath directly to an image context. All you'd need to know was the size of the UIImage to create:

    func tickImage(size: CGSize) -> UIImage {
        UIGraphicsBeginImageContextWithOptions(size, false, 0)
    
        let path = UIBezierPath()
        path.lineWidth = 3
    
        let maxRadius = size.width / 2
        let center = CGPoint(x: size.width / 2, y: size.height)
    
        for i in 0 ... 16 {
            let angle = -(CGFloat.pi / 2.0) + CGFloat.pi / 2.0 * (CGFloat(i - 8)) / 9.0
            let outerRadius = maxRadius
            let innerRadius = maxRadius - (i % 2 == 0 ? 20 : 10)
            path.move(to: point(angle: angle, from: center, radius: outerRadius))
            path.addLine(to: point(angle: angle, from: center, radius: innerRadius))
        }
    
        UIColor.lightGray.setStroke()
        path.stroke()
    
        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()
    
        return image
    }
    

    That having been said, I'm not sure why you'd do that instead of the CAShapeLayer in the beginning of this answer. I'd only do this "create UIImage" approach if I needed to save/upload this snapshot for use at some later date. Otherwise CAShapeLayer is a fine approach.