Search code examples
swiftswift5cashapelayer

Giving a CAShapeLayer a 3d effect


I am playing around with countdown timer in swift. What I want to give the countdown circle a 3D shadow of effect. the code below will operate the countdown time perfectly but i guess its more of the visuals i am trying to edit with it. is there any way to make the countdown circle larger and while giving it a 3d effect. If you run the code you will see it is just a 2d type of fill. I have been playing around with overlapping circles with a different color and alpha like a dark color to try and make it look more 3d, but Its definitely not the most efficient because it involves drawing multiple circles at once. Is there a way to get a similar 3d effect like the image below without having to redraw multiple overlapping circles. the code below is for the basic 2d flat version of the counter.

//for countdown timer: ----------------
let timeLeftShapeLayer = CAShapeLayer()
let bgShapeLayer = CAShapeLayer()
var timeLeft: TimeInterval = 10
var endTime: Date?
var timeLabel =  UILabel()
var timer = Timer()
// here you create your basic animation object to animate the strokeEnd
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")

func drawBgShape() {
    bgShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
        100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
    bgShapeLayer.strokeColor = UIColor.white.cgColor
    bgShapeLayer.fillColor = UIColor.clear.cgColor
    bgShapeLayer.lineWidth = 15
    view.layer.addSublayer(bgShapeLayer)
}
func drawTimeLeftShape() {
    timeLeftShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
        100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
    timeLeftShapeLayer.strokeColor = UIColor.red.cgColor
    timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
    timeLeftShapeLayer.lineWidth = 15
    view.layer.addSublayer(timeLeftShapeLayer)
}

func addTimeLabel() {
    timeLabel = UILabel(frame: CGRect(x: view.frame.midX-50 ,y: view.frame.midY/3, width: 100, height: 50))
    timeLabel.textAlignment = .center
    timeLabel.text = timeLeft.time
    view.addSubview(timeLabel)
}

@objc func updateTime() {
    if timeLeft > 0 {
        timeLeft = endTime?.timeIntervalSinceNow ?? 0
        timeLabel.text = timeLeft.time
    } else {
        timeLabel.text = "00:00"
        timer.invalidate()
    }
}

//-------------timer finish------------(extension for timer at bottom of file------

Extensions:

//extensions for timer
extension TimeInterval {
    var time: String {
        return String(format:"%02d:%02d", Int(self/60),  Int(ceil(truncatingRemainder(dividingBy: 60))) )
    }
}
extension Int {
    var degreesToRadians : CGFloat {
        return CGFloat(self) * .pi / 180
    }
}

ViewDidload:

//for countdown timer: -------------
    view.backgroundColor = UIColor(white: 0.94, alpha: 1.0)
        drawBgShape()
        drawTimeLeftShape()
        addTimeLabel()
        // here you define the fromValue, toValue and duration of your animation
        strokeIt.fromValue = 0
        strokeIt.toValue = 1
        strokeIt.duration = timeLeft
        // add the animation to your timeLeftShapeLayer
        timeLeftShapeLayer.add(strokeIt, forKey: nil)
        // define the future end time by adding the timeLeft to now Date()
        endTime = Date().addingTimeInterval(timeLeft)
        timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)

enter image description here


