はじめに
UIKitでサポートされていないアニメーションをしたい場合、Core Animationを使うことになると思います。
例えば、以下のように四角から丸に変化するアニメーションです。
CABasicAnimationでアニメーションさせる
上図のような四角から丸に変化するアニメーションをCABasicAnimation
で実装する場合、意図したアニメーションを実現できる簡単な実装は以下のようになると思います。
let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.duration = 3.0
animation.fromValue = 0.0
animation.toValue = 50.0
animation.autoreverses = false
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
greenView.layer.add(animation, forKey: nil)
アニメーションを実現するためには上記の実装で問題ありません。
しかし、実装する上では2つの懸念があります。
keyPathやfromValueなどがタイプセーフじゃない
CABasicAnimationのinitializerの引数であるkeyPathに注目します。
引数はString型
であるため、以下のようにcornerRadiusの文字列をタイポする可能性があります。
let animation = CABasicAnimation(keyPath: "conerRadius")
そして、アニメーションを実行しようとしたときに、アニメーションされないことでタイポに気づくことが多々あるかと思います。
また、fromValueに注目します。
fromValueは下図のようにOptional<Any>型
であるため、どんなものでも代入可能になっています。
本来であれば、keyPathがcornerRadiusの場合は数値を代入するという関連性がなければならないはずです。
keyPathやfromValueなどがタイプセーフな実装とは
上記であげたタイプセーフではない実装を、TheAnimation(iOS、tvOS、macOSをサポート)というOSSを利用することで、タイプセーフに実装することができます。
CABasicAnimationと同等の機能をもった、BasicAnimationを利用します。
initializerのkeyPathには、AnimationKeyPaths.cornerRadius
を渡しています。
AnimationKeyPaths.cornerRadius
はstaticな定数として定義されているため、入力にミスがある場合はコンパイルエラーとなるので、タイポする可能性がなくなります。
let animation = BasicAnimation(keyPath: .cornerRadius)
また、AnimationKeyPaths.cornerRadiusはAnimationKeyPath<CGFloat>型
となっています。
AnimationKeyPathのGeneric ArgumentがCGFloat型
となっており、下図のようにfromValueなどに型が紐付いています。
そのため、cornerRadiusのアニメーションに対してCGFloat型しか代入できない状態にできます。
このように実装できるため、2つあげた懸念点を改善することができます。
それでは、TheAnimation内部の実装はどのようになっているのでしょうか。
TheAnimationの実装
それでは、TheAnimationがどのようにしてタイプセーフな実装を実現しているのかを順を追って解説します。
本投稿では、CABasicAnimationに関連する部分に絞って解説します。
AnimationKeyPath
AnimationKeyPathは、keyPathを保持しつつ型を紐付けることができるようになっています。
まず、protocol AnimationValueType
を定義していて、アニメーション時に利用する値の型に対して採用しています。
public protocol AnimationValueType {}
たとえば、CGColorやCGFloatに対して採用し、アニメーション時に利用できる状態にします。
extension CGColor: AnimationValueType {}
extension CGFloat: AnimationValueType {}
...
AnimationKeyPathはGeneric ArgumentにAnimationValueTypeを採用した型を持ち、内部でkeyPathを保持しています。
open class AnimationKeyPaths {
fileprivate init() {}
}
public final class AnimationKeyPath<ValueType: AnimationValueType>: AnimationKeyPaths {
let rawValue: String
public init(keyPath: String) {
self.rawValue = keyPath
}
}
AnimationKeyPathがGeneric Argumentを持つため、static定数をAnimationKeyPathに定義しようとすると、extension AnimationKeyPath where ValueType == CGFloat
のようにGeneric Argumentの型を確定させた状態にする必要があります。
そのため、AnimationKeyPath.cornerRadius
のような呼び出しができず、AnimationKeyPath<CGFloat>.cornerRadius
のような呼び出しをする必要があります。
スーパークラスをAnimationKeyPaths
とし以下のようにstatic定数を定義することで、AnimationKeyPaths.cornerRadius
という呼び出しができます。
extension AnimationKeyPaths {
public static let backgroundColor = AnimationKeyPath<CGColor>(keyPath: "backgroundColor")
public static let cornerRadius = AnimationKeyPath<CGFloat>(keyPath: "cornerRadius")
...
}
つまり、init<ValueType: AnimationValueType>(keyPath: AnimationKeyPath<ValueType>)
という実装になっていても、.cornerRadius
のように型推論が利用できます。
SwiftyUserDefaultsのDefaultsKeys
も上記のような実装になっています。
protocol Animation
protocol Animation
はTheAnimationのアニメーションクラスが採用しているprotocolであり、CAAnimationをラップしています。
public protocol Animation: class {
var animation: CAAnimation { get }
var key: String { get }
}
内部でCAAnimationを保持しているため、CAAnimationのpropertyをTheAnimationで公開している型に変換して公開しています。
extension Animation {
public var timingFunction: TimingFunction? {
set { animation.timingFunction = newValue?.rawValue }
get { return animation.timingFunction.flatMap(TimingFunction.init) }
}
public var isRemovedOnCompletion: Bool {
set { animation.isRemovedOnCompletion = newValue }
get { return animation.isRemovedOnCompletion }
}
...
}
このようにして、CAAnimationと同等の機能を実現しています。
PrimitiveAnimation
PrimitiveAnimationはprotocol Animation
を採用しています。
Generic Argumentでは、CAPropertyAnimationまたはそのサブクラスの型をRawAnimation、AnimationValueTypeを採用した型をValueTypeとします。
initializerでは、AnimationKeyPath<ValueType>
を引数としています。
RawAnimationはCAPropertyAnimationまたはそのサブクラスなのでinitializerでkeyPathを引数としています。
そして、AnimationKeyPath内で保持しているrawValueを渡してRawAnimationを初期化し、保持します。
public final class PrimitiveAnimation<RawAnimation: CAPropertyAnimation, ValueType: AnimationValueType>: Animation {
public var animation: CAAnimation {
return _animation
}
public let key: String
let _animation: RawAnimation
public var keyPath: AnimationKeyPath<ValueType>? {
set { _animation.keyPath = newValue?.rawValue }
get { return _animation.keyPath.map(AnimationKeyPath.init) }
}
public init(keyPath: AnimationKeyPath<ValueType>) {
self._animation = RawAnimation(keyPath: keyPath.rawValue)
self.key = keyPath.rawValue
}
}
PrimitiveAnimationのextensionでGeneric Where Clauseを利用することで、RawAnimationのpropertyを利用できます。
例として、CAPropertyAnimationのサブクラスであるCABasicAnimationの場合は以下のようになります。
extension PrimitiveAnimation where RawAnimation == CABasicAnimation {
public var fromValue: ValueType? {
set { _animation.fromValue = newValue }
get { return _animation.fromValue as? ValueType }
}
...
}
そして、上記で定義したものは以下のように利用できます。
let animation = PrimitiveAnimation<CABasicAnimation, AnimationKeyPath<CGFloat>>(keyPath: .cornerRadius)
animation.fromValue = 50
BasicAnimation
BasicAnimationは以下のように定義されています。
typealias BasicAnimation<ValueType: AnimationValueType> = PrimitiveAnimation<CABasicAnimation, ValueType>
つまり、BasicAnimationはクラス名ではなく、RawAnimationがCABasicAnimationであるPrimitiveAnimationのtypealiasです。
RxSwiftのSingle
、Maybe
やCompletable
もPrimitiveSequence
をtypealiasとした上記のような実装になっています。
ちなみに
TheAnimationはCAAnimationのサブクラスに以下のように対応しています。
CAAnimation | TheAnimation |
---|---|
CAPropertyAnimation | PropertyAnimation |
CABasicAnimation | BasicAnimation |
CAKeyframeAnimation | KeyframeAnimation |
CASpringAnimation | SpringAnimation |
CATransition | TransitionAnimation |
CAAnimationGroup | AnimationGroup |
最後に
明らかに間違っていても、実行して動作を確認するまで気づきにくいバグを、このようにしてコンパイル時に気づくことがきるようになります。
型を紐付けることでエラーに気づける実装は、いろんな場面で応用することができると思うので、是非試してみてください。
また、TheAnimationの実装がベースになっていて、よりアニメーションの組み合わせを使いやすくしたSicaも公開されています。