Posted at
SwiftDay 13

Core Animation周りをタイプセーフに扱えるようにする


はじめに

UIKitでサポートされていないアニメーションをしたい場合、Core Animationを使うことになると思います。

例えば、以下のように四角から丸に変化するアニメーションです。

green.gif


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>型であるため、どんなものでも代入可能になっています。

スクリーンショット 2018-12-12 2.44.17.png

本来であれば、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型しか代入できない状態にできます。

スクリーンショット 2018-12-12 2.44.03.png

このように実装できるため、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のように型推論が利用できます。

SwiftyUserDefaultsDefaultsKeysも上記のような実装になっています。


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です。

RxSwiftSingleMaybeCompletablePrimitiveSequenceをtypealiasとした上記のような実装になっています。


ちなみに

TheAnimationはCAAnimationのサブクラスに以下のように対応しています。

CAAnimation
TheAnimation

CAPropertyAnimation
PropertyAnimation

CABasicAnimation
BasicAnimation

CAKeyframeAnimation
KeyframeAnimation

CASpringAnimation
SpringAnimation

CATransition
TransitionAnimation

CAAnimationGroup
AnimationGroup


最後に

明らかに間違っていても、実行して動作を確認するまで気づきにくいバグを、このようにしてコンパイル時に気づくことがきるようになります。

型を紐付けることでエラーに気づける実装は、いろんな場面で応用することができると思うので、是非試してみてください。

また、TheAnimationの実装がベースになっていて、よりアニメーションの組み合わせを使いやすくしたSicaも公開されています。