Solution

  • To obtain 3D effects, you usually work with color gradients. In your use case, you would work with a radial CAGradientLayer. You have to mask this layer to see only the area you want to be visible. The path to be filled consists of the area of the outer and the inner circle.

    This fill path can be created as follows:

    let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
    let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
    path.append(inner.reversing())
    

    For the gradients, you can use the locations parameter to specify an array of NSNumber objects that define the location of the gradient stops. The values must be in the range [0,1]. The corresponding associated colors of type CGColor are set in colors property.

    In a simple case you could define something like:

    gradient.locations = [0,                                          //0
                          NSNumber(value: innerRadius / outerRadius), //1
                          NSNumber(value: middle / outerRadius),      //2
                          1]                                          //3
    
    let colors = [color,            //0
                  color,            //1
                  color.lighter(),  //2
                  color,            //3
    ]
    gradient.colors = colors.map { $0.cgColor }
    

    However, the desired 3D appearance will be visible only after applying a mask with the corresponding path, see the right part of the figure:

    gradient with mask

    Animation

    It is easy to see that you can use one CAGradientLayer for the background and one for the foreground. The question then naturally arises, how can we animate the fill process with the foreground gradient?

    This can be achieved by placing the foreground gradient over the background gradient and using a CAShapeLayer as a mask for the foreground gradient. In doing so, the animation is done similarly to the example in your question using the strokeEnd property. Since it is a mask, the foreground gradient becomes visible gradually.

    Gradients

    Gradients can contain several areas. 3D effects are usually achieved by combining slightly lighter or darker gradations of similar colors. For the demo example, I used this nice, minimally modified answer to get lighter or darker color variants.

    Demo

    Using the above points, this may look like the following:

    demo

    The colors, distances and the gradients depend of course strongly on the requirements, this serves only as an example, how one could make such a thing. For the foreground gradient, two similar but different colors were chosen for the shadow area (inner circular area) and the outer circular area, which is in the light.

    Self-Contained Complete Example

    CircleProgressView.swift

    import UIKit
    
    class CircleProgressView: UIView {
    
        private let backgroundGradient = CAGradientLayer()
        private let foregroundGradient = CAGradientLayer()
        private let timeLeftShapeLayer = CAShapeLayer()
        private let backgroundMask = CAShapeLayer()
        private let thickness: CGFloat
    
        private let innerBackgroundColor: UIColor
        private let outerBackgroundColor: UIColor
        private let innerForegroundColor: UIColor
        private let outerForegroundColor: UIColor
    
        init(_ thickness: CGFloat,
             _ innerBackgroundColor: UIColor,
             _ outerBackgroundColor: UIColor,
             _ innerForegroundColor: UIColor,
             _ outerForegroundColor: UIColor) {
            
            self.thickness = thickness
            self.innerBackgroundColor = innerBackgroundColor
            self.outerBackgroundColor = outerBackgroundColor
            self.innerForegroundColor = innerForegroundColor
            self.outerForegroundColor = outerForegroundColor
    
            super.init(frame: .zero)
            
            backgroundGradient.type = .radial
            layer.addSublayer(backgroundGradient)
    
            foregroundGradient.type = .radial
            layer.addSublayer(foregroundGradient)
    
            timeLeftShapeLayer.strokeColor = UIColor.white.cgColor
            timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
            timeLeftShapeLayer.lineWidth = thickness
            layer.addSublayer(timeLeftShapeLayer)
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
            
        private func circle(_ gradient: CAGradientLayer,
                            _ path: UIBezierPath,
                            _ outerRadius: CGFloat,
                            _ innerColor: UIColor,
                            _ outerColor: UIColor) {
            let innerRadius = outerRadius - thickness
            let middle = outerRadius - thickness / 2
            let slice: CGFloat = thickness / 16
            gradient.frame = bounds
            gradient.locations = [0,                                                  //0
                                  NSNumber(value: (innerRadius) / outerRadius),       //1
                                  NSNumber(value: (middle - slice) / outerRadius),    //2
                                  NSNumber(value: (middle) / outerRadius),            //3
                                  NSNumber(value: (middle + slice) / outerRadius),    //4
                                  1]                                                  //5
            
            let colors = [innerColor,               //0
                          innerColor,               //1
                          innerColor.darker(),      //2
                          outerColor,               //3
                          outerColor.lighter(),     //4
                          outerColor                //5
            ]
            gradient.colors = colors.map { $0.cgColor }
            gradient.bounds = path.bounds
            gradient.startPoint = CGPoint(x: 0.5, y: 0.5)
            gradient.endPoint = CGPoint(x: 1, y: 1)
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let outerRadius: CGFloat = min(bounds.width, bounds.height) / 2.0
            let centerPoint = CGPoint(x: bounds.midX, y: bounds.midY)
    
            let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
            let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
            path.append(inner.reversing())
    
    
            circle(backgroundGradient, path, outerRadius, innerBackgroundColor, outerBackgroundColor)
    
            backgroundMask.frame = bounds
            backgroundMask.path = path.cgPath
            backgroundMask.lineWidth = 0
            backgroundGradient.mask = backgroundMask
    
            circle(foregroundGradient, path, outerRadius, innerForegroundColor, outerForegroundColor)
    
            let middlePath = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
            middlePath.lineWidth = thickness
            timeLeftShapeLayer.path = middlePath.cgPath
            foregroundGradient.mask = timeLeftShapeLayer
            timeLeftShapeLayer.strokeEnd = 0
        }
        
        func startAnimation() {
            timeLeftShapeLayer.removeAllAnimations()
            timeLeftShapeLayer.strokeEnd = 1
            DispatchQueue.main.async {
                let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
                strokeIt.fromValue = 0
                strokeIt.toValue = 1
                strokeIt.duration = 5
                self.timeLeftShapeLayer.add(strokeIt, forKey: nil)
            }
        }
        
    }
    

    UIColor+Brightness.swift

    Only for the sake of completeness, please note that the original can be found at https://stackoverflow.com/a/31466450.

    import UIKit
    
    extension UIColor {
    
       func lighter(amount : CGFloat = 0.15) -> UIColor {
           return hueColorWithBrightness(amount: 1 + amount)
       }
    
        func darker(amount : CGFloat = 0.15) -> UIColor {
           return hueColorWithBrightness(amount: 1 - amount)
       }
    
       private func hueColorWithBrightness(amount: CGFloat) -> UIColor {
           var hue: CGFloat = 0
           var saturation: CGFloat = 0
           var brightness: CGFloat = 0
           var alpha: CGFloat = 0
           
           if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
               return UIColor( hue: hue,
                               saturation: saturation,
                               brightness: brightness * amount,
                               alpha: alpha )
           } else {
               return self
           }
       }
    
    }
    

    ViewController.swift

    The call is rather unsurprising and should look something like this:

    import UIKit
    
    class ViewController: UIViewController {
        
        private var circleProgressView: CircleProgressView?
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = UIColor(red: 0xBB / 0xFF, green: 0xBB / 0xFF, blue: 0xBB / 0xFF, alpha: 1)
            
            let innerBackgroundColor = UIColor(red: 0x65 / 0xFF, green: 0x79 / 0xFF, blue: 0x85 / 0xFF, alpha: 1)
            let outerBackgroundColor = innerBackgroundColor
            let innerForegroundColor = UIColor(red: 0xCF / 0xFF, green: 0xC9 / 0xFF, blue: 0x22 / 0xFF, alpha: 1)
            let outerForegroundColor = UIColor(red: 0xF3 / 0xFF, green: 0xCA / 0xFF, blue: 0x46 / 0xFF, alpha: 1)
            
            let progressView = CircleProgressView(24, innerBackgroundColor, outerBackgroundColor, innerForegroundColor, outerForegroundColor)
            circleProgressView = progressView
            progressView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(progressView)
            
            let button = UIButton()
            button.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button)
            button.setTitle("Start", for: .normal)
            button.setTitleColor(.blue, for: .normal)
            button.addTarget(self, action: #selector(onStart), for: .touchUpInside)
            
            let margin: CGFloat = 24
            
            NSLayoutConstraint.activate([
                progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: margin),
                progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
                progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
                button.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: margin),
                button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
                button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
                button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -margin),
            ])
        }
        
        @objc func onStart() {
            circleProgressView?.startAnimation()
        }
        
    }