【第3回】日本語版SwiftUIチュートリアル【ユーザ入力を扱う】
日本語版SwiftUIチュートリアル
本チュートリアルは,Appleが掲載している公式のSwiftUIチュートリアルを「日本語で」そして「詳細に」解説し直すことを目的とした記事です.
この手の記事は,他にも似たようなものがありますが,全てを網羅しているわけではなく,部分的に解説している記事が多数です.
そこで,SwiftUIを使ってアプリ開発をしたい方々のために,そして勉強している自分自身のために,日本語版SwiftUIチュートリアルの完全版を書きます.
なお,本記事の難易度としては,初級者も見様見真似で作れるようにはなっていますが,それでは物足りないという中級者のために,細かい文法的な解説も随所に入れていこうと思います.
注意として,本記事はSwiftの言語解説はあまり含まないので,以下の記事を別タブで開いておいて,分からなければ逐一確認してみると良いと思います.
ちなみに出来上がるアプリは,上図に示すような観光地アプリです.
各観光地の位置情報を確認したり,その観光地をお気に入りに追加したりできるアプリを目指します.
第1回は????
第3回:ユーザ入力を扱う
今回は,ユーザ入力を受けビューを再描画させることを目指します.
具体的にいうと,各観光地をお気に入りに追加したり,解除したり,またお気に入りの観光地のみを表示させたり...
Step 1. お気に入り機能を実装する
実はJSONファイルによる観光地データの初期化では,お気に入りデータも入っています.
仮なので,どれをお気に入りにしているかは,Apple側が勝手に決めていますが,動作確認でそれらを使います.
まずは,モデルにそれに値するプロパティを追加しましょう.
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" } } |
その次に,「LandmarkRow.swift」で表示させてみましょう.
今回,お気に入りマークは星アイコンを使います.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | struct LandmarkRow: View { var landmark: Landmark var body: some View { HStack{ landmark.image .resizable() .frame(width: 50, height: 50) Text(landmark.name) Spacer() if landmark.isFavorite { Image(systemName: "star.fill") .imageScale(.medium) } } } } |
ここで, Image(systemName: "star.fill") とありますが, systemName は標準実装されているアイコンを呼び出す際のラベルです.
HTML/CSSでいうFontawesome的なもので,どんなアイコンがあるかは,SF Symbolsで確認できます.
さて,せっかくなので色を変えましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | struct LandmarkRow: View { var landmark: Landmark var body: some View { HStack{ landmark.image .resizable() .frame(width: 50, height: 50) Text(landmark.name) Spacer() if landmark.isFavorite { Image(systemName: "star.fill") .imageScale(.medium) .foregroundColor(.yellow) } } } } |
Step 2. リストのフィルタリング
今,お気に入りアイコンを追加したので「LandmarkList.swift」でもプレビューに反映されていると思います.
次に,このリストにフィルタリグ機能を追加しようと思います.
まずは,静的にお気に入りの観光地だけ表示させてみましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | struct LandmarkList: View { var showFavoritesOnly: Bool = true var body: some View { NavigationView{ List(landmarkData) { landmark in if !self.showFavoritesOnly || landmark.isFavorite{ NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } .navigationBarTitle(Text("Landmarks")) } } } |
お気に入りの観光地だけ表示されていますが,表示が綺麗ではありませんね.
これは,なにもビューを設定していない (=お気に入りではない) 観光地オブジェクトもリストに入ってしまっているため,空欄として描画されています.
そこで,リストに渡す観光地オブジェクト配列を最初に捌いてあげましょう.
そういったときは, ForEach(...) { } を利用します.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | struct LandmarkList: View { var showFavoritesOnly: Bool = true var body: some View { NavigationView{ List { ForEach(landmarkData) { landmark in if !self.showFavoritesOnly || landmark.isFavorite { NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } } .navigationBarTitle(Text("Landmarks")) } } } |
うまく描画されるようになりました!
Step 3. フィルタリングの切り替え
まだ今のままだと,お気に入りのものしか表示されないので,ユーザがそれを切り替えられるようにします.
この showFavoritesOnly というプロパティは,変更されたらすぐにビューの再描画が行われなければなりません.
そういったプロパティには @State を付与することで,前述の動作を実装することができます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | struct LandmarkList: View { @State var showFavoritesOnly: Bool = true var body: some View { NavigationView{ List { ForEach(landmarkData) { landmark in if !self.showFavoritesOnly || landmark.isFavorite { NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } } .navigationBarTitle(Text("Landmarks")) } } } |
そうしたら,それを変更できるようなトグルをビューに追加しましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | struct LandmarkList: View { @State var showFavoritesOnly: Bool = true var body: some View { NavigationView{ List { Toggle(isOn: $showFavoritesOnly) { Text("Favorites only") } ForEach(landmarkData) { landmark in if !self.showFavoritesOnly || landmark.isFavorite { NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } } .navigationBarTitle(Text("Landmarks")) } } } |
ここで, $showFavoritesOnly という扱いが, @State を付与したプロパティで使用できる特別な変数です.
先ほど言ったように,変更に応じてビューの再描画を促す役割を持ちます.
上手く動作していそうですね.
初期描画では,全部の観光地を表示して欲しいので, showFavoritesOnly = false での初期化に直しておきましょう.
Step 4. 監視可能オブジェクトの定義
これから,ユーザ自身がお気に入りに追加したり,解除したりするため,観光地データのプロパティも監視し続け,変更に応じて再描画をする必要があります.
そういったデータを一つのクラスとしてまとめた物を監視可能オブジェクト (Observable Object) と呼びます.
今回は,先ほど作成した showFavoritesOnly と観光地データ landmarkData を監視可能オブジェクトとしてまとめます.
これらはユーザに依存するので,「UserData.swift」という名前でModelsディレクトリに作成してください.
中身は以下のものだけです.
1 2 3 4 5 6 7 | import SwiftUI import Combine final class UserData: ObservableObject { @Published var showFavoritesOnly = false @Published var landmarks = landmarkData } |
監視可能オブジェクトは, Combine フレームワークから, ObservableObject に準拠したクラスが該当するようになります.
そして監視下で,変更に応じて全てのビューに対しての再描画を行わせたいプロパティには, @Published 属性を付与します.
これで定義はOKです.
Step 5. 監視可能オブジェクトをビューに適応させる
そうしたら,今定義したものを使いましょう.
まずプロパティとして,新たに環境オブジェクトというものを定義します.
これは,全てのビューにおいて共通して影響を与えるプロパティです.
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 | struct LandmarkList: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { Toggle(isOn: $showFavoritesOnly) { Text("Favorites only") } ForEach(landmarkData) { landmark in if !self.showFavoritesOnly || landmark.isFavorite { NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } } .navigationBarTitle(Text("Landmarks")) } } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { ForEach(["iPhone X"], id: \.self) { deviceName in LandmarkList() .previewDevice(PreviewDevice(rawValue: deviceName)) .previewDisplayName(deviceName) .environmentObject(UserData()) } } } |
そして各所修正していきます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | struct LandmarkList: View { @EnvironmentObject var userData: UserData var body: some View { NavigationView{ List { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } ForEach(userData.landmarks) { landmark in if !self.userData.showFavoritesOnly || landmark.isFavorite { NavigationLink(destination: LandmarkDetail(landmark: landmark)) { LandmarkRow(landmark: landmark) } } } } .navigationBarTitle(Text("Landmarks")) } } } |
動作させるには,まだ直す箇所があります.
「SceneDelegate.swift」のルートビューにも環境オブジェクトを指定します.
1 2 3 4 5 6 7 8 9 10 11 12 | // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController( rootView: LandmarkList() .environmentObject(UserData()) ) self.window = window window.makeKeyAndVisible() } } |
そして最後に,紐づいている「LandmarkDetail.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 | struct LandmarkDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var body: some View { VStack { MapView(coordinate: landmark.locationCoordinate) .edgesIgnoringSafeArea(.top) .frame(height: 300) CircleImage(image: landmark.image) .offset(y: -130) .padding(.bottom, -130) VStack(alignment: .leading) { Text(landmark.name) .font(.title) HStack { Text(landmark.park) .font(.subheadline) Spacer() Text(landmark.state) .font(.subheadline) } } .padding() Spacer() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { LandmarkDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } } |
環境オブジェクトは,プロパティの変更をする可能性のある全てのビューで定義してあげる必要があります.
Step 6. お気に入りボタンの追加・解除
最後に観光地詳細ビューから,お気に入りの追加と解除ができるようにしましょう.
まずは,お気に入りの追加・解除を変更するために今表示している観光地オブジェクトのインデックスを計算するプロパティを用意しておきましょう.
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 | struct LandmarkDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { $0.id == landmark.id })! } var body: some View { VStack { MapView(coordinate: landmark.locationCoordinate) .edgesIgnoringSafeArea(.top) .frame(height: 300) CircleImage(image: landmark.image) .offset(y: -130) .padding(.bottom, -130) VStack(alignment: .leading) { Text(landmark.name) .font(.title) HStack { Text(landmark.park) .font(.subheadline) Spacer() Text(landmark.state) .font(.subheadline) } } .padding() Spacer() } } } |
なぜ,わざわざインデックスからデータを検索するかというと,観光地オブジェクトが値型の構造体で定義されているからですね.
また,ここで $0 という特殊な表記が登場しますが,これについてはクロージャを参照してください.
そうしたら,ボタンを追加していきます.
場所は観光地名の右隣にしましょう.
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 | var body: some View { VStack { MapView(coordinate: landmark.locationCoordinate) .edgesIgnoringSafeArea(.top) .frame(height: 300) CircleImage(image: landmark.image) .offset(y: -130) .padding(.bottom, -130) VStack(alignment: .leading) { HStack { Text(landmark.name) .font(.title) Button(action: { // isFavoriteを切り替える self.userData.landmarks[self.landmarkIndex].isFavorite.toggle() }) { // もしお気に入りならば if self.userData.landmarks[self.landmarkIndex].isFavorite { // 塗り潰しされたスター Image(systemName: "star.fill") .foregroundColor(Color.yellow) } else { // そうでなければ白抜きスター Image(systemName: "star") .foregroundColor(Color.gray) } } } HStack { Text(landmark.park) .font(.subheadline) Spacer() Text(landmark.state) .font(.subheadline) } } .padding() Spacer() } } |
Botton(...) { } 引数のクロージャ内では,どういったスターアイコンを描画するかを決めていますが,ここでもインデックスを使ったお気に入りプロパティの取得をしていますね.
これも,先ほどの「観光地オブジェクトが値型の構造体」であることが関係しています.
この詳細ビューを描画した段階で, var landmark: Landmark プロパティはこのページだけのオブジェクトになっています.
したがって,前述の通りインデックスを使った元々のデータの修正を行っているわけですが,もしここでインデックスを使わずに landmark プロパティだけでどうにかしようとすると,リストビューに戻った段階で,お気に入りを切り替えたことは他のビューには伝わらず,何も起こっていない状態に陥ります.
Pythonなどでは全て基本参照型ですが,Swiftを扱う上では,値型と参照型に気をつけて実装しないといけませんね.
さて,以上で第3回は終わりになります.
そしてここで一区切りになります!
ここまででも,だいぶアプリとしてはそれっぽくなってきましたね!
第3回:おわりに
お疲れ様でした!
ここまでで,一つのアプリとして形にはなったので一区切りです.
第4回目以降では,「図形の描画」から始まり,「アニメーション」や「様々なビューの作成」などまだまだ盛り沢山です.
より,アプリらしくするためにもう少しだけSwiftUIチュートリアルお付き合いください.