【第7回】日本語版SwiftUIチュートリアル【UIコントロールの動作】
日本語版SwiftUIチュートリアル
本チュートリアルは,Appleが掲載している公式のSwiftUIチュートリアルを「日本語で」そして「詳細に」解説し直すことを目的とした記事です.
この手の記事は,他にも似たようなものがありますが,全てを網羅しているわけではなく,部分的に解説している記事が多数です.
そこで,SwiftUIを使ってアプリ開発をしたい方々のために,そして勉強している自分自身のために,日本語版SwiftUIチュートリアルの完全版を書きます.
なお,本記事の難易度としては,初級者も見様見真似で作れるようにはなっていますが,それでは物足りないという中級者のために,細かい文法的な解説も随所に入れていこうと思います.
注意として,本記事はSwiftの言語解説はあまり含まないので,以下の記事を別タブで開いておいて,分からなければ逐一確認してみると良いと思います.
ちなみに出来上がるアプリは,上図に示すような観光地アプリです.
各観光地の位置情報を確認したり,その観光地をお気に入りに追加したりできるアプリを目指します.
第1回は????
第7回:UIコントロールの動作
第7回では,第4回から作成していたものを用いて,ユーザのプロフィール情報を変更・管理するビューを作成していきます.
この回では,プロフィール画面からユーザのプロフィールをUIコントロールを用いて簡易的に変更できるようにします.
ついに全てのビューがここで繋がりますので,頑張っていきましょう.
Step 1. ユーザプロフィールを表示する
まずは,新たに「Profiles」というグループを作成しましょう.
階層は「Hikes」などと同じで構いません.
さらに,「Profile.swift」と言う名のプロフィール情報を管理する構造体を作成していきます.
この構造体では,ユーザ名,通知ON/OFF,季節の写真,ゴールした日,デフォルトのプロフィールを管理します.
したがって,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import Foundation struct Profile { var username: String var prefersNotifications = true var seasonalPhoto = Season.winter var goalDate = Date() // defaultはswicth-case構文で予約された単語なので通常変数名などでは使えない. // もし変数名としてどうしても扱いたい場合は, ` `で囲む static let `default` = Profile(username: "g_kumar") enum Season: String, CaseIterable, Identifiable { case spring = "????" case summer = "????" case autumn = "????" case winter = "☃️" var id: String { self.rawValue } } } |
ひとまずこのような形にしましょう.
1 | static let `default` = Profile(username: "g_kumar") |
この部分,自身の構造体のインスタンスを内部でプロパティとして保持していますが,これはシングルトンというデザインパターンの一種です.
シングルトンは一言で表せば「インスタンス(オブジェクト)を一つしか持たない構造体やクラス」を指します.
プロフィール情報は各ユーザで,それぞれ複数のインスタンスを生成する必要がありませんので,このように制約をかけるのです.
また,Swiftではシングルトンは通常, static を用いて定義されます.
さて,この構造体を用いて実際にビューを作成していきます.
まず動作確認がてら,Profileを管理する「ProfileHost.swift」を作ってコーディングしていきましょう.
ひとまず以下の形を,ベースとしておきます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import SwiftUI struct ProfileHost: View { @State private var draftProfile = Profile.default var body: some View { Text("Profile for: \(draftProfile.username)") } } struct ProfileHost_Previews: PreviewProvider { static var previews: some View { ProfileHost() } } |
うまく動作していますね.
次に,プロフィール詳細を管理するためのビュー「ProfileSummary.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 | import SwiftUI struct ProfileSummary: View { var profile: Profile var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { Text(profile.username) .bold() .font(.title) Text("Notifications: \(profile.prefersNotifications ? "On": "Off" )") Text("Seasonal Photos: \(profile.seasonalPhoto.rawValue)") Text("Goal Date: ") + Text(profile.goalDate, style: .date) } } } } struct ProfileSummary_Previews: PreviewProvider { static var previews: some View { ProfileSummary(profile: Profile.default) } } |
こちらもこのような感じにしておきましょう.
特筆すべきことはありません.
これを,ProfileHostから呼び出します.
1 2 3 4 5 6 7 8 9 10 11 12 | import SwiftUI struct ProfileHost: View { @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { ProfileSummary(profile: draftProfile) } .padding() } } |
これも問題なく動作するはずです.
さて次に,Hikes/ディレクトリに新たに「HikeBadge.swift」を作成してください.
前に作成したBadgeを使って,プロフィールに獲得したバッジを並べてみましょう!
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 HikeBadge: View { var name: String // バッジ名 var body: some View { VStack(alignment: .center) { Badge() .frame(width: 300, height: 300) .scaleEffect(1.0 / 3.0) .frame(width: 100, height: 100) Text(name) .font(.caption) .accessibilityLabel("Badge for \(name).") } } } struct HikeBadge_Previews: PreviewProvider { static var previews: some View { HikeBadge(name: "Preview Testing") } } |
次に,これをProfileSummaryで並べてみましょう.
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 | import SwiftUI struct ProfileSummary: View { var profile: Profile var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { Text(profile.username) .bold() .font(.title) Text("Notifications: \(profile.prefersNotifications ? "On": "Off" )") Text("Seasonal Photos: \(profile.seasonalPhoto.rawValue)") Text("Goal Date: ") + Text(profile.goalDate, style: .date) Divider() // 水平線で区切る VStack(alignment: .leading) { Text("Completed Badges") .font(.headline) ScrollView(.horizontal) { HStack { HikeBadge(name: "First Hike") HikeBadge(name: "Earth Day") .hueRotation(Angle(degrees: 90)) // 色相環を90度回転させる HikeBadge(name: "Tenth Hike") .grayscale(0.5) .hueRotation(Angle(degrees: 45)) // 色相環を45度回転させる } .padding(.bottom) } } } } } } |
段々クールなビューに仕上がってきましたね.
さらに,Hikeの情報も盛り込んでいきましょう!
まずは hikeData を監視可能をオブジェクトとします.
1 2 3 4 5 6 7 8 9 10 | import SwiftUI import Combine final class UserData: ObservableObject { @Published var showFavoritesOnly = false @Published var landmarks = landmarkData @Published var hikes = hikeData @Published var features = featuresData @Published var categories = categoriesData } |
あとは,ProfileSummaryで呼び出すだけです.
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 | mport SwiftUI struct ProfileSummary: View { @EnvironmentObject var userData: UserData var profile: Profile var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { Text(profile.username) .bold() .font(.title) Text("Notifications: \(profile.prefersNotifications ? "On": "Off" )") Text("Seasonal Photos: \(profile.seasonalPhoto.rawValue)") Text("Goal Date: ") + Text(profile.goalDate, style: .date) Divider() // 水平線で区切る VStack(alignment: .leading) { Text("Completed Badges") .font(.headline) ScrollView(.horizontal) { HStack { HikeBadge(name: "First Hike") HikeBadge(name: "Earth Day") .hueRotation(Angle(degrees: 90)) // 色相環を90度回転させる HikeBadge(name: "Tenth Hike") .grayscale(0.5) .hueRotation(Angle(degrees: 45)) // 色相環を45度回転させる } .padding(.bottom) } } Divider() VStack(alignment: .leading) { Text("Recent Hikes") .font(.headline) HikeView(hike: userData.hikes[0]) } } } } } struct ProfileSummary_Previews: PreviewProvider { static var previews: some View { ProfileSummary(profile: Profile.default) .environmentObject(UserData()) } } |
ここまでくると,段々勝手がわかってきますね.
本ステップ最後に,CategoryHomeからこのプロフィールにアクセスできるようにしましょう.
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 CategoryHome: View { @EnvironmentObject var userData: UserData @State private var showingProfile = false 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") .toolbar { // NavigationのToolbarにビューを追加 Button(action: { showingProfile.toggle() }) { Image(systemName: "person.crop.circle") .accessibilityLabel("User Profile") } } .sheet(isPresented: $showingProfile) { // showingProfile == trueならばシートを開く ProfileHost() .environmentObject(userData) } } } } |
プロフィールの表示はシート(Sheet)と呼ばれるビューを用いています.
下からビューがニョキっと覆い被さるビューです.
仕上げに,リストの余白を整えればひとまず完成です.
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 | import SwiftUI struct CategoryHome: View { @EnvironmentObject var userData: UserData @State private var showingProfile = false 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()) } .listStyle(InsetListStyle()) .navigationTitle("Featured") .toolbar { // NavigationのToolbarにビューを追加 Button(action: { showingProfile.toggle() }) { Image(systemName: "person.crop.circle") .accessibilityLabel("User Profile") } } .sheet(isPresented: $showingProfile) { // showingProfile == trueならばシートを開く ProfileHost() .environmentObject(userData) } } } } |
Step 2. 編集モードを追加する
次に,ユーザのプロフィールを編集できるようにしていきましょう.
ここでは,簡易的に「ユーザ名」「季節の写真」を編集できる機能を実装していきます.
早速「ProfileHost.swift」を編集していきますが,今の段階ではプレビューが動作しません.
これは,子ビューである「ProfileSummary」で環境オブジェクトが使用されているからです.
なので,以下のようにProfileHostでも環境オブジェクトを渡してあげましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct ProfileHost: View { @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { ProfileSummary(profile: draftProfile) } .padding() } } struct ProfileHost_Previews: PreviewProvider { static var previews: some View { ProfileHost() .environmentObject(UserData()) } } |
これで,再びプレビューが利用できるようになるはずです.
さて本題に移りますが,SwiftUIではあらかじめ「編集モード (EditMode)」という機能が実装されています.
これはその名の通り,もともとのビューと編集ビューを切り替えるために用意されています.
まずは見様見真似で実装してみましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 | import SwiftUI struct ProfileHost: View { @Environment(\.editMode) var editMode @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { ProfileSummary(profile: draftProfile) } .padding() } } |
最初に,編集モードか否かを保持するプロパティ editMode を定義しました.
\.editMode はKeyPathで,名前がプロパティ名と同じでややこしいですが,これとは別です.
そうしたら,編集ボタンもあらかじめ用意されているのでビューに追加しましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import SwiftUI struct ProfileHost: View { @Environment(\.editMode) var editMode @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { Spacer() EditButton() } ProfileSummary(profile: draftProfile) } .padding() } } |
右上に編集ボタンが現れました.
まだ押してもビューは何も変わりません.
今から編集ビューを実装していくのですが,ユーザプロフィールが環境オブジェクトにないため編集したとしても,その内容が他のビューに共有されません.
そこで,あらかじめUserDataにプロフィールデータを追加しておきましょう.
1 2 3 4 5 6 7 8 9 10 11 | import SwiftUI import Combine final class UserData: ObservableObject { @Published var showFavoritesOnly = false @Published var landmarks = landmarkData @Published var hikes = hikeData @Published var features = featuresData @Published var categories = categoriesData @Published var profile = Profile.default } |
そうしたら,ProfileHostを以下のように修正します.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct ProfileHost: View { @Environment(\.editMode) var editMode @EnvironmentObject var userData: UserData // @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { Spacer() EditButton() } ProfileSummary(profile: userData.profile) } .padding() } } |
ここまでは,プレビューになんら変化はないです.
ここで編集ビューはまだ作成していませんが,編集モード切り替えがうまく動作するかだけ確認してみましょう.
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 ProfileHost: View { @Environment(\.editMode) var editMode @EnvironmentObject var userData: UserData // @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { Spacer() EditButton() } if editMode?.wrappedValue == .inactive { ProfileSummary(profile: userData.profile) } else { Text("Profile Editor") } } .padding() } } |
これで「Edit」ボタンを押すことでビューの切り替えができるはずです(まだテキストだけの質素なビューですが...).
また,一旦 draftProfile は置いておきます (別にコメントアウトはしなくても良いです).
...
さて, editMode?.wrappedValue は,Optional型である editMode をアンラップしているわけですが, ? (はてな/クエスチョンマーク)によるアンラップは初めましての方も多いでしょう.
通常,強制的なアンラップでは ! を用いることがありますが,これを利用した場合, nil が出たときにランタイムエラーが出力されアプリがクラッシュします.
そこで, ? を使ったアンラップをするわけですが,これをOptional Chainingと呼んでいます.
Optional Chainingの場合はランタイムエラーは発生しません.
公式のドキュメント「Optional Chaining」では,Optional Chainingで nil が出た時は「fails gracefully (潔く失敗する)」と書かれていますね.
まあこれはともかく, editMode は列挙型なので .wrappedValue で,その値を取り出していることを意味しています.
それが, .inactive (アクティブでない) ならばProfileSummaryを表示する,そのようなコードになっています.
Step 3. 編集ビューを定義する
そうしたら最後に,編集ビューを作成していきましょう!
編集ビューは,ユーザデータを編集するためのものですが,もちろん編集されてはいけないものもあります(例えばバッジとか).
そういったもの以外を編集できるようなビューを構築していきます.
まずは新たに「ProfileEditor.swift」を作成しましょう.
初期は以下のようなコードにしておきます.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import SwiftUI struct ProfileEditor: View { @Binding var profile: Profile var body: some View { List { HStack { Text("Username").bold() Divider() TextField("Username", text: $profile.username) } } } } struct ProfileEditor_Previews: PreviewProvider { static var previews: some View { ProfileEditor(profile: .constant(.default)) } } |
ここで, @Binding が初登場ですが,これは @State と同じく「ビューを超えて変更を通知する」ためのPropatyWrapperです.
基本的には2つとも同じような挙動をとるのですが,違いはなんでしょう?
違いは親ビューで定義された @State プロパティは外部ビューからのアクセスを基本的に受け付けないという点です( private 修飾子を付けるのを推奨しているため).
しかし,子ビューにおいても同じくそのプロパティの値を直接変更したい場合がよくあります.
そのようなときに, @Binding プロパティとして定義してあげるのです.
要するに,親ビューで定義された @State プロパティを子ビューで扱うためには, @Binding プロパティとして定義する,ということです.
また,前にも登場しましたが, @State および @Binding プロパティの値を編集するときには, $ を付けてあげましょう.
そうしたら,ProfileHostに戻り今作成した編集ビューを紐付けます.
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 ProfileHost: View { @Environment(\.editMode) var editMode @EnvironmentObject var userData: UserData @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { Spacer() EditButton() } if editMode?.wrappedValue == .inactive { ProfileSummary(profile: userData.profile) } else { ProfileEditor(profile: $draftProfile) } } .padding() } } |
ここで, draftProfile を渡す形にしておきます.
今の段階では,編集ビューで編集したユーザ名の反映はまだされませんが,ビューの切り替えがうまくいっていることを確認してください.
また,ProfileEditorに戻ります.
次に,観光地に関するイベント通知を行うか否か,を切り替えるトグルスイッチを追加してみましょう.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import SwiftUI struct ProfileEditor: View { @Binding var profile: Profile var body: some View { List { HStack { Text("Username").bold() Divider() TextField("Username", text: $profile.username) } Toggle(isOn: $profile.prefersNotifications) { Text("Enable Notifications").bold() } } } } |
続いて,季節の写真を選ぶための,Pickerコントロールを配置します.
ここで列挙型の全ケースを取得する方法も覚えておきましょう.
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 | import SwiftUI struct ProfileEditor: View { @Binding var profile: Profile var body: some View { List { HStack { Text("Username").bold() Divider() TextField("Username", text: $profile.username) } Toggle(isOn: $profile.prefersNotifications) { Text("Enable Notifications").bold() } VStack(alignment: .leading, spacing: 20) { // 左よせ Text("Seasonal Photo").bold() Picker("Seasonal Photo", selection: $profile.seasonalPhoto) { // Pickerコントロール ForEach(Profile.Season.allCases) { season in // 列挙型の全ケースを取得し表示 Text(season.rawValue).tag(season) // .tag()で選ばれているものを強調 } } .pickerStyle(SegmentedPickerStyle()) // 横並びのPickerスタイル } } } } |
ちなみに, .pickerStyle(SegmentedPickerStyle()) を記述しないと,デフォルトの縦型ロールのPickerスタイルになります.
最後に,DatePicker (日付選択コントロール) を追加して,観光地訪問の目標日付を選択できるようにしましょう.
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 ProfileEditor: View { @Binding var profile: Profile var dateRange: ClosedRange<Date> { // 前後1年間のカレンダーデータを取得するComputed propaty let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)! let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)! return min...max } var body: some View { List { HStack { Text("Username").bold() Divider() TextField("Username", text: $profile.username) } Toggle(isOn: $profile.prefersNotifications) { Text("Enable Notifications").bold() } VStack(alignment: .leading, spacing: 20) { Text("Seasonal Photo").bold() Picker("Seasonal Photo", selection: $profile.seasonalPhoto) { ForEach(Profile.Season.allCases) { season in Text(season.rawValue).tag(season) } } .pickerStyle(SegmentedPickerStyle()) } DatePicker(selection: $profile.goalDate, in: dateRange, displayedComponents: .date) { Text("Goal Date").bold() } } } } |
Swiftの Calendar の使い方も直感的でわかりやすいですね.
ちなみに, a..<b はRangeオブジェクト, a...b はClosedRangeオブジェクトになります.
Countableか否かもRangeオブジェクトには存在しますが,そのあたりはこれらの単語で調べてみると良いでしょう.
今回は割愛します.
うまく実装できていれば,下図のようにカレンダーで日付設定ができるようになっているはずです.
Step 4. 編集の伝達を遅らせる
第7回最後のステップです.
ユーザの編集中には,その内容変更を他のビューに伝達しないようにする必要があります.
もし,途中で編集内容を破棄 (Cancel) したくなったら?
そのようなときは,編集内容を確定するまで,下書き保存に割り当てておき,編集が確定したら本物のコピーを取るようにすれば良いのです.
まず最初に,ProfileHostのEditボタンの横に,新たに「Cancel」ボタンを追加しましょう.
そのときに, draftProfile (下書きプロフィール) を利用して,編集前のデータを保持しておきます.
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 | import SwiftUI struct ProfileHost: View { @Environment(\.editMode) var editMode @EnvironmentObject var userData: UserData @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { if editMode?.wrappedValue == .active { Button("Cancel") { draftProfile = userData.profile editMode?.animation().wrappedValue = .inactive } } Spacer() EditButton() } if editMode?.wrappedValue == .inactive { ProfileSummary(profile: userData.profile) } else { ProfileEditor(profile: $draftProfile) } } .padding() } } |
最後に,編集した内容を更新するようにすれば編集機能が全てうまく動作するはずです.
このとき, onAppear(perform:) 修飾子と onDisappear(perform:) 修飾子を利用します.
編集モードで「Done」ボタンを押したときには前者,「Cancel」ボタンのときは後者が反応します.
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 ProfileHost: View { @Environment(\.editMode) var editMode @EnvironmentObject var userData: UserData @State private var draftProfile = Profile.default var body: some View { VStack(alignment: .leading, spacing: 20) { HStack { if editMode?.wrappedValue == .active { Button("Cancel") { draftProfile = userData.profile editMode?.animation().wrappedValue = .inactive } } Spacer() EditButton() } if editMode?.wrappedValue == .inactive { ProfileSummary(profile: userData.profile) } else { ProfileEditor(profile: $draftProfile) .onAppear { // Done draftProfile = userData.profile } .onDisappear { // Cancel userData.profile = draftProfile } } } .padding() } } |
うまく編集が同期できましたか?
Done/Cancelどちらもうまく動作するか確認してみてください.
第7回:おわりに
お疲れ様でした.
いよいよ本チュートリアルも終盤に入ります.
あとは,UIをもう少し整えて完成させるだけですが,Apple watchOSやmacOSにも対応させるにはどうすれば良いのかなどもチュートリアルに含まれています.
残すところ,あと3回頑張っていきましょう!