Giving a CAShapeLayer a 3d effect - swift

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)

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:
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:
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()
}
}

Related

Custom shape to UISlider and update progress in Swift 5

I have checked some of the StackOverflow answers regarding custom UIView slider but using them I unable to make the slider like this. This makes a circle or half circle. I have figured out some library that makes circle slider using UIView but its not helpful to me so could anyone please help me out. How can I make slider like in below UIImage? Thanks!
You will probably just roll your own. (You obviously could search for third party implementations, but that would be out of scope for StackOverflow.) There are a lot of ways of tackling this, but the basic elements here are:
The pink arc for the overall path. Personally, I'd use a CAShapeLayer for that.
The white arc from the start to the current progress (measured from 0 to 1). Again, a CAShapeLayer would be logical.
The white dot placed at the spot of the current progress. Below I create a CALayer with white background and then apply a CAGradientLayer as a mask to that. You could also just create a UIImage for this.
In terms of how to set the progress, you would set the paths of the pink and white arcs to the same path, but just update the strokeEnd of the white arc. You would also adjust the position of the white dot layer accordingly.
The only complicated thing here is figuring out the center of the arc. In my example below, I calculate it with some trigonometry based upon the bounds of the view so that arc goes from lower left corner, to the top, and back down the the lower right corner. Or you might instead pass the center of the arc as a parameter.
Anyway, it might look like:
#IBDesignable
class ArcView: UIView {
#IBInspectable
var lineWidth: CGFloat = 7 { didSet { updatePaths() } }
#IBInspectable
var progress: CGFloat = 0 { didSet { updatePaths() } }
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
progress = 0.35
}
lazy var currentPositionDotLayer: CALayer = {
let layer = CALayer()
layer.backgroundColor = UIColor.white.cgColor
let rect = CGRect(x: 0, y: 0, width: lineWidth * 3, height: lineWidth * 3)
layer.frame = rect
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UIColor.white.cgColor, UIColor.clear.cgColor]
gradientLayer.type = .radial
gradientLayer.frame = rect
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
gradientLayer.locations = [0.5, 1]
layer.mask = gradientLayer
return layer
}()
lazy var progressShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.lineWidth = lineWidth
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
return shapeLayer
}()
lazy var totalShapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.lineWidth = lineWidth
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = #colorLiteral(red: 0.9439327121, green: 0.5454334617, blue: 0.6426400542, alpha: 1)
return shapeLayer
}()
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
override func layoutSubviews() {
super.layoutSubviews()
updatePaths()
}
}
// MARK: - Private utility methods
private extension ArcView {
func configure() {
layer.addSublayer(totalShapeLayer)
layer.addSublayer(progressShapeLayer)
layer.addSublayer(currentPositionDotLayer)
}
func updatePaths() {
let rect = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
let halfWidth = rect.width / 2
let height = rect.height
let theta = atan(halfWidth / height)
let radius = sqrt(halfWidth * halfWidth + height * height) / 2 / cos(theta)
let center = CGPoint(x: rect.midX, y: rect.minY + radius)
let delta = (.pi / 2 - theta) * 2
let startAngle = .pi * 3 / 2 - delta
let endAngle = .pi * 3 / 2 + delta
let path = UIBezierPath(arcCenter: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
progressShapeLayer.path = path.cgPath // white arc
totalShapeLayer.path = path.cgPath // pink arc
progressShapeLayer.strokeEnd = progress
let currentAngle = (endAngle - startAngle) * progress + startAngle
let dotCenter = CGPoint(x: center.x + radius * cos(currentAngle),
y: center.y + radius * sin(currentAngle))
currentPositionDotLayer.position = dotCenter
}
}
Above, I set the background color of the ArcView so you could see its bounds, but you would obviously set the background color to be transparent.
Now there are tons of additional features you might add (e.g. add user interaction so you could “scrub” it, etc.). See https://github.com/robertmryan/ArcView for example. But the key when designing this sort of stuff is to just break it down into its constituent elements, layering one on top of the other.
You can programmatically set the progress of the arcView to get it to change the current value between values of 0 and 1:
func startUpdating() {
arcView.progress = 0
var current = 0
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in
current += 1
guard let self = self, current <= 10 else {
timer.invalidate()
return
}
self.arcView.progress = CGFloat(current) / 10
}
}
Resulting in:

