SwiftUIアニメーション練習: rotationEffectとscaleEffect

開発環境

> xcodebuild -version 
Xcode 13.1
Build version 13A1030d

記事中のスクリーンショットのOS: iOS15

モチベーション

(シャニマス4周年WEB CM第3弾~イルミネーションスターズ・アルストロメリア・シーズ篇~【アイドルマスター】 - YouTube より)

このアニメーションSwiftUIでどうやるんだ? 書こう!

つくったもの

コード

import SwiftUI

struct ContentView: View {
    @State private var flag = false
    private let customRed = Color.init(red: 255/255, green: 186/255, blue: 214/255)
    private let customBlue = Color.init(red: 20/255, green: 67/255, blue: 132/255)
    private let customYellow = Color.init(red: 255/255, green: 224/255, blue: 18/255)
    
    private let width: CGFloat = 320
    private let height: CGFloat = 180
    
    var body: some View {
        ZStack {
            // ■□□: 赤(下から上)
            VStack {
                Spacer()
                HStack(spacing: 0) {
                    Rectangle()
                        .fill(customRed)
                        .frame(width: width/3, height: height)
                        .offset(y: flag ? 0 : height)
                        .animation(.easeIn(duration: 0.475), value: flag)
                    Rectangle()
                        .fill(.clear)
                        .frame(width: width/3, height: height)
                    Rectangle()
                        .fill(.clear)
                        .frame(width: width/3, height: height)
                }
                Spacer()
            }
            // □□■: 黄(上から下)
            VStack {
                Spacer()
                HStack(spacing: 0) {
                    Rectangle()
                        .fill(.clear)
                        .frame(width: width/3, height: height)
                    Rectangle()
                        .fill(.clear)
                        .frame(width: width/3, height: height)
                    Rectangle()
                        .fill(customYellow)
                        .frame(width: width/3, height: height)
                        .offset(y: flag ? 0 : -height)
                        .animation(.easeIn(duration: 0.475), value: flag)
                    
                }
                Spacer()
            }
            // □■□: 青(中央)
            VStack {
                Spacer()
                Rectangle()
                    .fill(customBlue)
                    .frame(width: width, height: height)
                    .scaleEffect(flag ? CGSize(width: 9.0/16.0, height: 16.0/(9.0*3.0)) : CGSize(width: 1, height: 1), anchor: .center)
                    .rotationEffect(Angle.degrees(flag ? 90 : 0))
                    .animation(.easeIn(duration: 0.5), value: flag)
                Spacer()
            }
            // 背景
            VStack(spacing: 0) {
                Rectangle()
                    .fill(.black)
                Rectangle()
                    .fill(.clear)
                    .frame(width: width, height: height)
                Rectangle()
                    .fill(.black)
            }
            // ボタン
            VStack {
                Spacer()
                Button("toggle") {
                    flag.toggle()
                }
            }
        }
    }
}

ハマったところ

scaleEffect(_:anchor:) に渡すのは比率

scaleEffect(_:anchor:) に渡すのは比率のため、比率を計算する必要がある。

// □■□: 青(全体から中央)
VStack {
    Rectangle()
        ...
        .scaleEffect(flag ? CGSize(width: 9.0/16.0, height: 16.0/(9.0*3.0)) : CGSize(width: 1, height: 1), anchor: .center)
        ...
}

rotationEffect、scaleEffectの定義順

(定義順というより宣言順?)

rotationEffectとscaleEffectを一つのViewに適用させたい場合、定義順で評価順が変わるため比率の計算をする場合に注意。

rotationEffectをA, scaleEffectをBとすると、

A -> B B -> A
import SwiftUI

struct ContentView: View {
    @State private var flag = false
    
    var body: some View {
        VStack {
            Spacer()
            Rectangle()
                .fill(.blue)
                .frame(width: 50, height: 50)
            
                // A
                .rotationEffect(Angle.degrees(flag ? 90 : 0))
                
                
                // B
                .scaleEffect(flag ? CGSize(width: 3.0, height: 1.0) : CGSize(width: 1, height: 1), anchor: .center)

                .animation(.easeIn(duration: 0.5), value: flag)
            Spacer()
            Button("toggle") {
                flag.toggle()
            }
        }
    }
}

参考