はじめに
モチベーション
SwiftUI でアニメーション可能なシェイプを作成する場合は animatableData
を実装する必要があります。
アニメーション可能なパラメータが1つの場合は次のような実装をします。
public struct HogeShape: Shape {
public var foo: CGFloat
public var animatableData: CGFloat {
get { foo }
set { foo = newValue }
}
public func path(in rect: CGRect) -> Path {
// foo をパラメータにした Path を作る
}
}
animatableData
の型は必ずしも CGFloat
である必要はなく、VectorArithmetic
に適合していれば OK です。次の型が既知で適合しています。
- CGFloat
- Double
- Float
- AnimatablePair
- EmptyAnimatableData
AnimatablePair
を利用することで、次のようにパラメータを2つ指定することができます。
public struct FugaShape: Shape {
public var foo: CGFloat
public var bar: CGFloat
public var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(foo, bar)
}
set {
foo = newValue.first
bar = newValue.second
}
}
public func path(in rect: CGRect) -> Path {
// foo, bar をパラメータにした Path を作る
}
}
AnimatablePair
の型パラメータには、任意の VectorArithmetic
を指定することが可能なので、型パラメータに AnimatablePair
を指定することができます。
これを利用してパラメータを3つにすることができます。
public struct PiyoShape: Shape {
public var foo: CGFloat
public var bar: CGFloat
public var baz: CGFloat
public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>> {
get {
.init(foo, .init(bar, baz))
}
set {
foo = newValue.first
bar = newValue.second.first
baz = newValue.second.second
}
}
public func path(in rect: CGRect) -> Path {
// foo, bar, baz をパラメータにした Path を作る
}
}
AnimatablePair
の型パラメータに AnimatablePair
を設定していくことで、いくらでもアニメーション可能なプロパティを追加することができますが、型パラメータがネストされていくので可読性が落ちていきます。パラメータを5つにすると次のようになります。
var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>>> {
get {
.init(prop0, .init(prop1, .init(prop2, .init(prop3, prop4))))
}
set {
prop0 = newValue.first
prop1 = newValue.second.first
prop2 = newValue.second.second.first
prop3 = newValue.second.second.second.first
prop4 = newValue.second.second.second.second
}
}
プロパティが5つあることが、現実の解決すべき問題として多いか少ないかについては何とも言えないところですが、単純なシェイプでも柔軟にアニメーションをつけようとすると、プロパティの数はそれなりに必要になってくると思います。
たとえば、角を丸くした長方形のシェイプで考えてみます。
角丸の半径に加えて、各四隅の角丸の有無を自由に制御できるようにすると、単純なシェイプでも5つのプロパティが必要になります。
これらが自由に制御できれば、ビューのマスク処理にアニメーションをつけることも容易になります。
本記事では、VectorArithmetic
に適合した AnimatableValues
を自作することで、上記のネストを次のようなシンプルな実装にします。
public var animatableData: AnimatableValues {
get {
.init(prop0, prop1, prop2, prop3, prop4)
}
set {
let values: [CGFloat] = newValue.values()
prop0 = values[0]
prop1 = values[1]
prop2 = values[2]
prop3 = values[3]
prop4 = values[4]
}
}
環境
- Xcode 11.6 (11E708)
- Swift 5.2.4
VectorArithmetic を実装する
アニメーション可能なプロパティをフラットに複数保持するために Array で値を管理します。
public struct AnimatableValues: VectorArithmetic {
private var values: [Double]
}
あとは、VectorArithmetic に適合するために必要な実装をします。
必要最低限の実装
AnimatableValues
を VectorArithmetic
に適合するためには、ベクトル空間の公理系を満たす必要があり、以下のように実装します。
zero
static var zero: Self { get }
ゼロ元を返す必要があります。ゼロ元はどのような AnimatableValues
と加算してもプロパティを変更しないように実装します。
+
演算子
static func + (lhs: Self, rhs: Self) -> Self
各要素ごとに加算したオブジェクトを返す必要があります。
-
演算子
static func - (lhs: Self, rhs: Self) -> Self
各要素ごとに減算したオブジェクトを返す必要があります。
scale(by:)
mutating func scale(by rhs: Double)
各要素ごとに rhs
倍したオブジェクトを返す必要があります。
magnitudeSquared
var magnitudeSquared: Double { get }
各要素の二乗した和を返します。
実装例
上記を満たすような実装例です。
固定長のリストであれば .zero
は [0, 0, ..., 0]
のような実装が望ましいところですが、Array で保持している都合で実現できないので、空のリスト []
で代替しておき、+
, -
演算を実行するタイミングでゼロ埋めしているのが実装のポイントです。これで公理系を満たすことができます。
import SwiftUI
import enum Accelerate.vDSP
public struct AnimatableValues: VectorArithmetic, Hashable {
private var values: [Double]
public init(values: [Double]) {
self.values = values
}
public init<F: BinaryFloatingPoint>(values: [F]) {
self.init(values: values.map(Double.init))
}
public init<F: BinaryFloatingPoint>(_ values: F...) {
self.init(values: values.map(Double.init))
}
public func values<F: BinaryFloatingPoint>(_ type: F.Type = F.self) -> [F] {
values.map(F.init)
}
}
// MARK: - VectorArithmetic
public extension AnimatableValues {
mutating func scale(by rhs: Double) {
values = vDSP.multiply(rhs, values)
}
var magnitudeSquared: Double {
vDSP.sum(vDSP.multiply(values, values))
}
}
// MARK: - AdditiveArithmetic
public extension AnimatableValues {
static var zero: Self {
.init(values: [])
}
static func + (lhs: Self, rhs: Self) -> Self {
.init(values: operate(vDSP.add, lhs, rhs))
}
static func - (lhs: Self, rhs: Self) -> Self {
.init(values: operate(vDSP.subtract, lhs, rhs))
}
private static func operate(_ operation: ([Double], [Double]) -> [Double], _ lhs: Self, _ rhs: Self) -> [Double] {
let count = max(lhs.values.count, rhs.values.count)
let lhs = lhs.values + Array(repeating: 0, count: count - lhs.values.count)
let rhs = rhs.values + Array(repeating: 0, count: count - rhs.values.count)
return operation(lhs, rhs)
}
}
この実装によって、animatableData
は次のように簡潔に記述できるようになります。
public var animatableData: AnimatableValues {
get {
.init(prop0, prop1, prop2, prop3, prop4)
}
set {
let values: [CGFloat] = newValue.values()
prop0 = values[0]
prop1 = values[1]
prop2 = values[2]
prop3 = values[3]
prop4 = values[4]
}
}