【第5回】日本語版SwiftUIチュートリアル【アニメーション】
日本語版SwiftUIチュートリアル
本チュートリアルは,Appleが掲載している公式のSwiftUIチュートリアルを「日本語で」そして「詳細に」解説し直すことを目的とした記事です.
この手の記事は,他にも似たようなものがありますが,全てを網羅しているわけではなく,部分的に解説している記事が多数です.
そこで,SwiftUIを使ってアプリ開発をしたい方々のために,そして勉強している自分自身のために,日本語版SwiftUIチュートリアルの完全版を書きます.
なお,本記事の難易度としては,初級者も見様見真似で作れるようにはなっていますが,それでは物足りないという中級者のために,細かい文法的な解説も随所に入れていこうと思います.
注意として,本記事はSwiftの言語解説はあまり含まないので,以下の記事を別タブで開いておいて,分からなければ逐一確認してみると良いと思います.
ちなみに出来上がるアプリは,上図に示すような観光地アプリです.
各観光地の位置情報を確認したり,その観光地をお気に入りに追加したりできるアプリを目指します.
第1回は????
第5回:アニメーションビューと遷移
今回は,前回と似たような内容になりますが,アニメーションについてです.
アニメーションの遷移,動きをどう実装していくのか,ということを中心に解説していきます.
Step 1. ハイキングモデルを作る
この部分は公式チュートリアルでは,予め作成されたファイルを使いますが,本チュートリアルでは「ほぼゼロから実装」を目標としていますので,作成しましょう.
面倒臭いという方は,最後のコードをコピペでも構いません.
さて,今から何をするかというと,今まで観光地データを元にビューを作成してきましたが,今回は本アプリを使用している間に行ったハイキングの情報を可視化しようと考えています.
今回は,実際にハイキングデータが「Resource」にJSONファイルとして入っていると思うのでそれを使用します.
もし,無い方は改めてResource.zipを手元に落として確認してみてください.
まずは,JSONファイルの中身を見てみましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | [ { "name":"Lonesome Ridge Trail", // ハイキングコース名 "id":1001, "distance":4.5, // 距離 "difficulty":3, // 難易度 "observations":[ // 観測データ { "elevation":[ // 標高 291.65263635636268, // 最初 309.26016677925196 // 最後 ], "pace":[ // ペース 396.08716481908732, // 最初 403.68937873525232 // 最後 ], "heartRate":[ // 心拍数 117.16351898665887, // 最初 121.95815455919609 // 最後 ], "distanceFromStart":0 // スタートから何kmか }, { "elevation":[ 299.24001936628116, 317.44584350790012 ], "pace":[ 380.19020240756623, 395.3978319010256 ], "heartRate":[ 117.6410892152911, 124.82185220506081 ], "distanceFromStart":0.375 }, ... ] |
それぞれ何を示しているかというのは,コメントをつけておきました.
公式チュートリアルでは説明が無いので,いまいちイメージがつきづらいですよね.
0.375kmごとに,標高,ペース,心拍数を記録したデータです.
それぞれの区間における,初期値と到達地点での値の2つのデータが格納されています.
このデータを読み込むためのモデルを最初に作っていきます.
新しくModelsに「Hike.swift」を作成してください.
そしてコードを書いていきましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import SwiftUI struct Hike: Codable, Hashable, Identifiable { var name: String var id: Int var distance: Double var difficulty: Int var observations: [Observation] static var formatter = LengthFormatter() var distanceText: String { return Hike.formatter .string(fromValue: distance, unit: .kilometer) } struct Observation: Codable, Hashable { var distanceFromStart: Double var elevation: Range<Double> var pace: Range<Double> var heartRate: Range<Double> } } |
モデルの実装は一度やっているので,細かい説明は省きますが LengthFormatter() だけ初登場なので解説します.
LengthFormatter() は距離の単位を変換するためのクラスです.
例えばメートルからキロメートルへの変換や,マイルへの変換などを簡単にしてくれます.
他にも,質量系の MassFormatter() など様々なFormatterが存在します.
そうしたら,これも「Data.swift」で読み込むようにします.(公式ではいつの間にか? ModelData.swiftになっていますね)
1 2 3 4 5 6 7 8 | import UIKit import SwiftUI import CoreLocation let landmarkData: [Landmark] = load("landmarkData.json") let hikeData: [Hike] = load("hikeData.json") ... |
これで下準備は完了です.
Step 2. 個別ビューへアニメーションを追加してみる
先ほど下準備は完了した,と言いましたが本題に入る前に,まだやることがあります.
まず,読み込んだハイキングデータを可視化したビューを実装したいのですが,これは公式チュートリアルでも解説はなく,これを解説していると冗長になるので,コードの中身とコメントだけで済ませます.
一つ目は「GraphCapsule.swift」です.
これは,区間ごとに記録したデータを高さとした楕円型のビューを描画する構造体です.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | import SwiftUI struct GraphCapsule: View { var index: Int // インデックス var height: CGFloat // ビュー(外枠)の高さ var range: Range<Double> // 楕円の大きさ = 観測データ範囲 var overallRange: Range<Double> // 観測データ全体の範囲 // 楕円の大きさ全体の範囲から算出 var heightRatio: CGFloat { max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15) } // 楕円の位置を算出 var offsetRatio: CGFloat { CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange)) } var body: some View { Capsule() .fill(Color.white) .frame(height: height * heightRatio) // 必ずビューに収まるようにする .offset(x: 0, y: height * -offsetRatio) } } struct GraphCapsule_Previews: PreviewProvider { static var previews: some View { GraphCapsule(index: 0, height: 150, range: 10..<50, overallRange: 0..<100) } } |
magnitude(of: ) 関数はこの後作成する「HikeGraph.swift」で定義します.
こちらのファイルは,先ほどの「GraphCupsule」を呼び出して実際に観測データを可視化するビューになります.
その他,いくつか関数を定義しています.
以下,「HikeGraph.swift」です.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | import SwiftUI // 受け取った複数のデータから全体の最小値から最大値の範囲オブジェクトを返す関数 func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double> where C.Element == Range<Double> { guard !ranges.isEmpty else { return 0..<0 } let low = ranges.lazy.map { $0.lowerBound }.min()! let high = ranges.lazy.map { $0.upperBound }.max()! return low..<high } // 範囲オブジェクトの大きさを返す関数 func magnitude(of range: Range<Double>) -> Double { return range.upperBound - range.lowerBound } // 切り替えアニメーションを追記 extension Animation { static func ripple(index: Int) -> Animation { Animation.spring(dampingFraction: 0.5) .speed(2) .delay(0.03 * Double(index)) } } // ビュー設定 struct HikeGraph: View { var hike: Hike var path: KeyPath<Hike.Observation, Range<Double>> // ジャンルによって色を変える var color: Color { switch path { case \.elevation: return .gray case \.heartRate: return Color(hue: 0, saturation: 0.5, brightness: 0.7) case \.pace: return Color(hue: 0.7, saturation: 0.4, brightness: 0.7) default: return .black } } var body: some View { // 観測データ配列を全て取得 let data = hike.observations // 観測データそれぞれの全体範囲を取得 let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] }) // データ最大値 let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()! // データ最大値をもとにビューの高さ比率を決める let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) // ビューを構築 return GeometryReader { proxy in HStack(alignment: .bottom, spacing: proxy.size.width / 120) { ForEach(data.indices) { index in GraphCapsule( index: index, height: proxy.size.height, range: data[index][keyPath: self.path], overallRange: overallRange) .colorMultiply(self.color) .transition(.slide) .animation(.ripple(index: index)) } .offset(x: 0, y: proxy.size.height * heightRatio) } } } } struct HikeGraph_Previews: PreviewProvider { static var previews: some View { Group { HikeGraph(hike: hikeData[0], path: \.elevation) .frame(height: 200) HikeGraph(hike: hikeData[0], path: \.heartRate) .frame(height: 200) HikeGraph(hike: hikeData[0], path: \.pace) .frame(height: 200) } } } |
このとき「xxx may have crashed」エラーが出たら,JSONファイルがビルドターゲットに入っていないことが考えられます.
...
ここで初めて出てきた KeyPath<> について.
KeyPath は構造体のプロパティに対して動的なアクセスを可能とする一種の型 (クラス) です.
複雑な構造体であるほどそのメリットを発揮するわけですが,実際に struct HikeGraph 内のプロパティでは,
1 2 3 4 5 6 | struct HikeGraph: View { var hike: Hike var path: KeyPath<Hike.Observation, Range<Double>> // ... } |
と定義していますね.
これは, struct Hike > struct Observation という複雑な構造をした構造体に対してのKeyPath,つまりアクセスするためのパスを一つのプロパティで済ませています.
今はイメージがつきにくいですが,ここの部分,あとでまた出てくるのでちょっと覚えておいてください.
...
Step 3. HikeViewを作る
次に, struct HikeGraph を使って詳細データのビュー「HikeDetail.swift」とそれを一覧として操作する大枠のビュー「HikeView.swift」を作成していきます.
まずは「HikeDetail.swift」から.
このビューでは,HikeDataから3つの主要データ「Elevation (標高)」「HeartRate (心拍)」「Pace (ペース)」を全て一つのインターフェイスで,ボタンによってこれらのグラフを切り替え,表示する役割を担います.
ともかく,コメント付きでコード全体を見せます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | import SwiftUI struct HikeDetail: View { let hike: Hike @State var dataToShow = \Hike.Observation.elevation // KeyPath<>: 初期値も設定 // ↑ 型は明記していないが,本当は KeyPath<Hike.Observation, Range<Double>> という型になる var buttons = [ // (ボタン表示名, その値へのKeyPath)というタプル配列 ("Elevation", \Hike.Observation.elevation), ("Heart Rate", \Hike.Observation.heartRate), ("Pace", \Hike.Observation.pace) ] var body: some View { return VStack { HikeGraph(hike: hike, path: dataToShow) // Hikeインスタンスと,表示するもののKeyPathを渡している .frame(height: 200) HStack(spacing: 25) { ForEach(buttons, id: \.0) { value in Button(action: { self.dataToShow = value.1 // タプルの2つ目の要素(=KeyPath)を代入するというアクション }) { Text(value.0) // ボタンテキストはタプルの1つ目(=String) .font(.system(size: 15)) .foregroundColor(value.1 == self.dataToShow // 三項演算子: もしタプル二番目要素と今表示しているKeyPathが一致しているならば ? Color.gray : Color.accentColor) .animation(nil) // アニメーションは指定なし } } } } } } struct HikeDetail_Previews: PreviewProvider { static var previews: some View { HikeDetail(hike: hikeData[0]) } } |
ここでも,KeyPathが出てきました.
しかし,このコードをみるとなんとなくその使い方が見えてくるはずです.
KeyPathは, \ を先頭につけることで取得することができ,その型はこのコードのように初期値があれば明記しなくても推測してくれますが,先に触れた「HikeGraph.swift」では初期値がなかったため, KeyPath<Hike.Observation, Range<Double>> と型を明記していたわけですね.
C/C++のオブジェクトポインタとも少し似ていますね.
さて,次に「HikeView.swift」を作っていきましょう.
その内容は,LandmarkViewと似ていて,Stackを利用して今つくったHikeDetailビューを呼び出すUIを作成します.
これもコード全容を掲載してしまいます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | import SwiftUI struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { HikeGraph(hike: hike, path: \.elevation) // ここでもKeyPath: 今回はHike.Observationであることは定義から自明なので,省略して \. と表記している .frame(width: 50, height: 30) VStack(alignment: .leading) { Text(hike.name) .font(.headline) Text(hike.distanceText) } Spacer() Button(action: { self.showDetail.toggle() }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .padding() .animation(.easeInOut) // アニメーション遷移についての定義 } } if showDetail { HikeDetail(hike: self.hike) } } } } struct HikeView_Previews: PreviewProvider { static var previews: some View { VStack { HikeView(hike: hikeData[0]) .padding() Spacer() } } } |
アニメーションなどの動作はみなさんの目で確かめてみてください.
Step 4. アニメーションを調整する
ここまででも十分UIとしては機能しています.
が,もう少しアニメーションに拘ってみましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... Button(action: { self.showDetail.toggle() }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .scaleEffect(showDetail ? 1.5 : 1) // 三項演算子: 詳細表示しているなら1.5倍に .padding() .animation(.easeInOut) } } // ... } } } |
例えば,ボタンを押して詳細表示するときに矢印を大きくしてみたり.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... Button(action: { self.showDetail.toggle() }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .scaleEffect(showDetail ? 1.5 : 1) .padding() .animation(.spring()) // アニメーションの種類を変えてみる (違いはよくわからない) } } // ... } } } |
アニメーションの挙動を変えてみたり.
他にも, .default .easeIn .easeOut など色々な指定方法があります.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... Button(action: { withAnimation(.easeInOut(duration: 2)) { // 2秒かけてアクションを実行 self.showDetail.toggle() } }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .scaleEffect(showDetail ? 1.5 : 1) .padding() .animation(.spring()) } // ... } } } |
こんなこともできます.
フワーっと表示されるかと思います.
Step 5. 凝ったアニメーションの設定
もう少し,複雑で凝ったアニメーションも作成してみましょう.
本チュートリアルでは,先ほども簡単にいじったHikeDetailの表示についてもう少し詳細に定義していきます.
まずは,先ほどのコードを一部元に戻します.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... Button(action: { withAnimation { self.showDetail.toggle() } }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .scaleEffect(showDetail ? 1.5 : 1) .padding() .animation(.spring()) } // ... } } } |
そうしたら,仮に以下のようにスライドして表示させるコードを追記してみましょう!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... } if showDetail { HikeDetail(hike: self.hike) .transition(.slide) } } } } |
これだけでも,十分カッコ良いアニメーションなのですが,せっかくなのでもう少し細かく,自分好みに定義してみます.
そんなときは,自分でアニメーションを定義して .slide のように扱えるようにしましょう.
その場合は, extension を使って構造体 (またはクラスや列挙型) を拡張してしまいます.
.transition() の引数型は AnyTransition なので...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | extension AnyTransition { // 新たにAnyTransitoinにアニメーションを追加 (拡張: extension) する static var moveAndFade: AnyTransition { // moveAndFadeという名前を新たに定義 AnyTransition.slide } } struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... } if showDetail { HikeDetail(hike: self.hike) .transition(.moveAndFade) // ここで呼び出せるようになる } } } } |
と,このように拡張し使用することができます.
ここまでは先ほどと挙動はなんら変わりません.
ここからは,今作成した moveAndFade をさらに細かく仕様を決めていきましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | extension AnyTransition { static var moveAndFade: AnyTransition { AnyTransition.move(edge: .trailing) // 同じ端からフレームインアウトするようになる } } struct HikeView: View { var hike: Hike @State private var showDetail = false var body: some View { VStack { HStack { // ... } if showDetail { HikeDetail(hike: self.hike) .transition(.moveAndFade) // ここで呼び出せるようになる } } } } |
まずは,動きをシンメトリックにしてみました.
.move(edge: .trailing) は,Trailing (右端) からフレームインアウトするようにしています.
他にも, .top .bottom .leading が使用できます.
ここで,フレームインとアウト,異なる動きを実装したい場合は AnyTransition.asymmetric(insertion: , removal: ) を利用します.
以下のような使い方です.
1 2 3 4 5 6 7 8 9 10 11 | extension AnyTransition { static var moveAndFade: AnyTransition { let insertion = AnyTransition.move(edge: .trailing) // フレームイン .combined(with: .opacity) // 透過を伴って let removal = AnyTransition.scale // フレームアウト .combined(with: .opacity) return .asymmetric(insertion: insertion, removal: removal) // アシンメトリ (非対称) なアニメーション設定 } } |
return も追記されましたが,Computed propatyを覚えていれば不思議がることはないでしょう.
これで非対称なアニメーションが定義できました!
Step X. プロジェクトを整理する
ここまで,本チュートリアルを追ってきた方はプロジェクトファイルが乱雑になってきていませんか?
ここで整理しておきましょう.
本来であれば,こういったアプリやソフトウェアを設計する段階が明確にあり,プロジェクトはある程度整理されているのですが,チュートリアルでは見切り発車のようにコーディングを始めてしまったため,コードが乱雑になっています,
したがって
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | . ├── Landmarks │ ├── Badges/ │ │ ├── Badge.swift │ │ ├── BadgeBackground.swift │ │ ├── BadgeSymbol.swift │ │ ├── HexagonParameters.swift │ │ └── RotatedBadgeSymbol.swift │ ├── Helpers/ │ │ ├── CircleImage.swift │ │ └── MapView.swift │ ├── Hikes/ │ │ ├── GraphCapsule.swift │ │ ├── HikeDetail.swift │ │ ├── HikeGraph.swift │ │ └── HikeView.swift │ ├── Landmarks/ │ │ ├── LandmarkDetail.swift │ │ ├── LandmarkList.swift │ │ └── LandmarkRow.swift │ ├── Models/ │ │ ├── Data.swift │ │ ├── Hike.swift │ │ ├── Landmark.swift │ │ └── UserData.swift │ ├── Preview Content/ │ └── Resources/ └── Landmarks.xcodeproj |
このような感じで,Badge関連はBadges/に,Hike関連はHikes/に,のように整理(グループ化)します.(一部ファイルは省略して記載しています)
新しいディレクトリ(グループ)の作成は,作成したいディレクトリ構造(ここでは親ディレクトリであるLandmarks)にカーソルを合わせダブルタップ,その中の「New Group」からできます.
第5回:おわりに
今回はアニメーションの定義の仕方について学びました.
魅力的なアプリは,クールなアニメーションが用いられていることも少なくありません.
ただ,アニメーションを取り入れすぎるとウザくなったりするので,使用量をうまく調整して使っていきましょう.
さて,次回からは今まで作成してきたビューを統合し,一つのアプリにまとめ上げていきます!