💫

【SwiftUI】 回転系のアニメーション: カスタムレイアウト+Animatable bodyによるリッチなUI表現

2024/10/17に公開

はじめに

今回の記事では、WWDC23で紹介されていたAnimatableに準拠したViewの挙動について理解を深めるために、学習した内容を言語化していきます。

動機

WWDC23のデモで、オブジェクトが円周上を回転するアニメーションを紹介していました。
このアニメーションの実現方法を知りたくなり、実装して確めてみました。

環境

  • Xcode 16.0
  • Swift 6.0

実装の方針

ViewのbodyとLayoutを1フレームごとに再実行させる
(理由は後述)

  1. カスタムのLayoutRadialLayoutを作成
  2. カスタムLayoutRadialLayoutとオフセット角度offsetプロパティから成る子Viewを作成
  3. 親Viewでoffsetの状態を変更し、子Viewに渡す

全体のコード

親View
import SwiftUI

struct ContentView: View {
    
    @State private var offset: Angle = .degrees(0)
    
    var body: some View {
        RadialView(offset: offset)
            .padding()
            .onTapGesture {
                withAnimation {
                    offset += .degrees(180)
                }
            }
    }
}
子View
import SwiftUI

struct RadialView: View {
    
    var offset: Angle
    
    var body: some View {
        RadialLayout(offset: offset) {
            Avatar("🐶")
            Avatar("🐱")
            Avatar("🐭")
        }
        .background {
            Circle()
                .strokeBorder(.gray, style: StrokeStyle(lineWidth: 4, dash: [10, 10]))
        }
    }
}

extension RadialView: Animatable {
    
    var animatableData: Angle.AnimatableData {
        get { offset.animatableData }
        set { offset.animatableData = newValue}
    }
    
}

struct RadialLayout: Layout {
    
    private let offset: Angle
    
    init(offset: Angle = Angle(degrees: 0.0)) {
        self.offset = offset
    }
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        for (index, subview) in subviews.enumerated() {
            let radius = bounds.width / 3.0
            let angle = (Angle.degrees(360.0 / Double(subviews.count) * Double(index)) + offset).radians
            
            var point = CGPoint(x: 0, y: radius)
                .applying(CGAffineTransform(rotationAngle: angle))
            
            point.x += bounds.midX
            point.y += bounds.midY
            
            subview.place(at: point, anchor: .center, proposal: .unspecified)
        }
    }
}

ポイント

ポイントは子ViewをAnimatableに準拠させること。
これを行うことで、子ViewのbodyがAnimatable属性になり、offsetがAnimatableデータとして扱われる。

extension RadialView: Animatable {
    
    var animatableData: Angle.AnimatableData {
        get { offset.animatableData }
        set { offset.animatableData = newValue}
    }
    
}

親Viewから渡されるoffsetの変更に対して、アニメーションのフレームごとbodyが呼び出されLayoutが再実行される。

Animatableに準拠させた場合 デフォルト

GitHubリポジトリへのリンク

リポジトリでも公開しているので、細かくみたい方はこちらも参考にしてみてください。

https://github.com/Yoppei/swiftui-rotate-layout-animation

参考

WWDC23

https://developer.apple.com/videos/play/wwdc2023/10156

記事

https://qiita.com/ShionKoga/items/c67ee47049ce0f91905e

Discussion