iOS комплексная анимация координации, как Android Animator(набор)
Я сделал довольно сложную анимацию в своем приложении для Android, используя классы Animator. Я хочу портировать эту анимацию на iOS. Предпочтительно это чем-то похоже на Android Animator. Я огляделся вокруг и, кажется, ничего не то, что я хочу. Самый близкий, который я получил, был с CAAnimation. Но, к сожалению, все дочерние делегаты игнорируются, если их помещают в группу.
Позвольте мне начать с анимации, которую я сделал на Android. Я анимирую три группы представлений (которые содержат ImageView и TextView). Для каждой кнопки у меня есть анимация, которая переводит вид влево и одновременно анимирует альфа в 0. После этой анимации есть другая анимация, которая переводит тот же самый вид справа в исходное положение, а также анимирует альфа обратно в 1. Есть один вид, который также имеет анимацию масштаба, кроме анимации перевода и альфа. Все виды используют разные функции синхронизации (ослабление). Анимация и анимация различны, и один вид имеет другую функцию синхронизации для масштаба, в то время как анимация альфа и трансляции использует то же самое. После окончания первой анимации я устанавливаю значения для подготовки второй анимации. Продолжительность анимации масштаба также короче, чем анимация перевода и альфа. Я помещаю отдельные анимации (перевод и альфа) в AnimatorSet (в основном это группа анимаций). Этот AnimatorSet помещается в другой AnimatorSet для запуска анимации после каждого другого (сначала анимация, а затем -). И этот AnimatorSet помещен в другой AnimatorSet, который запускает анимацию всех 3 кнопок одновременно.
Извините за длинное объяснение. Но так вы понимаете, как я пытаюсь перенести это на iOS. Это слишком сложно для UIView.animate(). CAAnimation переопределяет делегатов, если помещается в CAAnimationGroup. Насколько мне известно, ViewPropertyAnimator не позволяет использовать пользовательские функции синхронизации и не может координировать несколько анимаций.
У кого-нибудь есть идея, что я мог бы использовать для этого? Я также в порядке с пользовательской реализацией, которая дает мне обратный вызов каждый тик анимации, чтобы я мог соответствующим образом обновить представление.
редактировать
Код анимации Android:
fun setState(newState: State) {
if(state == newState) {
return
}
processing = false
val prevState = state
state = newState
val reversed = newState.ordinal < prevState.ordinal
val animators = ArrayList<Animator>()
animators.add(getMiddleButtonAnimator(reversed, halfAnimationDone = {
displayMiddleButtonState()
}))
if(prevState == State.TAKE_PICTURE || newState == State.TAKE_PICTURE) {
animators.add(getButtonAnimator(leftButton, leftButton, leftButton.imageView.width.toFloat(), reversed, halfAnimationDone = {
displayLeftButtonState()
}))
}
if(prevState == State.TAKE_PICTURE || newState == State.TAKE_PICTURE) {
animators.add(getButtonAnimator(
if(newState == State.TAKE_PICTURE) rightButton else null,
if(newState == State.CROP_PICTURE) rightButton else null,
rightButton.imageView.width.toFloat(),
reversed,
halfAnimationDone = {
displayRightButtonState(inAnimation = true)
}))
}
val animatorSet = AnimatorSet()
animatorSet.playTogether(animators)
animatorSet.start()
}
fun getButtonAnimator(animateInView: View?, animateOutView: View?, maxTranslationXValue: Float, reversed: Boolean, halfAnimationDone: () -> Unit): Animator {
val animators = ArrayList<Animator>()
if(animateInView != null) {
val animateInAnimator = getSingleButtonAnimator(animateInView, maxTranslationXValue, true, reversed)
if(animateOutView == null) {
animateInAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
halfAnimationDone()
}
})
}
animators.add(animateInAnimator)
}
if(animateOutView != null) {
val animateOutAnimator = getSingleButtonAnimator(animateOutView, maxTranslationXValue, false, reversed)
animateOutAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
halfAnimationDone()
}
})
animators.add(animateOutAnimator)
}
val animatorSet = AnimatorSet()
animatorSet.playTogether(animators)
return animatorSet
}
private fun getSingleButtonAnimator(animateView: View, maxTranslationXValue: Float, animateIn: Boolean, reversed: Boolean): Animator {
val translateDuration = 140L
val fadeDuration = translateDuration
val translateValues =
if(animateIn) {
if(reversed) floatArrayOf(-maxTranslationXValue, 0f)
else floatArrayOf(maxTranslationXValue, 0f)
} else {
if(reversed) floatArrayOf(0f, maxTranslationXValue)
else floatArrayOf(0f, -maxTranslationXValue)
}
val alphaValues =
if(animateIn) {
floatArrayOf(0f, 1f)
} else {
floatArrayOf(1f, 0f)
}
val translateAnimator = ObjectAnimator.ofFloat(animateView, "translationX", *translateValues)
val fadeAnimator = ObjectAnimator.ofFloat(animateView, "alpha", *alphaValues)
translateAnimator.duration = translateDuration
fadeAnimator.duration = fadeDuration
if(animateIn) {
translateAnimator.interpolator = EasingInterpolator(Ease.CUBIC_OUT)
fadeAnimator.interpolator = EasingInterpolator(Ease.CUBIC_OUT)
} else {
translateAnimator.interpolator = EasingInterpolator(Ease.CUBIC_IN)
fadeAnimator.interpolator = EasingInterpolator(Ease.CUBIC_IN)
}
val animateSet = AnimatorSet()
if(animateIn) {
animateSet.startDelay = translateDuration
}
animateSet.playTogether(translateAnimator, fadeAnimator)
return animateSet
}
fun getMiddleButtonAnimator(reversed: Boolean, halfAnimationDone: () -> Unit): Animator {
val animateInAnimator = getMiddleButtonSingleAnimator(true, reversed)
val animateOutAnimator = getMiddleButtonSingleAnimator(false, reversed)
animateOutAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
halfAnimationDone()
}
})
val animatorSet = AnimatorSet()
animatorSet.playTogether(animateInAnimator, animateOutAnimator)
return animatorSet
}
private fun getMiddleButtonSingleAnimator(animateIn: Boolean, reversed: Boolean): Animator {
val translateDuration = 140L
val scaleDuration = 100L
val fadeDuration = translateDuration
val maxTranslationXValue = middleButtonImageView.width.toFloat()
val translateValues =
if(animateIn) {
if(reversed) floatArrayOf(-maxTranslationXValue, 0f)
else floatArrayOf(maxTranslationXValue, 0f)
} else {
if(reversed) floatArrayOf(0f, maxTranslationXValue)
else floatArrayOf(0f, -maxTranslationXValue)
}
val scaleValues =
if(animateIn) floatArrayOf(0.8f, 1f)
else floatArrayOf(1f, 0.8f)
val alphaValues =
if(animateIn) {
floatArrayOf(0f, 1f)
} else {
floatArrayOf(1f, 0f)
}
val translateAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "translationX", *translateValues)
val scaleXAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "scaleX", *scaleValues)
val scaleYAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "scaleY", *scaleValues)
val fadeAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "alpha", *alphaValues)
translateAnimator.duration = translateDuration
scaleXAnimator.duration = scaleDuration
scaleYAnimator.duration = scaleDuration
fadeAnimator.duration = fadeDuration
if(animateIn) {
translateAnimator.interpolator = EasingInterpolator(Ease.QUINT_OUT)
scaleXAnimator.interpolator = EasingInterpolator(Ease.CIRC_OUT)
scaleYAnimator.interpolator = EasingInterpolator(Ease.CIRC_OUT)
fadeAnimator.interpolator = EasingInterpolator(Ease.QUINT_OUT)
} else {
translateAnimator.interpolator = EasingInterpolator(Ease.QUINT_IN)
scaleXAnimator.interpolator = EasingInterpolator(Ease.CIRC_IN)
scaleYAnimator.interpolator = EasingInterpolator(Ease.CIRC_IN)
fadeAnimator.interpolator = EasingInterpolator(Ease.QUINT_IN)
}
if(animateIn) {
val scaleStartDelay = translateDuration - scaleDuration
val scaleStartValue = scaleValues[0]
middleButtonImageView.scaleX = scaleStartValue
middleButtonImageView.scaleY = scaleStartValue
scaleXAnimator.startDelay = scaleStartDelay
scaleYAnimator.startDelay = scaleStartDelay
}
val animateSet = AnimatorSet()
if(animateIn) {
animateSet.startDelay = translateDuration
}
animateSet.playTogether(translateAnimator, scaleXAnimator, scaleYAnimator)
return animateSet
}
Редактировать 2
Вот видео о том, как выглядит анимация на Android:
2 ответа
Так что я работал над своим собственным решением, используя CADisplayLink
, Так описывается в документации CADisplayLink
:
CADisplayLink - это объект таймера, который позволяет вашему приложению синхронизировать чертеж с частотой обновления экрана.
Он в основном обеспечивает обратный вызов при выполнении кода рисования (чтобы вы могли плавно запускать анимацию).
Я не собираюсь все объяснять во время этого ответа, потому что в нем будет много кода, и большая его часть должна быть понятной. Если что-то неясно или у вас есть вопрос, вы можете прокомментировать ниже этот ответ.
Это решение дает полную свободу анимации и дает возможность координировать их. Я много смотрел на Animator
класс на Android и хотел подобный синтаксис, чтобы мы могли легко перенести анимацию с Android на iOS или наоборот. Я проверил это в течение нескольких дней и также удалил некоторые причуды. Но хватит говорить, давайте посмотрим код!
Это Animator
класс, который является базовой структурой для классов анимации:
class Animator {
internal var displayLink: CADisplayLink? = nil
internal var startTime: Double = 0.0
var hasStarted: Bool = false
var hasStartedAnimating: Bool = false
var hasFinished: Bool = false
var isManaged: Bool = false
var isCancelled: Bool = false
var onAnimationStart: () -> Void = {}
var onAnimationEnd: () -> Void = {}
var onAnimationUpdate: () -> Void = {}
var onAnimationCancelled: () -> Void = {}
public func start() {
hasStarted = true
startTime = CACurrentMediaTime()
if(!isManaged) {
startDisplayLink()
}
}
internal func startDisplayLink() {
stopDisplayLink() // make sure to stop a previous running display link
displayLink = CADisplayLink(target: self, selector: #selector(animationTick))
displayLink?.add(to: .main, forMode: .commonModes)
}
internal func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc internal func animationTick() {
}
public func cancel() {
isCancelled = true
onAnimationCancelled()
if(!isManaged) {
animationTick()
}
}
}
Он содержит все жизненно важные органы, такие как запуск CADisplayLink
, предоставляя возможность остановиться CADisplayLink
(когда анимация завершена), логические значения, которые указывают состояние и некоторые обратные вызовы. Вы также заметите логическое значение isManaged. Это логическое, когда Animator
контролируется группой. Если это так, группа будет предоставлять тики анимации, и этот класс не должен запускать CADisplayLink
,
Далее идет ValueAnimator
:
class ValueAnimator : Animator {
public internal(set) var progress: Double = 0.0
public internal(set) var interpolatedProgress: Double = 0.0
var duration: Double = 0.3
var delay: Double = 0
var interpolator: Interpolator = EasingInterpolator(ease: .LINEAR)
override func animationTick() {
// In case this gets called after we finished
if(hasFinished) {
return
}
let elapsed: Double = (isCancelled) ? self.duration : CACurrentMediaTime() - startTime - delay
if(elapsed < 0) {
return
}
if(!hasStartedAnimating) {
hasStartedAnimating = true
onAnimationStart()
}
if(duration <= 0) {
progress = 1.0
} else {
progress = min(elapsed / duration, 1.0)
}
interpolatedProgress = interpolator.interpolate(elapsedTimeRate: progress)
updateAnimationValues()
onAnimationUpdate()
if(elapsed >= duration) {
endAnimation()
}
}
private func endAnimation() {
hasFinished = true
if(!isManaged) {
stopDisplayLink()
}
onAnimationEnd()
}
internal func updateAnimationValues() {
}
}
Этот класс является базовым классом для всех аниматоров значений. Но он также может быть использован для анимации, если вы хотите сделать вычисления самостоятельно. Вы, вероятно, заметили Interpolator
а также interpolatedProgress
Вот. Interpolator
класс будет показан чуть позже. Этот класс обеспечивает ослабление анимации. Это где interpolatedProgress
приходит в. progress
это просто линейный прогресс от 0,0 до 1,0, но interpolatedProgress
может иметь другое значение для смягчения. Например, когда progress
имеет значение 0,2, interpolatedProgress
может уже иметь 0,4 в зависимости от того, какое облегчение вы будете использовать. Также обязательно используйте interpolatedProgress
рассчитать правильное значение. Пример и первый подкласс ValueAnimator
ниже.
Ниже CGFloatValueAnimator
который, как следует из названия, анимирует значения CGFloat:
class CGFloatValueAnimator : ValueAnimator {
private let startValue: CGFloat
private let endValue: CGFloat
public private(set) var animatedValue: CGFloat
init(startValue: CGFloat, endValue: CGFloat) {
self.startValue = startValue
self.endValue = endValue
self.animatedValue = startValue
}
override func updateAnimationValues() {
animatedValue = startValue + CGFloat(Double(endValue - startValue) * interpolatedProgress)
}
}
Это пример того, как подкласс ValueAnimator
и вы можете сделать намного больше, как это, если вам нужны другие, например, двойные или целые числа. Вы просто указываете начальное и конечное значение и Animator
рассчитывает на основе interpolatedProgress
какой ток animatedValue
является. Вы можете использовать это animatedValue
обновить ваше мнение. Я покажу пример в конце.
Потому что я упомянул Interpolator
пару раз, мы продолжим Interpolator
сейчас:
protocol Interpolator {
func interpolate(elapsedTimeRate: Double) -> Double
}
Это просто протокол, который вы можете реализовать самостоятельно. Я покажу вам часть EasingInterpolator
класс я использую сам. Я могу предоставить больше, если кому-то это нужно.
class EasingInterpolator : Interpolator {
private let ease: Ease
init(ease: Ease) {
self.ease = ease
}
func interpolate(elapsedTimeRate: Double) -> Double {
switch (ease) {
case Ease.LINEAR:
return elapsedTimeRate
case Ease.SINE_IN:
return (1.0 - cos(elapsedTimeRate * Double.pi / 2.0))
case Ease.SINE_OUT:
return sin(elapsedTimeRate * Double.pi / 2.0)
case Ease.SINE_IN_OUT:
return (-0.5 * (cos(Double.pi * elapsedTimeRate) - 1.0))
case Ease.CIRC_IN:
return -(sqrt(1.0 - elapsedTimeRate * elapsedTimeRate) - 1.0)
case Ease.CIRC_OUT:
let newElapsedTimeRate = elapsedTimeRate - 1
return sqrt(1.0 - newElapsedTimeRate * newElapsedTimeRate)
case Ease.CIRC_IN_OUT:
var newElapsedTimeRate = elapsedTimeRate * 2.0
if (newElapsedTimeRate < 1.0) {
return (-0.5 * (sqrt(1.0 - newElapsedTimeRate * newElapsedTimeRate) - 1.0))
}
newElapsedTimeRate -= 2.0
return (0.5 * (sqrt(1 - newElapsedTimeRate * newElapsedTimeRate) + 1.0))
default:
return elapsedTimeRate
}
}
}
Это всего лишь несколько примеров расчетов для конкретных послаблений. Я фактически перенес все упрощения, сделанные для Android, расположенные здесь: https://github.com/MasayukiSuda/EasingInterpolator.
Прежде чем я покажу пример, у меня есть еще один класс, чтобы показать. Какой класс позволяет группировать аниматоров:
class AnimatorSet : Animator {
private var animators: [Animator] = []
var delay: Double = 0
var playSequential: Bool = false
override func start() {
super.start()
}
override func animationTick() {
// In case this gets called after we finished
if(hasFinished) {
return
}
let elapsed = CACurrentMediaTime() - startTime - delay
if(elapsed < 0 && !isCancelled) {
return
}
if(!hasStartedAnimating) {
hasStartedAnimating = true
onAnimationStart()
}
var finishedNumber = 0
for animator in animators {
if(!animator.hasStarted) {
animator.start()
}
animator.animationTick()
if(animator.hasFinished) {
finishedNumber += 1
} else {
if(playSequential) {
break
}
}
}
if(finishedNumber >= animators.count) {
endAnimation()
}
}
private func endAnimation() {
hasFinished = true
if(!isManaged) {
stopDisplayLink()
}
onAnimationEnd()
}
public func addAnimator(_ animator: Animator) {
animator.isManaged = true
animators.append(animator)
}
public func addAnimators(_ animators: [Animator]) {
for animator in animators {
animator.isManaged = true
self.animators.append(animator)
}
}
override func cancel() {
for animator in animators {
animator.cancel()
}
super.cancel()
}
}
Как видите, здесь я установил isManaged
логическое значение. Вы можете поместить несколько аниматоров, которые вы делаете внутри этого класса, чтобы координировать их. И потому что этот класс также расширяется Animator
Вы также можете положить в другой AnimatorSet
или несколько. По умолчанию он запускает все анимации одновременно, но если playSequential
установлен в true, он будет запускать все анимации по порядку.
Время для демонстрации:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let animView = UIView()
animView.backgroundColor = UIColor.yellow
self.view.addSubview(animView)
animView.snp.makeConstraints { maker in
maker.width.height.equalTo(100)
maker.center.equalTo(self.view)
}
let translateAnimator = CGFloatValueAnimator(startValue: 0, endValue: 100)
translateAnimator.delay = 1.0
translateAnimator.duration = 1.0
translateAnimator.interpolator = EasingInterpolator(ease: .CIRC_IN_OUT)
translateAnimator.onAnimationStart = {
animView.backgroundColor = UIColor.blue
}
translateAnimator.onAnimationEnd = {
animView.backgroundColor = UIColor.green
}
translateAnimator.onAnimationUpdate = {
animView.transform.tx = translateAnimator.animatedValue
}
let alphaAnimator = CGFloatValueAnimator(startValue: animView.alpha, endValue: 0)
alphaAnimator.delay = 1.0
alphaAnimator.duration = 1.0
alphaAnimator.interpolator = EasingInterpolator(ease: .CIRC_IN_OUT)
alphaAnimator.onAnimationUpdate = {
animView.alpha = alphaAnimator.animatedValue
}
let animatorSet = AnimatorSet()
// animatorSet.playSequential = true // Uncomment this to play animations in order
animatorSet.addAnimator(translateAnimator)
animatorSet.addAnimator(alphaAnimator)
animatorSet.start()
}
}
Я думаю, что большая часть этого будет говорить сама за себя. Я создал вид, который переводит х и исчезает. Для каждой анимации вы реализуете onAnimationUpdate
обратный вызов для изменения значений, используемых в представлении, как в этом случае перевод х и альфа.
Примечание. В отличие от Android, продолжительность и задержка указываются в секундах, а не в миллисекундах.
Мы работаем с этим кодом прямо сейчас, и он отлично работает! Я уже написал некоторые анимационные материалы в нашем приложении для Android. Я мог бы легко перенести анимацию на iOS с минимальным переписыванием, и анимация работает точно так же! Я мог бы скопировать код, написанный в моем вопросе, изменил код Kotlin на Swift, применил onAnimationUpdate
, изменил продолжительность и задержки на секунды, и анимация работала как шарм.
Я хочу выпустить это как библиотеку с открытым исходным кодом, но я еще не сделал этого. Я обновлю этот ответ, когда отпущу его.
Если у вас есть какие-либо вопросы по поводу кода или как он работает, не стесняйтесь спрашивать.
Вот начало анимации, я думаю, вы ищете. Если вам не нравится время слайдов, вы можете отключить UIView.animate
с .curveEaseInOut
за CAKeyframeAnimation
где вы могли бы контролировать каждый кадр более детально. Вы хотели бы CAKeyFrameAnimation
для каждого вида вы анимируете.
Это игровая площадка, и вы можете скопировать и вставить ее на пустую игровую площадку, чтобы увидеть ее в действии.
import UIKit
import Foundation
import PlaygroundSupport
class ViewController: UIViewController {
let bottomBar = UIView()
let orangeButton = UIButton(frame: CGRect(x: 0, y: 10, width: 75, height: 75))
let yellow = UIView(frame: CGRect(x: 20, y: 20, width: 35, height: 35))
let magenta = UIView(frame: CGRect(x: 80, y: 30, width: 15, height: 15))
let cyan = UIView(frame: CGRect(x: 50, y: 20, width: 35, height: 35))
let brown = UIView(frame: CGRect(x: 150, y: 30, width:
15, height: 15))
let leftBox = UIView(frame: CGRect(x: 15, y: 10, width: 125, height: 75))
func setup() {
let reset = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
reset.backgroundColor = .white
reset.addTarget(self, action: #selector(resetAnimation), for: .touchUpInside)
self.view.addSubview(reset)
bottomBar.frame = CGRect(x: 0, y: self.view.frame.size.height - 100, width: self.view.frame.size.width, height: 100)
bottomBar.backgroundColor = .purple
self.view.addSubview(bottomBar)
orangeButton.backgroundColor = .orange
orangeButton.center.x = bottomBar.frame.size.width / 2
orangeButton.addTarget(self, action: #selector(orangeTapped(sender:)), for: .touchUpInside)
orangeButton.clipsToBounds = true
bottomBar.addSubview(orangeButton)
yellow.backgroundColor = .yellow
orangeButton.addSubview(yellow)
magenta.backgroundColor = .magenta
magenta.alpha = 0
orangeButton.addSubview(magenta)
// Left box is an invisible bounding box to get the effect that the view appeared from nowhere
// Clips to bounds so you cannot see the view when it has not been animated
// Try setting to false
leftBox.clipsToBounds = true
bottomBar.addSubview(leftBox)
cyan.backgroundColor = .cyan
leftBox.addSubview(cyan)
brown.backgroundColor = .brown
brown.alpha = 0
leftBox.addSubview(brown)
}
@objc func orangeTapped(sender: UIButton) {
// Perform animation
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: {
self.yellow.frame = CGRect(x: -20, y: 30, width: 15, height: 15)
self.yellow.alpha = 0
self.magenta.frame = CGRect(x: 20, y: 20, width: 35, height: 35)
self.magenta.alpha = 1
self.cyan.frame = CGRect(x: -150, y: 30, width: 15, height: 15)
self.cyan.alpha = 0
self.brown.frame = CGRect(x: 50, y: 20, width: 35, height: 35)
self.brown.alpha = 1
}, completion: nil)
}
@objc func resetAnimation() {
// Reset the animation back to the start
yellow.frame = CGRect(x: 20, y: 20, width: 35, height: 35)
yellow.alpha = 1
magenta.frame = CGRect(x: 80, y: 30, width: 15, height: 15)
magenta.alpha = 0
cyan.frame = CGRect(x: 50, y: 20, width: 35, height: 35)
cyan.alpha = 1
brown.frame = CGRect(x: 150, y: 30, width: 15, height: 15)
brown.alpha = 0
}
}
let viewController = ViewController()
viewController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 667)
viewController.view.backgroundColor = .blue
viewController.setup()
PlaygroundPage.current.liveView = viewController