【第6回】日本語版SwiftUIチュートリアル【複雑なUIを構築する】
日本語版SwiftUIチュートリアル
本チュートリアルは,Appleが掲載している公式のSwiftUIチュートリアルを「日本語で」そして「詳細に」解説し直すことを目的とした記事です.
この手の記事は,他にも似たようなものがありますが,全てを網羅しているわけではなく,部分的に解説している記事が多数です.
そこで,SwiftUIを使ってアプリ開発をしたい方々のために,そして勉強している自分自身のために,日本語版SwiftUIチュートリアルの完全版を書きます.
なお,本記事の難易度としては,初級者も見様見真似で作れるようにはなっていますが,それでは物足りないという中級者のために,細かい文法的な解説も随所に入れていこうと思います.
注意として,本記事はSwiftの言語解説はあまり含まないので,以下の記事を別タブで開いておいて,分からなければ逐一確認してみると良いと思います.
ちなみに出来上がるアプリは,上図に示すような観光地アプリです.
各観光地の位置情報を確認したり,その観光地をお気に入りに追加したりできるアプリを目指します.
第1回は????
第6回:複雑なUIを構築する
第6回では,これまでに作成してきた大きなビュー「LandmarkList」と今回作るもう一つの大きなビューを橋渡しする,メインビューの作成に移ります.
テーマにあるように,最初に作った「観光地がただ順番に配置されているビュー」ではなく,カテゴリごとに整列された「複雑なビュー」の作成です.
今回もそれなりに長いですが頑張っていきましょう.
Step 1. カテゴリビューを作成する
今回作成するビューはカテゴリビューと名付け,そのUI全体をCategoryHomeと呼ぶことにします.
まず,「Categories」という新しいグループを作成し,早速「CategoryHome.swift」を作成していきます.
1 2 3 4 5 6 7 8 9 10 11 12 13 | import SwiftUI struct CategoryHome: View { var body: some View { Text("Hello, World!") } } struct CategoryHome_Previews: PreviewProvider { static var previews: some View { CategoryHome() } } |
これはまだ自動生成されたままのビューです.
ここで,LandmarkList.swiftで作成したように,NavigationViewを利用して階層的なナビゲーションビューを作成していきます.
1 2 3 4 5 6 7 8 | struct CategoryHome: View { var body: some View { NavigationView{ Text("Hello, World!") .navigationTitle("Featured") } } } |
※ここで,「"xxxxxx" is only available in iOS 14.0 or newer」というエラーに遭遇した場合は,Project > Deployment Target > iOS Deployment Target より対象をiOS 14以上に設定します.
Step 2. カテゴリリストを作成する
まずは,Landmarkデータの入った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 | struct Landmark: Hashable, Codable, Identifiable{ var id: Int var name: String fileprivate var imageName: String fileprivate var coordinates: Coordinates var state: String var park: String var category: Category var isFavorite: Bool var locationCoordinate: CLLocationCoordinate2D { CLLocationCoordinate2D( latitude: coordinates.latitude, longitude: coordinates.longitude) } enum Category: String, CaseIterable, Hashable, Codable{ case featured = "Featured" case lakes = "Lakes" case rivers = "Rivers" case mountains = "Mountains" } } |
このカテゴリを用いてリストを作成するのですが,今から作りたいリストは「カテゴリごとに観光地データが整列されたもの」です.
これを実現するために,別でカテゴリデータを作成しましょう.
まず,「Data.swift」にてカテゴリを扱う変数を新たに定義しましょう.
1 2 3 4 5 6 7 8 9 10 | import UIKit import SwiftUI import CoreLocation let landmarkData: [Landmark] = load("landmarkData.json") let hikeData: [Hike] = load("hikeData.json") var categoriesData: [String: [Landmark]] // ... |
型は辞書型(連想配列)です.
このとき,カテゴリごとに観光地データを分割する機能が,辞書型クラスにデフォルトで実装されていますのでそれを使います.
1 2 3 4 5 6 | var categoriesData: [String: [Landmark]] { // Category => [Landmark] という連想配列 (辞書型変数) を定義 Dictionary( grouping: landmarkData, // landmarkDataに対して, by: { $0.category.rawValue } // 列挙型 (enum) のカテゴリのrawValue (実際の値) を使ってグループ化 ) } |
これだけでOKです.
次に,これを監視可能なオブジェクトに入れてあげましょう.
1 2 3 4 5 6 7 8 | import SwiftUI import Combine final class UserData: ObservableObject { @Published var showFavoritesOnly = false @Published var landmarks = landmarkData @Published var categories = categoriesData } |
あとは,CategoryHomeで呼び出しましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct CategoryHome: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ Text("Hello, World!") .navigationTitle("Featured") } } } struct CategoryHome_Previews: PreviewProvider { static var previews: some View { CategoryHome() .environmentObject(UserData()) } } |
ここまで来ると,カテゴライズされたデータが利用可能になっているので...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct CategoryHome: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { ForEach(userData.categories.keys.sorted(), id: \.self) { key in Text(key) } } .navigationTitle("Featured") } } } |
うまく反映されました.
カテゴリリストは一旦置いておいて次に進みます.
Step 3. スクロール可能なカテゴリ行を作成する
次に,カテゴリごとにスクロール可能な横方向に整列した観光地ビューを作成していきましょう.
新たに,「CategoryRow.swift」を作成してください.
1 2 3 4 5 6 7 8 9 10 11 12 13 | import SwiftUI struct CategoryRow: View { var body: some View { Text("Hello, World!") } } struct CategoryRow_Previews: PreviewProvider { static var previews: some View { CategoryRow() } } |
これはデフォルトの状態です.
このビューでは,カテゴリごとに生成されるビューなので,このビュー(構造体)には観光地リストが渡される想定になります.
したがって,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import SwiftUI struct CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { Text("Hello, World!") } } struct CategoryRow_Previews: PreviewProvider { static var landmarks = UserData().landmarks // プレビューでは観光地データを直接使用 static var previews: some View { CategoryRow( categoryName: landmarks[0].category.rawValue, // 仮で0番目の観光地カテゴリ名を使用 items: Array(landmarks.prefix(3)) // 仮で先頭から3要素だけ ) } } |
としましょう.
プレビューは,あくまで仮表示ですので実際にカテゴライズされたデータは与えていませんので注意してください.
1 2 3 4 5 6 7 8 9 10 11 12 | import SwiftUI struct CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { Text(categoryName) .font(.headline) } } // ... |
実際に,プレビューで受け取ったカテゴリ名を表示してみると,うまくいっていそうです.
このまま情報量を増やしましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { VStack(alignment: .leading) { Text(categoryName) .font(.headline) HStack(alignment: .top, spacing: 0) { ForEach(items) { landmark in Text(landmark.name) } } } } } |
とりあえず,受け取ったデータ全てに対して観光地名をスタックしてみました.
そうしたら,レイアウトを調整しつつ,もう少し多くのデータを使用して,スクロール可能なビューに変えてみます.
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 | import SwiftUI struct CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { VStack(alignment: .leading) { Text(categoryName) .font(.headline) .padding(.leading, 15) // 左からパディング .padding(.top, 5) // 上からパディング ScrollView(.horizontal, showsIndicators: false) { // 水平方向のスクロール & インディケータ無し HStack(alignment: .top, spacing: 0) { ForEach(items) { landmark in Text(landmark.name) } } } .frame(height: 185) } } } struct CategoryRow_Previews: PreviewProvider { static var landmarks = UserData().landmarks // プレビューでは観光地データを直接使用 static var previews: some View { CategoryRow( categoryName: landmarks[0].category.rawValue, // 仮で0番目の観光地カテゴリ名を使用 items: Array(landmarks.prefix(5)) // 仮で先頭から5要素だけ ) } } |
うまく動作していそうですね!
でもこれだと味気ないので,画像を利用したいのですがコードがごちゃごちゃしてきたので,観光地ごとにビューを作成しましょう.
ここでは「CategoryItem.swift」とします.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | import SwiftUI struct CategoryItem: View { var landmark: Landmark var body: some View { VStack(alignment: .leading) { landmark.image .resizable() .frame(width: 155, height: 155) .cornerRadius(5) Text(landmark.name) .font(.caption) } .padding(.leading, 15) } } struct CategoryItem_Previews: PreviewProvider { static var previews: some View { CategoryItem(landmark: UserData().landmarks[0]) // 仮で一番目の観光地 } } |
特別な解説はもう要りませんね?
これを,CategoryRowで呼び出しましょう.
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 CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { VStack(alignment: .leading) { Text(categoryName) .font(.headline) .padding(.leading, 15) .padding(.top, 5) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 0) { ForEach(items) { landmark in CategoryItem(landmark: landmark) } } } .frame(height: 185) } } } |
クールなビューに仕上がりました!
Step 4. カテゴリビューを仕上げる
さて,今作成してきたものを繋げてカテゴリビューを完成させましょう.
CategoryHomeに戻り,先ほど作成したCategoryRowを呼び出します.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import SwiftUI struct CategoryHome: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { ForEach(userData.categories.keys.sorted(), id: \.self) { key in CategoryRow(categoryName: key, items: userData.categories[key]!) } } .navigationTitle("Featured") } } } |
うまく紐づいているようです!
そうしたら,取り上げられた(Featured)観光地を表示してもっとクールなビューにしましょう.
JSONデータには "isFeatured" というデータも格納されているのでそれを利用します.
まずはモデルに追記します.
1 2 3 4 5 6 7 8 9 10 11 12 13 | struct Landmark: Hashable, Codable, Identifiable{ var id: Int var name: String fileprivate var imageName: String fileprivate var coordinates: Coordinates var state: String var park: String var category: Category var isFavorite: Bool var isFeatured: Bool // ... |
カテゴリと同様にData.swiftにて,Featuredに該当する観光地配列を作成しましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import UIKit import SwiftUI import CoreLocation let landmarkData: [Landmark] = load("landmarkData.json") let hikeData: [Hike] = load("hikeData.json") var featuresData: [Landmark] { landmarkData.filter { $0.isFeatured } } var categoriesData: [String: [Landmark]] { // Category => [Landmark] という連想配列 (辞書型変数) を定義 Dictionary( grouping: landmarkData, // landmarkDataに対して by: { $0.category.rawValue } // 列挙型 (enum) のカテゴリのrawValue (実際の値) を使ってグループ化 ) } // ... |
さらに同様に監視可能オブジェクトにしてください.
1 2 3 4 5 6 7 8 9 | import SwiftUI import Combine final class UserData: ObservableObject { @Published var showFavoritesOnly = false @Published var landmarks = landmarkData @Published var features = featuresData @Published var categories = categoriesData } |
あとは,CategoryHomeで呼び出すだけです.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import SwiftUI struct CategoryHome: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { userData.features[0].image .resizable() .scaledToFill() .frame(height: 200) .clipped() ForEach(userData.categories.keys.sorted(), id: \.self) { key in CategoryRow(categoryName: key, items: userData.categories[key]!) } } .navigationTitle("Featured") } } } |
うまく読み込めていますね!
最後に,(最近の流行りかもしれませんが)スクロールメニューなので余白をなくしてデザインを整えてあげましょう.
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 CategoryHome: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { userData.features[0].image .resizable() .scaledToFill() .frame(height: 200) .clipped() .listRowInsets(EdgeInsets()) ForEach(userData.categories.keys.sorted(), id: \.self) { key in CategoryRow(categoryName: key, items: userData.categories[key]!) } .listRowInsets(EdgeInsets()) } .navigationTitle("Featured") } } } |
とても見栄えがよくなりましたね!
Step 5. ビューのナビゲーションをつなげる
初期に作成した,観光地詳細ビューと今作成したカテゴライズされた観光地を繋げましょう.
まずはCategoryRowにて,以下のコードを追記すれば紐づけることができますね.
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 | import SwiftUI struct CategoryRow: View { var categoryName: String // カテゴリ名 var items: [Landmark] // カテゴリに属する観光地リスト var body: some View { VStack(alignment: .leading) { Text(categoryName) .font(.headline) .padding(.leading, 15) .padding(.top, 5) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 0) { ForEach(items) { landmark in NavigationLink(destination: LandmarkDetail(landmark: landmark)) { CategoryItem(landmark: landmark) } } } } .frame(height: 185) } } } |
これでも良いのですが,ナビゲーションリンク化したアイテムは自動で,テキストはアクセントカラー(ここでは青)に,画像も青くなってしまうことがあります.
こういった機能は,通常では大した問題にはならないのですが,デザインを考慮した時に変更したい場合があります.
そのような時には,最小構成のビュー(ここではCategoryItem)にてあらかじめ指定しておくことで回避できます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct CategoryItem: View { var landmark: Landmark var body: some View { VStack(alignment: .leading) { landmark.image .renderingMode(.original) .resizable() .frame(width: 155, height: 155) .cornerRadius(5) Text(landmark.name) .foregroundColor(.primary) .font(.caption) } .padding(.leading, 15) } } |
さて,観光地データについて,これで全てのビューが出揃いましたので,大きなビュー同士をまとめていきましょう.
本チュートリアルでは,最初に作ったLandmarkListと今作成してきたCategoryHomeがそれに当たります.
これらをどうまとめるかと言うと,タブビューを用いることにします.
タブビューの挙動については,実際に実装して見た方が早いので早速実装していきます.
新たに,親グループ(Landmarks)に「ContentView.swift」を作成してください.
そしてひとまず,LandmarkListを表示させておきます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import SwiftUI struct ContentView: View { var body: some View { LandmarkList() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environmentObject(UserData()) } } |
このビューに,「Featured」と「List」という2つのタブを作っていきます.
見様見真似でコーディングしてみてください.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import SwiftUI struct ContentView: View { @State private var selection: Tab = .featured // 初期は Featuredタブ // Tabに関する列挙型を定義 enum Tab { case featured case list } var body: some View { LandmarkList() } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import SwiftUI struct ContentView: View { @State private var selection: Tab = .featured // 初期は Featuredタブ // Tabに関する列挙型を定義 enum Tab { case featured case list } var body: some View { TabView(selection: $selection) { // タブビュー:@State属性を扱う場合は $ を付与 CategoryHome() .tag(Tab.featured) // selectionが Tab.feturedのとき LandmarkList() .tag(Tab.list) // selectionが Tab.listのとき } } } |
ここで,画面下部にグレーの表示が現れましたね.
ここにタブ切り替えボタン(ラベル)が現れます.
早速,各タブビューにラベルを付与してみます.
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 | import SwiftUI struct ContentView: View { @State private var selection: Tab = .featured // 初期は Featuredタブ // Tabに関する列挙型を定義 enum Tab { case featured case list } var body: some View { TabView(selection: $selection) { CategoryHome() .tabItem { Label("Featured", systemImage: "star") } .tag(Tab.featured) LandmarkList() .tabItem { Label("List", systemImage: "list.bullet") } .tag(Tab.list) } } } |
タブが表示され,実際に動作するはずです.
実際に確認してみてください.
第6回:おわりに
お疲れ様でした.
だいぶアプリケーションらしくなってきましたね!
ここまでくると,あなたは多くのSwiftUI基礎知識を学んでいるはずなので,もしかしたら「もっとこうしたい」「こういうアプリ作りたいな」などアイデアが浮かんでくる人もいるかもしれませんね.
ですが,まずはしっかり本チュートリアルを終わらせることが大事です.
そして,本チュートリアルでも解説しきれていないところがたくさんあるので,少しでも「これなんでだろう...??」と思ったら,とりあえず調べてみましょう.
答えを探しあてて,そのときは解決しなくともその姿勢が大事です.
いよいよ本チュートリアルも折り返しました.
終盤も頑張りましょう!