2
5

More than 1 year has passed since last update.

各パーツにアニメーションを付与する(SwiftUI)

Posted at

こんにちは。フエルマネー開発者です。

前回はNavigationStackを使った画面遷移設計を行いました。
続いては、Viewに用いる共通パーツ(コンポーネント)を作成していきます。

そうです、ここに来てもまだメインのView構成はしません。周りからしっかりと固めていくのが最近の僕のスタイルです。

.animation(_:value:)とは

iOS16時点で推奨されているアニメーションのためのモディファイアです。
アニメーションさせたいViewにくっつけて使用します。
1-1.png
まずは以下をご覧ください。

ContentView.swift
struct ContentView: View {
    @State var offset: CGFloat = 0.0
    
    var body: some View{
        VStack{
            Rectangle()
                .frame(width: 30, height: 30)
                .offset(x: offset)
            Button("ずらす"){
                offset += 50
            }
        }
    }
}

結果
1-2.gif

ボタンを押すたびに、正方形がoffsetされて動くコードですが、ここに先ほどのanimationをつけてみます。

ContentView.swift
struct ContentView: View {
    @State var offset: CGFloat = 0.0
    
    var body: some View{
        VStack{
            Rectangle()
                .frame(width: 30, height: 30)
                .offset(x: offset)
+                .animation(.linear(duration: 0.4), value: offset)
            Button("ずらす"){
                offset += 50
            }
        }
    }
}

結果
1-3.gif

アニメーションが付与されて、滑らかに動くようになりました。
一行でアニメーションが作れるなんて、素晴らしい時代です。

.animation(.linear(duration: 0.4), value: offset)

このモディファイアでは、valueに入れた値の変化を監視します。今回で言うと、offsetが変化した時のViewの変化にはanimationを適用しますよという意味です。animationの種類はいくつかあります。
・linear: 一定の速度で変化する
・easeIn: 最初は遅く、だんだん早くなる
・easeOut: 最初は早く、だんだん遅くなる
・easeInOut: 遅い→早い→遅い
・default: Viewごとに決まってるらしいです

durationは、何秒かけてanimationするかです。秒単位で指定します。

実践!

それでは実際にフエルマネーで今回使用するアニメーションを作っていきましょう。

何度か使用するようなコンポーネントは、分けてどこかのファイルにまとめて置いとくのがいいと思います。全体の構成を表す大きなViewに画面遷移やらアニメーションやらonAppearやらをたくさん書くのは可読性が落ちますからね。

この記事では、フエルマネーコンポーネントの一つ、見出しテキストを紹介します。

ちょっと目立つ見出しテキスト
1-4.gif

この見出しテキストは、最初表示される時にアニメーションがつきます。

まずは完成形をご覧ください。

FMTitle.swift
struct FMTitle: View{
    let title: String
    @State var isAppeared: Bool = false
    
    init(_ title: String){
        self.title = title
    }
    
    var body: some View{
        VStack(spacing: 0){
            Text(title)
                .font(.title)
            Rectangle()
                .fill(Color.cyan.gradient)
                .frame(height: 3)
        }
        .overlay{
            GeometryReader{ g in
                HStack(spacing: 0){
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? -g.size.width / 2 : 0)
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? g.size.width / 2 : 0)
                }
            }
        }
        .clipped()
        .animation(.easeIn(duration: 0.6), value: isAppeared)
        .onAppear(){
            isAppeared.toggle()
        }
    }
}

意外と長いんですよね、、、
それでは部分ごとに説明していきます。

let title: String
@State var isAppeared: Bool = false

init(_ title: String){
    self.title = title
}

本Viewの定義部です。titleに入れた文字列を見出しとして表示します。
isAppearedがanimationのトリガーとなる部分です。

VStack(spacing: 0){
    Text(title)
        .font(.title)
    Rectangle()
        .fill(Color.cyan.gradient)
        .frame(height: 3)
}

これはアニメーション関係なく、見た目を作っているところですね。説明はあまりいらないと思います。