Issue with CABasicAnimation

So basically I am trying to create dialogView. This dialogView will contain a loading spinner and some text which will be updated depending on the state. I followed two guides to use a CAShapeLayer to draw a circle and then animate a line around it.
Link to code/guides I followed
https://www.youtube.com/watch?v=O3ltwjDJaMk
https://github.com/vinayjn/Spinner/blob/master/Spinner.swift
Despite following most of these tutorials to the tee. My animation will not work. At the moment it just looks like this and will not spin.
class LoginDialogView: UIViewController {
lazy var loaderView: UIView = {
let loaderView = UIView()
loaderView.backgroundColor = .yellow
return loaderView
}()
lazy var dialogTitle : UILabel = {
let dialogTitle = UILabel()
dialogTitle.text = "Apples"
dialogTitle.font = UIFont.systemFont(ofSize: 24.0)
dialogTitle.textAlignment = .left
dialogTitle.numberOfLines = 1
return dialogTitle
}()
private var shouldAddSublayer: Bool {
/*
check if:
1. we have any sublayers at all, if we don't then its safe to add a new, so return true
2. if there are sublayers, see if "our" layer is there, if it is not, return true
*/
guard let sublayers = loaderView.layer.sublayers else { return true }
return sublayers.filter({ $0.name == "progress"}).count == 0
}
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if shouldAddSublayer {
setupCircleLayers()
}
}
fileprivate func setupView() {
view.backgroundColor = .red
view.layer.cornerRadius = 20
view.addSubview(loaderView)
loaderView.snp.makeConstraints { (make) in
make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(10)
make.centerX.equalTo(view.safeAreaLayoutGuide.snp.centerX)
make.height.width.equalTo(100)
}
}
private func setupCircleLayers() {
let trackLayer = createCircleShapeLayer(strokeColor: UIColor.init(red: 56/255, green: 25/255, blue: 49/255, alpha: 1), fillColor: #colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1))
loaderView.layer.addSublayer(trackLayer)
startAnimating()
}
private func startAnimating(){
animateRing()
}
private func animateRing(){
let rotationAnimation = CABasicAnimation(keyPath: "strokeEnd")
rotationAnimation.fromValue = 0 * (CGFloat.pi / 180)
rotationAnimation.toValue = 360 * (CGFloat.pi / 180)
rotationAnimation.duration = 1.6
rotationAnimation.repeatCount = HUGE
loaderView.layer.add(rotationAnimation, forKey: "strokeEnd")
}
private func createCircleShapeLayer(strokeColor: UIColor, fillColor: UIColor) -> CAShapeLayer {
let centerpoint = CGPoint.zero
let circularPath = UIBezierPath(arcCenter: centerpoint, radius: 30, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
let layer = CAShapeLayer()
layer.path = circularPath.cgPath
layer.fillColor = fillColor.cgColor
layer.strokeColor = strokeColor.cgColor
layer.strokeStart = 0
layer.strokeEnd = 0.5
layer.lineCap = .round
layer.lineWidth = 5
layer.position = CGPoint(x: loaderView.frame.size.width / 2, y: loaderView.frame.size.height / 2)
layer.name = "progress"
return layer
}
}
I have looked over and compared the code a couple times and I can't see what I did that was different. So my question is can anyone look over this code and figure out what I did wrong? and Can anyone recommend a safe way to stop this animation that wouldn't cause any potential memory leaks?
Im using ios 13 and xcode 11.3.1
You are not adding the animation to the shape layer; you added it to the container view's layer. Keep a reference to your trackLayer and add the animation to that layer.
class LoginDialogView: UIViewController {
lazy var loaderView: UIView = {
let loaderView = UIView()
loaderView.backgroundColor = .yellow
return loaderView
}()
var trackLayer: CAShapeLayer?
...
...
...
private func setupCircleLayers() {
trackLayer = createCircleShapeLayer(strokeColor: UIColor(red: 56/255,
green: 25/255,
blue: 49/255,
alpha: 1),
fillColor: #colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1))
loaderView.layer.addSublayer(trackLayer!)
startAnimating()
}
...
...
...
private func animateRing(){
let rotationAnimation = CABasicAnimation(keyPath: "strokeEnd")
rotationAnimation.fromValue = 0 * (CGFloat.pi / 180)
rotationAnimation.toValue = 360 * (CGFloat.pi / 180)
rotationAnimation.duration = 1.6
rotationAnimation.repeatCount = HUGE
trackLayer.add(rotationAnimation, forKey: "strokeEnd")
}
...
...
...
}
Note: ... in the above code refers to the truncated portion of your code.

Color animation

I've written simple animations for drawing rectangles in lines, we can treat them as a bars.
Each bar is one shape layer which has a path which animates ( size change and fill color change ).
#IBDesignable final class BarView: UIView {
lazy var pathAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
let red = UIColor(red: 249/255, green: 26/255, blue: 26/255, alpha: 1)
let orange = UIColor(red: 1, green: 167/255, blue: 463/255, alpha: 1)
let green = UIColor(red: 106/255, green: 239/255, blue: 47/255, alpha: 1)
lazy var backgroundColorAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "fillColor")
animation.duration = 1
animation.fromValue = red.cgColor
animation.byValue = orange.cgColor
animation.toValue = green.cgColor
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
#IBInspectable var spaceBetweenBars: CGFloat = 10
var numberOfBars: Int = 5
let data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
override func awakeFromNib() {
super.awakeFromNib()
initSublayers()
}
override func layoutSubviews() {
super.layoutSubviews()
setupLayers()
}
func setupLayers() {
let width = bounds.width - (spaceBetweenBars * CGFloat(numberOfBars + 1)) // There is n + 1 spaces between bars.
let barWidth: CGFloat = width / CGFloat(numberOfBars)
let scalePoint: CGFloat = bounds.height / 10.0 // 10.0 - 10 points is max
guard let sublayers = layer.sublayers as? [CAShapeLayer] else { return }
for i in 0...numberOfBars - 1 {
let barHeight: CGFloat = scalePoint * data[i]
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
var xPos: CGFloat!
if i == 0 {
xPos = spaceBetweenBars
} else if i == numberOfBars - 1 {
xPos = bounds.width - (barWidth + spaceBetweenBars)
} else {
xPos = barWidth * CGFloat(i) + spaceBetweenBars * CGFloat(i) + spaceBetweenBars
}
let startPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: 0)).cgPath
let endPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: -barHeight)).cgPath
sublayers[i].path = startPath
pathAnimation.toValue = endPath
sublayers[i].removeAllAnimations()
sublayers[i].add(pathAnimation, forKey: "path")
sublayers[i].add(backgroundColorAnimation, forKey: "backgroundColor")
}
}
func initSublayers() {
for _ in 1...numberOfBars {
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
}
}
}
The size ( height ) of bar depends of the data array, each sublayers has a different height. Based on this data I've crated a scale.
PathAnimation is changing height of the bars.
BackgroundColorAnimation is changing the collors of the path. It starts from red one, goes through the orange and finish at green.
My goal is to connect backgroundColorAnimation with data array as well as it's connected with pathAnimation.
Ex. When in data array is going to be value 1.0 then the bar going to be animate only to the red color which is a derivated from a base red color which is declared as a global variable. If the value in the data array going to be ex. 4.5 then the color animation will stop close to the delcared orange color, the 5.0 limit going to be this orange color or color close to this. Value closer to 10 going to be green.
How could I connect these conditions with animation properties fromValue, byValue, toValue. Is it an algorithm for that ? Any ideas ?
You have several problems.
You're setting fillMode and isRemovedOnCompletion. This tells me, to be blunt, that you don't understand Core Animation. You need to watch WWDC 2011 Session 421: Core Animation Essentials.
You're adding more layers every time layoutSubviews is called, but not doing anything with them.
You're adding animation every time layoutSubviews runs. Do you really want to re-animate the bars when the double-height “in-call” status bar appears or disappears, or on an interface rotation? It's probably better to have a separate animateBars() method, and call it from your view controller's viewDidAppear method.
You seem to think byValue means “go through this value on the way from fromValue to toValue”, but that's not what it means. byValue is ignored in your case, because you're setting fromValue and toValue. The effects of byValue are explained in Setting Interpolation Values.
If you want to interpolate between colors, it's best to use a hue-based color space, but I believe Core Animation uses an RGB color space. So you should use a keyframe animation to specify intermediate colors that you calculate by interpolating in a hue-based color space.
Here's a rewrite of BarView that fixes all these problems:
#IBDesignable final class BarView: UIView {
#IBInspectable var spaceBetweenBars: CGFloat = 10
var data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
var maxDatum = CGFloat(10)
func animateBars() {
guard window != nil else { return }
let bounds = self.bounds
var flatteningTransform = CGAffineTransform.identity.translatedBy(x: 0, y: bounds.size.height).scaledBy(x: 1, y: 0.001)
let duration: CFTimeInterval = 1
let frames = Int((duration * 60.0).rounded(.awayFromZero))
for (datum, barLayer) in zip(data, barLayers) {
let t = datum / maxDatum
if let path = barLayer.path {
let path0 = path.copy(using: &flatteningTransform)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.duration = 1
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
pathAnimation.fromValue = path0
barLayer.add(pathAnimation, forKey: pathAnimation.keyPath)
let colors = gradient.colors(from: 0, to: t, count: frames).map({ $0.cgColor })
let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
colorAnimation.timingFunction = pathAnimation.timingFunction
colorAnimation.duration = duration
colorAnimation.values = colors
barLayer.add(colorAnimation, forKey: colorAnimation.keyPath)
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
createOrDestroyBarLayers()
let bounds = self.bounds
let barSpacing = (bounds.size.width - spaceBetweenBars) / CGFloat(data.count)
let barWidth = barSpacing - spaceBetweenBars
for ((offset: i, element: datum), barLayer) in zip(data.enumerated(), barLayers) {
let t = datum / maxDatum
let barHeight = t * bounds.size.height
barLayer.frame = bounds
let rect = CGRect(x: spaceBetweenBars + CGFloat(i) * barSpacing, y: bounds.size.height, width: barWidth, height: -barHeight)
barLayer.path = CGPath(rect: rect, transform: nil)
barLayer.fillColor = gradient.color(at: t).cgColor
}
}
private let gradient = Gradient(startColor: .red, endColor: .green)
private var barLayers = [CAShapeLayer]()
private func createOrDestroyBarLayers() {
while barLayers.count < data.count {
barLayers.append(CAShapeLayer())
layer.addSublayer(barLayers.last!)
}
while barLayers.count > data.count {
barLayers.removeLast().removeFromSuperlayer()
}
}
}
private extension UIColor {
var hsba: [CGFloat] {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return [hue, saturation, brightness, alpha]
}
}
private struct Gradient {
init(startColor: UIColor, endColor: UIColor) {
self.startColor = startColor
self.startHsba = startColor.hsba
self.endColor = endColor
self.endHsba = endColor.hsba
}
let startColor: UIColor
let endColor: UIColor
let startHsba: [CGFloat]
let endHsba: [CGFloat]
func color(at t: CGFloat) -> UIColor {
let out = zip(startHsba, endHsba).map { $0 * (1.0 - t) + $1 * t }
return UIColor(hue: out[0], saturation: out[1], brightness: out[2], alpha: out[3])
}
func colors(from t0: CGFloat, to t1: CGFloat, count: Int) -> [UIColor] {
var colors = [UIColor]()
colors.reserveCapacity(count)
for i in 0 ..< count {
let s = CGFloat(i) / CGFloat(count - 1)
let t = t0 * (1 - s) + t1 * s
colors.append(color(at: t))
}
return colors
}
}
Result:

Reload values for draw circle

Hi I'm creating a semicircle that updates your line widh depending on the values I give you that are always varying. I want to know how to update the values to determine the end angle and that these are always updated. Thanks
class Circle: UIView {
//These 2 var (total dictionary and Secondary dictionary) have the number of the keys and the values ​​are always varying because they are obtained from json data
var dictionaryTotal: [String:String] = ["a":"153.23", "b":"162.45", "c":"143.65", "d":"140.78", "e":"150.23"]
var dictionarySecundario: [String:String] = ["w":"153.23", "l":"162.45", "v":"143.65"]
var lineWidth: CGFloat = 25
// circles radius
var half = CGFloat.pi
var quarter = CGFloat(3 * CGFloat.pi / 2)
func drawCircleCenteredAtVariavel(center:CGPoint, withRadius radius:CGFloat) -> UIBezierPath{
let circlePath = UIBezierPath(arcCenter: centerOfCircleView, radius: halfOfViewsSize, startAngle: half, endAngle: CGFloat(((CGFloat(self.dictionarySecundario.count) * CGFloat.pi) / CGFloat(self. dictionaryTotal.count)) + CGFloat.pi), clockwise: true)
circlePath.lineWidth = lineWidth
UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1).set()
return circlePath
}
override func draw(_ rect: CGRect) {
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}
}
This is the picture of semicircle
It's not clear from your question what you're trying to accomplish, so this isn't really answer, but it's more than I wanted to type in a comment.
The first thing you should do is separate responsibilities in so the Circle class above should only be responsible for drawing the semicircle for a particular configuration.
For example, a semicircular progress view based on your code above:
class CircleProgressView: UIView {
var lineWidth: CGFloat = 25
var barColor = UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1)
var progress: Float = 0
{
didSet {
progress = max(min(progress, 1.0), 0.0)
setNeedsDisplay()
}
}
func semicirclePath(at center: CGPoint, radius: CGFloat, percentage: Float) -> UIBezierPath {
let endAngle = CGFloat.pi + (CGFloat(percentage) * CGFloat.pi)
let circlePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat.pi, endAngle: endAngle, clockwise: true)
circlePath.lineWidth = lineWidth
return circlePath
}
override func draw(_ rect: CGRect) {
barColor.set()
let center = CGPoint.init(x: bounds.midX, y: bounds.midY)
let radius = max(bounds.width, bounds.height)/2 - lineWidth/2
semicirclePath(at: center, radius: radius, percentage: progress).stroke()
}
}
Then, outside of this class, you would have other code that would determine what progress is based on the JSON values you're receiving.
Assuming that your drawCircleCenteredAtVariavel works, you should redraw whenever the lineWidth value is changed. Try this
var lineWidth: CGFloat = 25 {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}

