LoginSignup
7
2

More than 3 years have passed since last update.

【SwiftUI】VectorArithmetic を自作して AnimatablePair の型パラメータ地獄から解脱する

Last updated at Posted at 2020-07-26

はじめに

モチベーション

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 に適合するために必要な実装をします。

必要最低限の実装

AnimatableValuesVectorArithmetic に適合するためには、ベクトル空間の公理系を満たす必要があり、以下のように実装します。

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]
    }
}
7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2