SwiftUI GeometryEffect

GeometryEffect は、ビューの座標変換をアニメーション可能な形で定義するプロトコルだ。回転、拡大縮小、移動といった幾何学的な変換を、SwiftUI の補間システムを使って滑らかにアニメーションできる。

GeometryEffect の基本構造

GeometryEffect は AnimatableModifier に似ているが、ProjectionTransform を返す点が異なる。

struct SkewEffect: GeometryEffect {
    var skewX: CGFloat
    
    var animatableData: CGFloat {
        get { skewX }
        set { skewX = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let transform = CGAffineTransform(a: 1, b: 0, c: skewX, d: 1, tx: 0, ty: 0)
        return ProjectionTransform(transform)
    }
}

effectValue で返す ProjectionTransform により、ビューの描画位置・形状が変換される。

CGAffineTransform との関係

ProjectionTransform は CGAffineTransform または CATransform3D から作成できる。

// 2D 変換
ProjectionTransform(CGAffineTransform.identity)

// 3D 変換
ProjectionTransform(CATransform3DIdentity)

CGAffineTransform は 2D の行列変換(移動、回転、拡大縮小、せん断)を表現し、CATransform3D は 3D 変換(遠近法を含む)を表現する。

実践例:3D フリップエフェクト

struct FlipEffect: GeometryEffect {
    var angle: Double
    var axis: (x: CGFloat, y: CGFloat, z: CGFloat)
    
    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let radians = angle * .pi / 180
        
        var transform3D = CATransform3DIdentity
        transform3D.m34 = -1 / 500  // 遠近法
        transform3D = CATransform3DRotate(
            transform3D,
            radians,
            axis.x, axis.y, axis.z
        )
        
        // 中心を基準に回転
        let anchorX = size.width / 2
        let anchorY = size.height / 2
        
        let offset = CATransform3DMakeTranslation(anchorX, anchorY, 0)
        let offsetBack = CATransform3DMakeTranslation(-anchorX, -anchorY, 0)
        
        let combined = CATransform3DConcat(
            CATransform3DConcat(offsetBack, transform3D),
            offset
        )
        
        return ProjectionTransform(combined)
    }
}

extension View {
    func flip3D(angle: Double, axis: (CGFloat, CGFloat, CGFloat)) -> some View {
        modifier(FlipEffect(angle: angle, axis: axis))
    }
}
struct FlipDemo: View {
    @State private var flipped = false
    
    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue)
            .frame(width: 200, height: 150)
            .flip3D(
                angle: flipped ? 180 : 0,
                axis: (0, 1, 0)
            )
            .onTapGesture {
                withAnimation(.spring()) {
                    flipped.toggle()
                }
            }
    }
}

タップするとカードが Y 軸を中心に回転する 3D フリップアニメーションになる。

実践例:波打つエフェクト

struct WaveEffect: GeometryEffect {
    var progress: Double
    var amplitude: CGFloat
    var frequency: Double
    
    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let offset = sin(progress * .pi * 2 * frequency) * amplitude
        
        let transform = CGAffineTransform(translationX: 0, y: offset)
        return ProjectionTransform(transform)
    }
}

progress を 0 から 1 にアニメーションさせると、ビューが上下に波打つ。

ignoredByLayout

GeometryEffect はデフォルトでレイアウトに影響しない。つまり、変換後のビューが他のビューの配置を押しのけることはない。

HStack {
    Text("A")
    Text("B")
        .modifier(SomeGeometryEffect())  // 変換されても A と C の位置は変わらない
    Text("C")
}

これは ignoredByLayout() がデフォルトで適用されているためだ。レイアウトに影響させたい場合は、カスタム実装が必要になる。

rotation3DEffect との違い

標準の rotation3DEffect も 3D 回転を提供するが、GeometryEffect を使う利点がある。

rotation3DEffect

手軽に使える。anchorZ など追加パラメータあり。しかし、複合変換や独自の数式を適用するのは難しい。

GeometryEffect

完全にカスタマイズ可能。複数の変換を組み合わせたり、時間に応じた複雑な数式を適用したりできる。

複数の値をアニメーション

AnimatablePair を使って複数のパラメータをアニメーションできる。

struct ComplexEffect: GeometryEffect {
    var rotation: Double
    var scale: CGFloat
    
    var animatableData: AnimatablePair<Double, CGFloat> {
        get { AnimatablePair(rotation, scale) }
        set {
            rotation = newValue.first
            scale = newValue.second
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let angle = rotation * .pi / 180
        let anchorX = size.width / 2
        let anchorY = size.height / 2
        
        var transform = CGAffineTransform.identity
        transform = transform.translatedBy(x: anchorX, y: anchorY)
        transform = transform.rotated(by: angle)
        transform = transform.scaledBy(x: scale, y: scale)
        transform = transform.translatedBy(x: -anchorX, y: -anchorY)
        
        return ProjectionTransform(transform)
    }
}

回転とスケールが同時にアニメーションし、複合的な動きを実現できる。

GeometryEffect は低レベルな API だが、標準のモディファイアでは実現できない変換アニメーションを作るための強力なツールだ。3D 的な演出やインタラクティブな UI を作る際に活用できる。