Why doesn't UIView.animateWithDuration affect this custom view?

I designed a custom header view that masks an image and draws a border on the bottom edge, which is an arc. It looks like this:
Here's the code for the class:
class HeaderView: UIView
{
private let imageView = UIImageView()
private let dimmerView = UIView()
private let arcShape = CAShapeLayer()
private let maskShape = CAShapeLayer() // Masks the image and the dimmer
private let titleLabel = UILabel()
#IBInspectable var image: UIImage? { didSet { self.imageView.image = self.image } }
#IBInspectable var title: String? { didSet {self.titleLabel.text = self.title} }
#IBInspectable var arcHeight: CGFloat? { didSet {self.setupLayers()} }
// MARK: Initialization
override init(frame: CGRect)
{
super.init(frame:frame)
initMyStuff()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder:aDecoder)
initMyStuff()
}
override func prepareForInterfaceBuilder()
{
backgroundColor = UIColor.clear()
}
internal func initMyStuff()
{
backgroundColor = UIColor.clear()
titleLabel.font = Font.AvenirNext_Bold(24)
titleLabel.text = "TITLE"
titleLabel.textColor = UIColor.white()
titleLabel.layer.shadowColor = UIColor.black().cgColor
titleLabel.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
titleLabel.layer.shadowRadius = 0.0;
titleLabel.layer.shadowOpacity = 1.0;
titleLabel.layer.masksToBounds = false
titleLabel.layer.shouldRasterize = true
imageView.contentMode = UIViewContentMode.scaleAspectFill
addSubview(imageView)
dimmerView.frame = self.bounds
dimmerView.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.6)
addSubview(dimmerView)
addSubview(titleLabel)
// Add the shapes
self.layer.addSublayer(arcShape)
self.layer.addSublayer(maskShape)
self.layer.masksToBounds = true // This seems to be unneeded...test more
// Set constraints
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView .autoPinEdgesToSuperviewEdges()
titleLabel.autoCenterInSuperview()
}
func setupLayers()
{
let aHeight = arcHeight ?? 10
// Create the arc shape
arcShape.path = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight)
arcShape.strokeColor = UIColor.white().cgColor
arcShape.lineWidth = 1.0
arcShape.fillColor = UIColor.clear().cgColor
// Create the mask shape
let maskPath = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight, closed: true)
maskPath.moveTo(nil, x: bounds.size.width, y: bounds.size.height)
maskPath.addLineTo(nil, x: bounds.size.width, y: 0)
maskPath.addLineTo(nil, x: 0, y: 0)
maskPath.addLineTo(nil, x: 0, y: bounds.size.height)
//let current = CGPathGetCurrentPoint(maskPath);
//print(current)
let mask_Dimmer = CAShapeLayer()
mask_Dimmer.path = maskPath.copy()
maskShape.fillColor = UIColor(red: 0, green: 0, blue: 0, alpha: 1.0).cgColor
maskShape.path = maskPath
// Apply the masks
imageView.layer.mask = maskShape
dimmerView.layer.mask = mask_Dimmer
}
override func layoutSubviews()
{
super.layoutSubviews()
// Let's go old school here...
imageView.frame = self.bounds
dimmerView.frame = self.bounds
setupLayers()
}
}
Something like this will cause it to just snap to the new size without gradually changing its frame:
UIView.animate(withDuration: 1.0)
{
self.headerView.arcHeight = self.new_headerView_arcHeight
self.headerView.frame = self.new_headerView_frame
}
I figure it must have something to do with the fact that I'm using CALayers, but I don't really know enough about what's going on behind the scenes.
EDIT:
Here's the function I use to create the arc path:
class func createHorizontalArcPath(_ startPoint:CGPoint, width:CGFloat, arcHeight:CGFloat, closed:Bool = false) -> CGMutablePath
{
// http://www.raywenderlich.com/33193/core-graphics-tutorial-arcs-and-paths
let arcRect = CGRect(x: startPoint.x, y: startPoint.y-arcHeight, width: width, height: arcHeight)
let arcRadius = (arcRect.size.height/2) + (pow(arcRect.size.width, 2) / (8*arcRect.size.height));
let arcCenter = CGPoint(x: arcRect.origin.x + arcRect.size.width/2, y: arcRect.origin.y + arcRadius);
let angle = acos(arcRect.size.width / (2*arcRadius));
let startAngle = CGFloat(M_PI)+angle // (180 degrees + angle)
let endAngle = CGFloat(M_PI*2)-angle // (360 degrees - angle)
// let startAngle = radians(180) + angle;
// let endAngle = radians(360) - angle;
let path = CGMutablePath();
path.addArc(nil, x: arcCenter.x, y: arcCenter.y, radius: arcRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false);
if(closed == true)
{path.addLineTo(nil, x: startPoint.x, y: startPoint.y);}
return path;
}
BONUS:
Setting the arcHeight property to 0 results in no white line being drawn. Why?
The Path property can't be animated. You have to approach the problem differently. You can draw an arc 'instantly', any arc, so that tells us that we need to handle the animation manually. If you expect the entire draw process to take say 3 seconds, then you might want to split the process to 1000 parts, and call the arc drawing function 1000 times every 0.3 miliseconds to draw the arc again from the beginning to the current point.
self.headerView.arcHeight is not a animatable property. It is only UIView own properties are animatable
you can do something like this
let displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode
let expectedFramesPerSecond = 60
var diff : CGFloat = 0
func update() {
let diffUpdated = self.headerView.arcHeight - self.new_headerView_arcHeight
let done = (fabs(diffUpdated) < 0.1)
if(!done){
self.headerView.arcHeight -= diffUpdated/(expectedFramesPerSecond*0.5)
self.setNeedsDisplay()
}
}