Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift: Animate object along animating path

I would like to animate little red dot rotating around circle that is expanding in a pulse manner (go from small to big, then start back from small). It seems that little dot keeps rotating around original shape and does not take into account that circle it's expanding... I have this in code:

// MARK: - Properties

  private lazy var containerView = UIView()

  let littleCircleRadius: CGFloat = 10

  private lazy var littleRedDot: CALayer = {
    let layer = CALayer()
    layer.backgroundColor = UIColor.red.cgColor
    let littleDotSize = CGSize(width: 10, height: 10)
    layer.frame = CGRect(x: containerView.bounds.center.x - littleDotSize.width / 2,
                         y: containerView.bounds.center.y - littleCircleRadius - littleDotSize.width/2 ,
                         width: littleDotSize.width,
                         height: littleDotSize.height)
    return layer
  }()

 private lazy var littleCircleLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    layer.lineWidth = 1.5
    layer.lineCap = .round
    layer.strokeColor = UIColor.black.cgColor
    layer.fillColor = UIColor.clear.cgColor
    return layer
  }()

// MARK: - Setup
 func setup() {
    view.addSubview(containerView)
    containerView.frame = CGRect(x: 40, y: 200, width: 300, height: 300)
    containerView.backgroundColor = UIColor.gray.withAlphaComponent(0.2)

    littleCircleLayer.path = makeArcPath(arcCenter: containerView.bounds.center, radius: 10)
    containerView.layer.addSublayer(littleCircleLayer)
    containerView.layer.addSublayer(littleRedDot)
}

// MARK: - Animations
func animate() {
    CATransaction.begin()
    CATransaction.setAnimationDuration(1.5)
    animateLittleRedDotRotation()
    animateCircleExpanding()
    CATransaction.commit()
}

func animateLittleRedDotRotation() {
    let anim = CAKeyframeAnimation(keyPath: "position")
    anim.duration = 1.5
    anim.rotationMode = .rotateAuto
    anim.repeatCount = Float.infinity
    anim.path = littleCircleLayer.path
    littleRedDot.add(anim, forKey: "rotate")
}

func animateCircleExpanding() {
    let maxCircle = makeArcPath(arcCenter: containerView.bounds.center, radius: 100)
    let circleExpandingAnim = CABasicAnimation(keyPath: "path")
    circleExpandingAnim.fromValue = littleCircleLayer.path
    circleExpandingAnim.toValue = maxCircle
    circleExpandingAnim.repeatCount = Float.infinity
    circleExpandingAnim.duration = 1.5
    littleCircleLayer.add(circleExpandingAnim, forKey: "pulseCircuitAnimation")
}

This creates following effect:

enter image description here

However I would like to achieve for little dot to be rotating along the expanding circle path (as it animates from small circle to bigger circle), not the original small circle path. Any ideas ?

like image 849
Peter Ševčík Avatar asked Jun 09 '26 21:06

Peter Ševčík


1 Answers

Using CoreAnimation to animate the position of the red dot based upon the path assumes that the path isn't changing. You could, theoretically, define a spiral path that mirrors the expanding circle. Personally, I'd just use CADisplayLink, a special timer designed optimally for screen refreshes, and retire the CoreAnimation calls entirely. E.g.

func startDisplayLink() {
    let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
    displayLink.add(to: .main, forMode: .common)
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
    let percent = CGFloat(displayLink.timestamp).truncatingRemainder(dividingBy: duration) / duration
    let radius = ...
    let center = containerView.bounds.center
    circleLayer.path = makeArcPath(arcCenter: center, radius: radius)
    let angle = percent * .pi * 2
    let dotCenter = CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
    redDot.path = makeArcPath(arcCenter: dotCenter, radius: 5)
}

That yields:

enter image description here


The full example:

class ViewController: UIViewController {

    private let radiusRange: ClosedRange<CGFloat> = 10...100
    private let duration: CGFloat = 1.5

    private lazy var containerView: UIView = {
        let containerView = UIView()
        containerView.translatesAutoresizingMaskIntoConstraints = false
        return containerView
    }()

    private lazy var redDot: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.fillColor = UIColor.red.cgColor
        return layer
    }()

    private lazy var circleLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 1.5
        layer.strokeColor = UIColor.black.cgColor
        layer.fillColor = UIColor.clear.cgColor
        return layer
    }()

    private weak var displayLink: CADisplayLink?

    override func viewDidLoad() {
        super.viewDidLoad()

        setup()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        startDisplayLink()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        stopDisplayLink()
    }
}

// MARK: Private utility methods

private extension ViewController {
    func setup() {
        addContainer()

        containerView.layer.addSublayer(circleLayer)
        containerView.layer.addSublayer(redDot)
    }

    func addContainer() {
        view.addSubview(containerView)

        NSLayoutConstraint.activate([
            containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            containerView.topAnchor.constraint(equalTo: view.topAnchor),
            containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    func makeArcPath(arcCenter: CGPoint, radius: CGFloat) -> CGPath {
        UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true).cgPath
    }
}

// MARK: - DisplayLink related methods

private extension ViewController {
    func startDisplayLink() {
        stopDisplayLink()  // stop existing display link, if any

        let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
        displayLink.add(to: .main, forMode: .common)
        self.displayLink = displayLink
    }

    func stopDisplayLink() {
        displayLink?.invalidate()
    }

    @objc func handleDisplayLink(_ displayLink: CADisplayLink) {
        let percent = CGFloat(displayLink.timestamp).truncatingRemainder(dividingBy: duration) / duration
        let radius = radiusRange.percent(percent)
        let center = containerView.bounds.center
        circleLayer.path = makeArcPath(arcCenter: center, radius: radius)
        let angle = percent * .pi * 2
        let dotCenter = CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
        redDot.path = makeArcPath(arcCenter: dotCenter, radius: 5)
    }
}

// MARK: - CGRect extension

extension CGRect {
    var center: CGPoint { return CGPoint(x: midX, y: midY) }
}

// MARK: - ClosedRange extension

extension ClosedRange where Bound: FloatingPoint {
    func percent(_ percent: Bound) -> Bound {
        (upperBound - lowerBound) * percent + lowerBound
    }
}
like image 62
Rob Avatar answered Jun 11 '26 17:06

Rob



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!