Анимация дуги UIBezierPath между двумя углами

Я создал собственный круговой выбор диапазона, аналогичный тому, который используется в приложении «Будильник/перед сном» для iOS. Для этого я использую два UIButton больших пальца с прикрепленными UIPanGestureRecognizer и CAShapeLayer + UIBezierPath, указывающими временной диапазон между ними. Это работает фантастически, когда я использую сенсорный ввод как для движения по кругу, так и для изменения размера самой дуги.

Простите мое грубое изображение кругов, но вот суть того, как работает сборщик:

Круги

Проблема заключается в том, что мне нужно переключаться между определенными предустановками временного диапазона с помощью UISegmentedControl, и я действительно хотел бы анимировать это изменение. Мне удалось красиво анимировать оба больших пальца (зеленые) по кругу на все 360 градусов с помощью CAKeyframeAnimation(keyPath: "position"), но я не смог сделать это с моим временным диапазоном (красный). CAShapeLayer. Лучшее, что мне удалось, это анимировать слой диапазона с помощью CABasicAnimation(keyPath: "path"), но он исказился во время анимации и не следовал за дугой, а вместо этого проходил по кругу по прямому вектору.

Для краткости я не буду публиковать содержимое getStuff() функций, так как они не имеют отношения к проблеме.

// This works
let startAngle: CGFloat = getAngle(for: start)
let startPath: UIBezierPath = getThumbTravelPath(fromAngle: startThumbAngle, toAngle: startAngle)
let startAnimation = CAKeyframeAnimation(keyPath: "position")
startAnimation.path = startPath.cgPath
startAnimation.isRemovedOnCompletion = false
startAnimation.calculationMode = .cubicPaced
startAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
startAnimation.duration = 0.2
startThumbView.layer.add(startAnimation, forKey: nil)
    
let endAngle: CGFloat = getAngle(for: end)
let endPath: UIBezierPath = getThumbTravelPath(fromAngle: endThumbAngle, toAngle: endAngle)
let endAnimation = CAKeyframeAnimation(keyPath: "position")
endAnimation.path = endPath.cgPath
endAnimation.isRemovedOnCompletion = false
endAnimation.calculationMode = .cubicPaced
endAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
endAnimation.duration = 0.2
endThumbView.layer.add(endAnimation, forKey: nil)

// This doesn't work
let path: UIBezierPath = getRangePathFor(startAngle: startAngle, endAngle: endAngle)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = rangePath.cgPath 
pathAnimation.toValue = path.cgPath
pathAnimation.duration = 0.2
pathAnimation.isRemovedOnCompletion = false
pathAnimation.isAdditive = true
rangeLayer.path = path.cgPath
rangeLayer.add(pathAnimation, forKey: nil)

Я видел, как некоторые люди предлагали использовать подход CABasicAnimation(keyPath: "startStroke") + CABasicAnimation(keyPath: "endStroke"), но, насколько я могу судить, это не сработает, потому что я не могу иметь отрицательное значение для startStroke, поэтому я не могу иметь дугу, простирающуюся, скажем, от от 270° до 45°.


person KLD    schedule 15.09.2020    source источник
comment
Почему бы вам не анимировать от 270º до 405º?   -  person Leo Dabus    schedule 15.09.2020
comment
Проблема в том, что startStroke и endStroke варьируются только от 0,0 до 1,0, то есть я не могу перейти от 0,75 до 1,125.   -  person KLD    schedule 15.09.2020


Ответы (1)


Похоже, strokeStart и strokeEnd все-таки были ответом.


Решение

Мне удалось решить эту проблему, повернув сам rangeLayer таким образом, чтобы он всегда соответствовал углу начального большого пальца, поэтому мой strokeStart всегда равен нулю. В качестве следующего шага я просто вычитаю startAngle из endAngle (в градусах, а не в радианах) и использую его для вычисления значения endStroke.

    private func setRangeLayer(startRadian: CGFloat, endRadian: CGFloat, animated: Bool, duration: CFTimeInterval? = nil) {
    let endAngle = endRadian / .pi * 180
    let endStroke = endAngle / 360
    let rotation = CGAffineTransform(rotationAngle: startRadian + .pi / 2)
    
    CATransaction.begin()
    if !animated {
        CATransaction.disableActions()
        CATransaction.setAnimationDuration(0)
    } else {
        CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
        if let duration = duration {
            CATransaction.setAnimationDuration(duration)
        }
     }
    rangeLayer.strokeStart = 0
    rangeLayer.strokeEnd = endStroke
    rangeLayer.setAffineTransform(rotation)
    CATransaction.commit()
    }

Пример

Причина, по которой я конвертирую градусы в радианы, а затем обратно в градусы, заключается в том, чтобы стандартизировать функцию для различных вариантов использования в классе. Однако вы можете так же легко использовать радианы для rotation и градусы для endStroke.

    let startAngle: CGFloat = getAngle(for: startTimestamp)
    let endAngle: CGFloat = getAngle(for: endTimestamp)
    let startRadian: CGFloat = (startAngle * .pi / 180) - .pi / 2
    let endRadian: CGFloat = (endAngle - startAngle) * .pi / 180
    setRangeLayer(startRadian: startRadian, endRadian: endRadian, animated: true, duration: animationDuration)

Важная заметка

Чтобы правильно rotate CAShapeLayer вращаться вокруг своего центра, вы должны заранее установить его frame / bounds, иначе вместо этого он будет вращаться вокруг своей исходной точки, как винт самолета.


Надеюсь, это поможет кому-то в похожей ситуации.

person KLD    schedule 17.09.2020