I'm trying to create a Custom Progress Bar View and the idea is to have a timer that will update every second. However, the transition from the progress is not so smooth.
If I reduce the timer interval to something like 0.01 instead of 0.1 the "animation" works nicely; however, I'm trying to avoid it.
There's some way to make this animation smooth using 1 second on the Timer interval?
class CustomProgressBar: UIView {
private let progressLayer = CALayer()
var progress: CGFloat = 1.0 {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
addSublayers()
startTimer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
addSublayers()
startTimer()
}
private func startTimer() {
Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(updateProgressBar), userInfo: nil, repeats: true)
}
override func draw(_ rect: CGRect) {
let backgroundMask = CAShapeLayer()
backgroundMask.path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height * 0.5).cgPath
layer.mask = backgroundMask
updateLayer(rect: rect, color: .white)
backgroundColor = .black
progressLayer.backgroundColor = UIColor.white.cgColor
}
private func addSublayers() {
layer.addSublayer(progressLayer)
}
#objc func updateProgressBar() {
progress -= 0.1
}
private func updateLayer(rect: CGRect, color: UIColor?) {
let progressRectBorderSize = rect.height * 0.2
let width: CGFloat
width = max(rect.width * progress - (progressRectBorderSize * 2), 0)
let progressRect = CGRect(origin: CGPoint(x: progressRectBorderSize, y: progressRectBorderSize), size: CGSize(width: width, height: rect.height - (progressRectBorderSize * 2)))
let progressBackgroundMask = CAShapeLayer()
let progressBackgroundMaskRoundedRect = CGRect(x: 0, y: 0, width: progressRect.width, height: progressRect.height)
progressBackgroundMask.path = UIBezierPath(roundedRect: progressBackgroundMaskRoundedRect, cornerRadius: progressRect.height * 0.5).cgPath
progressLayer.mask = progressBackgroundMask
progressLayer.frame = progressRect
}
}
Here is how I create CustomProgressBar for you according to your needs.
I gave it a run, it works smoothly. You can customize it according to your Design needs.
class CustomProgressBar: UIView {
private var progressLayerBackground = CAShapeLayer()
private var progressLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ rect: CGRect) {
progressLayerBackground = createBarShapeLayer(strokeColor: .black, rect: rect, margin: 10)
layer.addSublayer(progressLayerBackground)
progressLayer = createBarShapeLayer(strokeColor: .white, rect: rect, margin: 10)
progressLayer.lineWidth = 15
progressLayer.strokeEnd = 0 // This define how much will be width of bar at start.
layer.addSublayer(progressLayer)
animateBar()
backgroundColor = .white
progressLayerBackground.backgroundColor = UIColor.white.cgColor
}
private func addSublayers() {
layer.addSublayer(progressLayerBackground)
}
private func createBarShapeLayer(strokeColor: UIColor, rect: CGRect, margin: CGFloat) -> CAShapeLayer {
let layer = CAShapeLayer()
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: rect.width - margin, y: rect.height / 2))
linePath.addLine(to: CGPoint(x: rect.minX + margin , y: rect.height / 2))
layer.path = linePath.cgPath
layer.strokeColor = strokeColor.cgColor
layer.lineWidth = 20
layer.strokeEnd = 1
layer.lineCap = .round
return layer
}
private func animateBar() {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 2 // This defines how much will be duration of animation.
basicAnimation.fillMode = .forwards
basicAnimation.beginTime = CACurrentMediaTime() + 0.5
basicAnimation.isRemovedOnCompletion = false
progressLayer.add(basicAnimation, forKey: "moveHorizontally")
}
}
Demo: https://drive.google.com/file/d/1iGwcPPe7uQROa9s276t4SIsHY42fTJ1D/view?usp=sharing
Related
Recently ran into a problem in one of my tasks. I tried to solve this problem for two days. Finally I came here.
I have a custom progress view. Don't look at the colors, they are so awful just for the debugging process to see better.
[the look I have now] 1
[the look I want to have] 2
As you can see, there is a red dot at the end of the progress layer, but it is cut at the top and bottom ...
And it shouldn't be like that ...
Also I will leave here all the code I have, maybe someone
can help me.
Thank you all in advance for your time.
import UIKit
class PlainProgressBar: UIView {
//MARK: - Private
//colour of progress layer : default -> white
private var color: UIColor = .white {
didSet { setNeedsDisplay() }
}
// progress numerical value
private var progress: CGFloat = 0.0 {
didSet { setNeedsDisplay() }
}
private var height : CGFloat = 0.0 {
didSet{ setNeedsDisplay() }
}
private let progressLayer = CALayer()
private let dotLayer = CALayer()
private let backgroundMask = CAShapeLayer()
private func setupLayers() {
layer.addSublayer(progressLayer)
layer.addSublayer(dotLayer)
}
//MARK:- Public
// set color for progress layer
func setColor(progressLayer color: UIColor){
self.color = color
}
func set(progress: CGFloat){
if progress > 1.0{
self.progress = 1.0
}
if progress < 0.0{
self.progress = 0.0
}
if progress >= 0.0 && progress <= 1.0{
self.progress = progress
}
}
func set(height: CGFloat){
self.height = height
}
override init(frame: CGRect) {
super.init(frame: frame)
setupLayers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayers()
}
//Main draw function for view
override func draw(_ rect: CGRect) {
backgroundMask.path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: CGSize(width: rect.width, height: height)), cornerRadius: rect.height * 0.25).cgPath
layer.mask = backgroundMask
let dotOnProgressLayer = CGRect(origin: .zero, size: CGSize(width: 10.0, height: 10.0))
let progressRect = CGRect(origin: .zero, size: CGSize(width: rect.width * progress, height: height))
progressLayer.frame = progressRect
progressLayer.backgroundColor = color.cgColor
dotLayer.frame = dotOnProgressLayer
dotLayer.cornerRadius = dotLayer.bounds.width * 0.5
dotLayer.backgroundColor = #colorLiteral(red: 0.9254902005, green: 0.2352941185, blue: 0.1019607857, alpha: 1).cgColor
dotLayer.position = CGPoint(x: progressLayer.frame.width ,y: progressLayer.frame.height / 2)
}
}
progressView.set(height: 4.5)
progressView.setColor(progressLayer: UIColor(ciColor: .green))
You are masking the views layer with
layer.mask = backgroundMask
So anything outside the mask will no be drawn.
I'm having quite a bit of difficulty simply ensuring a circular progress bar is always in the centre of the UIView it is associated to.
This is what is happening:
Ignore the grey region, this is simply the UIView on a placeholder card. The red is the UIView I have added as an outlet to the UIViewController.
Below is the code for the class that I have made:
class CircleProgress: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
var progressLyr = CAShapeLayer()
var trackLyr = CAShapeLayer()
var progressClr = UIColor.red {
didSet {
progressLyr.strokeColor = progressClr.cgColor
}
}
var trackClr = UIColor.black {
didSet {
trackLyr.strokeColor = trackClr.cgColor
}
}
private func setupView() {
self.backgroundColor = UIColor.red
let centre = CGPoint(x: frame.size.width/2, y: frame.size.height/2)
let circlePath = UIBezierPath(arcCenter: centre,
radius: 10,
startAngle: 0,
endAngle: 2 * CGFloat.pi,
clockwise: true)
trackLyr.path = circlePath.cgPath
trackLyr.fillColor = UIColor.clear.cgColor
trackLyr.strokeColor = trackClr.cgColor
trackLyr.lineWidth = 5.0
trackLyr.strokeEnd = 1.0
layer.addSublayer(trackLyr)
}
}
The aim is simply to have all 4 edges of the black circle touching the edges of the red square.
Any help is hugely appreciated. I'm thinking it must be too obvious but this has cost me too many hours tonight. :)
The problem is that there is no frame being passed when creating the object. No need for changing anything to your code. For sure you have to change the width and the height to whatever you want.
Here is an example...
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
// Add circleProgress
addCircleProgress()
}
private func addCircleProgress() {
let circleProgress = CircleProgress(frame: CGRect(x: self.view.center.x - 50, y: self.view.center.x - 50, width: 100, height: 100))
self.view.addSubview(circleProgress)
}
}
class CircleProgress: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
var progressLyr = CAShapeLayer()
var trackLyr = CAShapeLayer()
var progressClr = UIColor.red {
didSet {
progressLyr.strokeColor = progressClr.cgColor
}
}
var trackClr = UIColor.black {
didSet {
trackLyr.strokeColor = trackClr.cgColor
}
}
private func setupView() {
self.backgroundColor = UIColor.red
let centre = CGPoint(x: frame.size.width/2, y: frame.size.height/2)
let circlePath = UIBezierPath(arcCenter: centre,
radius: 50,
startAngle: 0,
endAngle: 2 * CGFloat.pi,
clockwise: true)
trackLyr.path = circlePath.cgPath
trackLyr.fillColor = UIColor.clear.cgColor
trackLyr.strokeColor = trackClr.cgColor
trackLyr.lineWidth = 5.0
trackLyr.strokeEnd = 1.0
layer.addSublayer(trackLyr)
}
}
A few suggestions:
I’d suggest making this CircularProgress take up the whole view. If you want this to be inset within the view, then, fine, add a property for that. In my example below, I created a property called inset to capture this value.
Make sure to update your path in layoutSubviews. Especially if you use constraints, you want this to respond to size changes. So add these layers from init, but update the path in layoutSubviews.
Don’t reference frame (which is the location within the superview coordinate system). Use bounds. And inset it by half the line width, so the circle doesn’t exceed the bounds of the view.
You created a progress color and progress layer, but didn’t use either one. I’ve guessed you wanted that to show the progress within the track.
You are stroking your path from 0 to 2π. People tend to expect these circular progress views to start from -π/2 (12 o’clock) and progress to 3π/2. So I’ve updated the path to use those values. But use whatever you want.
If you want, you can make it #IBDesignable if you want to see this rendered in IB.
Thus, pulling that together, you get:
#IBDesignable
public class CircleProgress: UIView {
#IBInspectable
public var lineWidth: CGFloat = 5 { didSet { updatePath() } }
#IBInspectable
public var strokeEnd: CGFloat = 1 { didSet { progressLayer.strokeEnd = strokeEnd } }
#IBInspectable
public var trackColor: UIColor = .black { didSet { trackLayer.strokeColor = trackColor.cgColor } }
#IBInspectable
public var progressColor: UIColor = .red { didSet { progressLayer.strokeColor = progressColor.cgColor } }
#IBInspectable
public var inset: CGFloat = 0 { didSet { updatePath() } }
private lazy var trackLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = trackColor.cgColor
layer.lineWidth = lineWidth
return layer
}()
private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = progressColor.cgColor
layer.lineWidth = lineWidth
return layer
}()
override init(frame: CGRect = .zero) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
public override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
}
private extension CircleProgress {
func setupView() {
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
}
func updatePath() {
let rect = bounds.insetBy(dx: lineWidth / 2 + inset, dy: lineWidth / 2 + inset)
let centre = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
let path = UIBezierPath(arcCenter: centre,
radius: radius,
startAngle: -.pi / 2,
endAngle: 3 * .pi / 2,
clockwise: true)
trackLayer.path = path.cgPath
trackLayer.lineWidth = lineWidth
progressLayer.path = path.cgPath
progressLayer.lineWidth = lineWidth
}
}
That yields:
Update this function
private func setupView() {
self.backgroundColor = UIColor.red
let centre = CGPoint(x: bounds.midX, y: bounds.midY)
let circlePath = UIBezierPath(arcCenter: centre,
radius: bounds.maxX/2,
startAngle: 0,
endAngle: 2 * CGFloat.pi,
clockwise: true)
trackLyr.path = circlePath.cgPath
trackLyr.fillColor = UIColor.clear.cgColor
trackLyr.strokeColor = trackClr.cgColor
trackLyr.lineWidth = 5.0
trackLyr.strokeEnd = 1.0
layer.addSublayer(trackLyr)
}
I am trying to make an animated donut view that when given a value between 0 and 100 it will animate round the view up to that number. I have this working fine but want to fade the color from one to another, then another on the way around. Currently, when I add my gradient it goes from left to right and not around the circumference of the donut view.
class CircleScoreView: UIView {
private let outerCircleLayer = CAShapeLayer()
private let outerCircleGradientLayer = CAGradientLayer()
private let outerCircleLineWidth: CGFloat = 5
override init(frame: CGRect) {
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
buildLayers()
}
/// Value must be within 0...100 range
func setScore(_ value: Int, animated: Bool = false) {
if value != 0 {
let clampedValue: CGFloat = CGFloat(value.clamped(to: 0...100)) / 100
if !animated {
outerCircleLayer.strokeEnd = clampedValue
} else {
let outerCircleAnimation = CABasicAnimation(keyPath: "strokeEnd")
outerCircleAnimation.duration = 1.0
outerCircleAnimation.fromValue = 0
outerCircleAnimation.toValue = clampedValue
outerCircleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
outerCircleLayer.strokeEnd = clampedValue
outerCircleLayer.add(outerCircleAnimation, forKey: "outerCircleAnimation")
}
outerCircleGradientLayer.colors = [Constant.Palette.CircleScoreView.startValue.cgColor,
Constant.Palette.CircleScoreView.middleValue.cgColor,
Constant.Palette.CircleScoreView.endValue.cgColor]
}
}
private func buildLayers() {
// Outer background circle
let arcCenter = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
let startAngle = CGFloat(-0.5 * Double.pi)
let endAngle = CGFloat(1.5 * Double.pi)
let circlePath = UIBezierPath(arcCenter: arcCenter,
radius: (frame.size.width - outerCircleLineWidth) / 2,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
// Outer circle
setupOuterCircle(outerCirclePath: circlePath)
}
private func setupOuterCircle(outerCirclePath: UIBezierPath) {
outerCircleLayer.path = outerCirclePath.cgPath
outerCircleLayer.fillColor = UIColor.clear.cgColor
outerCircleLayer.strokeColor = UIColor.black.cgColor
outerCircleLayer.lineWidth = outerCircleLineWidth
outerCircleLayer.lineCap = CAShapeLayerLineCap.round
outerCircleGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
outerCircleGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
outerCircleGradientLayer.frame = bounds
outerCircleGradientLayer.mask = outerCircleLayer
layer.addSublayer(outerCircleGradientLayer)
}
}
I am going for something like this but the color isn't one block but gradients around the donut view from one color to the next.
If you imported AngleGradientLayer into your project then all you should need to do is change:
private let outerCircleGradientLayer = CAGradientLayer() to
private let outerCircleGradientLayer = AngleGradientLayer()
I am trying to have an animation in a view controller in which the circle rotates with animation. The circle should rotate until a process completed like a gif below. I have implemented the circle animation but couldn't reach to the point what I want to achieve.
import UIKit
class ViewController: UIViewController {
var circle : Circle?;
override func viewDidLoad() {
super.viewDidLoad();
view.backgroundColor = UIColor.white;
setupViews();
}
func setupViews(){
circle = Circle(frame: self.view.frame);
view.addSubview(circle!);
circle?.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true;
circle?.topAnchor.constraint(equalTo: view.topAnchor).isActive = true;
circle?.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true;
circle?.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true;
}
}
class Circle : UIView{
override init(frame: CGRect) {
super.init(frame: frame);
self.backgroundColor = .blue;
self.translatesAutoresizingMaskIntoConstraints = false;
setupCircle();
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
let gradientLayer = CAGradientLayer();
func setupCircle(){
layer.addSublayer(shapeLayer);
let circlePath = UIBezierPath(arcCenter: CGPoint(x: self.frame.width / 2 - 50, y: self.frame.height / 2 - 50), radius: 50, startAngle: CGFloat(Double.pi * (0 / 4)), endAngle: CGFloat(Double.pi * 2), clockwise: true);
shapeLayer.path = circlePath.cgPath;
let group = CAAnimationGroup()
group.animations = [animateStrokeEnd, animateOpacity]
group.duration = 0.8
group.repeatCount = HUGE // repeat forver
shapeLayer.add(group, forKey: nil)
}
let shapeLayer: CAShapeLayer = {
let layer = CAShapeLayer();
layer.strokeColor = UIColor.white.cgColor;
layer.lineWidth = 5;
layer.fillColor = UIColor.clear.cgColor;
layer.strokeStart = 0
layer.strokeEnd = 1;
return layer;
}();
let animateOpacity : CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "opacity");
animation.fromValue = 0;
animation.toValue = 0.8;
animation.byValue = 0.01;
animation.repeatCount = Float.infinity;
return animation
}();
let animateStrokeEnd: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "strokeEnd");
animation.fromValue = 0;
animation.repeatCount = Float.infinity;
animation.toValue = 1;
return animation;
}();
}
I am using strokeEnd animation to implement the animation. And opacity to animate the color. But when the circle reaches 360 degree, its makes a lag before starting a new circle.
Does anybody know how to remove this effect and get smooth animation?
The above code produces this animation
But i want to achieve this animation
Also the stroke colour is different from the original animation. Can we achieve this animation using the CABasicAnimation?
Rather than trying to animate the actual drawing, just draw the view once and then animate it.
Here is a custom PadlockView and a custom CircleView which mimic the animation you showed. To use it, add the code below to your project. Add a UIView to your Storyboard, change its class to PadlockView, and make an #IBOutlet to it (called padlock perhaps). When you want the view to animate, set padlock.circle.isAnimating = true. To stop animating, set padlock.circle.isAnimating = false.
CircleView.swift
// This UIView extension was borrowed from #keval's answer:
// https://stackoverflow.com/a/41160100/1630618
extension UIView {
func rotate360Degrees(duration: CFTimeInterval = 3) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat.pi * 2
rotateAnimation.isRemovedOnCompletion = false
rotateAnimation.duration = duration
rotateAnimation.repeatCount = Float.infinity
self.layer.add(rotateAnimation, forKey: nil)
}
}
class CircleView: UIView {
var foregroundColor = UIColor.white
var lineWidth: CGFloat = 3.0
var isAnimating = false {
didSet {
if isAnimating {
self.isHidden = false
self.rotate360Degrees(duration: 1.0)
} else {
self.isHidden = true
self.layer.removeAllAnimations()
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
func setup() {
self.isHidden = true
self.backgroundColor = .clear
}
override func draw(_ rect: CGRect) {
let width = bounds.width
let height = bounds.height
let radius = (min(width, height) - lineWidth) / 2.0
var currentPoint = CGPoint(x: width / 2.0 + radius, y: height / 2.0)
var priorAngle = CGFloat(360)
for angle in stride(from: CGFloat(360), through: 0, by: -2) {
let path = UIBezierPath()
path.lineWidth = lineWidth
path.move(to: currentPoint)
currentPoint = CGPoint(x: width / 2.0 + cos(angle * .pi / 180.0) * radius, y: height / 2.0 + sin(angle * .pi / 180.0) * radius)
path.addArc(withCenter: CGPoint(x: width / 2.0, y: height / 2.0), radius: radius, startAngle: priorAngle * .pi / 180.0 , endAngle: angle * .pi / 180.0, clockwise: false)
priorAngle = angle
foregroundColor.withAlphaComponent(angle/360.0).setStroke()
path.stroke()
}
}
}
PadlockView.swift
class PadlockView: UIView {
var circle: CircleView!
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
func setup() {
self.backgroundColor = .clear
circle = CircleView()
circle.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(circle)
circle.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
circle.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
circle.widthAnchor.constraint(equalTo: self.widthAnchor).isActive = true
circle.heightAnchor.constraint(equalTo: self.heightAnchor).isActive = true
}
override func draw(_ rect: CGRect) {
let width = bounds.width
let height = bounds.height
let lockwidth = width / 3
let lockheight = height / 4
let boltwidth = lockwidth * 2 / 3
UIColor.white.setStroke()
let path = UIBezierPath()
path.move(to: CGPoint(x: (width - lockwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width + lockwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width + lockwidth) / 2, y: height / 2 + lockheight))
path.addLine(to: CGPoint(x: (width - lockwidth) / 2, y: height / 2 + lockheight))
path.close()
path.move(to: CGPoint(x: (width - boltwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width - boltwidth) / 2, y: height / 2 - boltwidth / 4))
path.addArc(withCenter: CGPoint(x: width/2, y: height / 2 - boltwidth / 4), radius: boltwidth / 2, startAngle: .pi, endAngle: 0, clockwise: true)
path.lineWidth = 2.0
path.stroke()
}
}
Note: Continuous animation code courtesy of this answer.
Here is a demo that I setup with the following code in my ViewController:
#IBOutlet weak var padlock: PadlockView!
#IBAction func startStop(_ sender: UIButton) {
if sender.currentTitle == "Start" {
sender.setTitle("Stop", for: .normal)
padlock.circle.isAnimating = true
} else {
sender.setTitle("Start", for: .normal)
padlock.circle.isAnimating = false
}
}
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()
}
}