1

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

kitchen800
  • 197
  • 1
  • 12
  • 36
  • What does "a 3d effect" mean? – matt Apr 16 '22 at 17:11
  • When the circle is currently rendered, it’s visually flat because it is one uniform color. A 3D effect would be adding some type of shadowing or something to the uniform color to make not look flat. – kitchen800 Apr 16 '22 at 17:38
  • Well, that's very vague. If you don't even know what you want to do, it's impossible to do it. And in any case it doesn't sound like a CAShapeLayer. – matt Apr 16 '22 at 19:45
  • See above for description of what I was looking to do. – kitchen800 Apr 16 '22 at 22:38
  • @kitchen800 -- your best approach is to ***try*** something. *"I was thinking of creating an offset circle..."* - well, great. Try that. If it gives you your desired "3D effect" then you're all set. If not, show your attempt... show a screen-shot of how it looks... describe what's "not right"... ideally, use an image editor to create the image you *really* want and show us how it differs from what you've got. – DonMag Apr 17 '22 at 14:11
  • yes tried many things, thought it was easier on the basic code was there for everyone to play with if they wished. I must have been mistaken. All solved now anyway. A solution was to use overlapping circles with varying shades and alphas with to give it a 3d effect. It probably isn't the best solution for someone who would like to get the same effect because it does entail drawing several circles at once, a better was could be if someone figured out how to get one circle with a gradient fill on each section. probably many solutions to this, was interest to see others ideas and execution. – kitchen800 Apr 17 '22 at 16:08
  • @matt cool will do when answered. Just updated the question with where I’m at with it. You mentioned before it doesn’t seem like CAShapeLayer would be the right way to tackle it, I’d agree because it would involve drawing a lot of different layers at once which doesn’t seem efficient. What do you think would be the best way to go about it. Do you think maybe an imbedded video would be a more appropriate? – kitchen800 Apr 18 '22 at 22:24

1 Answers1

4

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()
    }
    
}
Stephan Schlecht
  • 26,556
  • 1
  • 33
  • 47
  • 1
    Hey thanks for the help, I really appreciate The time you took you explain that. I learned a lot from that. Especially because I was trying it for so long and kept questioning the results I was getting, if they were the right way or not. – kitchen800 Apr 22 '22 at 12:08