.overlay{
    GeometryReader{ g in
        HStack(spacing: 0){
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? -g.size.width / 2 : 0)
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? g.size.width / 2 : 0)
        }
    }
}
.clipped()

ここがアニメーションする場所です。今回は中央から見出しが見えるようにアニメーションするので、初期状態では左右からRectangleをoverlayで被せて見出しを隠します。Rectangleは背景色と同色にすることで対応しています。

今回はダークモードなのでblackにしていますが、モードに応じて自動で切り替わるようにassetで背景色を定義しておきましょう。assetでの色定義についてはまた後日、気が向いたら書きます。調べたらすぐ出ると思いますが。

ここで説明するちょいムズ要素は、以下の2つです。
・GeometryReader
・3項演算子(X ? A : B)

GeometryReader{ g in
}

GeometryReaderは、自身の位置やサイズを参照できる画期的なViewです。
引数(上記でいうgの部分)に自身の情報が格納されています。
自身の幅を取得したいときは、g.size.widthを参照すれば良いです。

ただ注意して欲しいのは、GeometryReaderはRectangleやSpacerのように、親Viewに対して満ち満ちに広がります。なので以下のようなコードを書いても、Textの大きさを認識してくれるわけではないということです。

GeometryReader{ g in
    Text("hoge")
}

この場合、g.size.widthは、Text("hoge")ではなく、あくまでGeometryの親ビューによって決まるということです。

もしあなたが、Text("hoge")のサイズを取得したいのであれば、以下のコードで解決できます。

Text("hoge")
    .overlay{
        GeometryReader{ g in
        }
    }

.overlayを使えば、親ビューはText("hoge")と同じサイズになります。
今回フエルマネーの見出しでも、この手法を用いています。

続いて、3項演算子。条件演算子とも言われるみたいですが、以下の形で記述します。

[条件式] ? [値A] : [値B]

条件式がtrueの場合は値A、falseの場合は値Bを返すということです。
if-else文で分岐すると結構かさばるので、これはすごく便利です。
swiftに限らず使われる標準の記述法です。

ではこれらを理解した上でもう一度見てみましょう。

.overlay{
    GeometryReader{ g in
        HStack(spacing: 0){
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? -g.size.width / 2 : 0)
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? g.size.width / 2 : 0)
        }
    }
}
.clipped()

まとめると、isAppearedがfalse→trueになると、左のRectangleは左に動き、右のRectangleは右に動く。動く量はTextの大きさに準じて決まるということですね。

最後についているclippedですが、これはoverlayが親ビューのTextからはみ出て表示されないように、はみ出た分は切り取るということです。ビュー全体へのちょっとした配慮ですね。

.animation(.easeIn(duration: 0.6), value: isAppeared)
.onAppear(){
    isAppeared.toggle()
}

そして最後、animationを実行する部分です。
isApperedが変化した時にはeaseInでアニメーションします。
.onAppear、すなわち本Viewが「表示されたとき」にisAppearedがfalse→trueになるので、animationが実行されます。

これで先ほどの見出しテキストが完成です。
1-4.gif

コード全体

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State var isPresented = false
    
    var body: some View{
        VStack{
            if isPresented{
                FMTitle("Fuer Money for iOS")
            }
            Button("appear"){
                isPresented.toggle()
            }
        }
    }
}

struct FMTitle: View{
    let title: String
    @State var isAppeared: Bool = false
    
    init(_ title: String){
        self.title = title
    }
    
    var body: some View{
        VStack(spacing: 0){
            Text(title)
                .font(.title)
            Rectangle()
                .fill(Color.cyan.gradient)
                .frame(height: 3)
        }
        .overlay{
            GeometryReader{ g in
                HStack(spacing: 0){
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? -g.size.width / 2 : 0)
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? g.size.width / 2 : 0)
                }
            }
        }
        .clipped()
        .animation(.easeIn(duration: 0.6), value: isAppeared)
        .onAppear(){
            isAppeared.toggle()
        }
    }
}
2
5
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
2
5