SwiftUIのデータフローその3
「SwiftUIのデータフローその2」から時間がたってしまいましたが、データフローについて最後に@EnvironmentObjectと@Environmentを取り上げます。
WWDC2020後に更新されたドキュメントにも触れます。
※この記事ではSwift言語(少なくともSwift 5.1まで)の基本的な知識を前提にしています。
コード内のキーワードや書式などの不明点は Swift5初級ガイド などを参照してください。
※SwiftUIについて全体的なことは『SwiftUI最初の一歩』、コードの基本とビューについては『SwiftUIの文法 その1 View』を参照してください。
お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。サンプルは無料です。MacでもiPadでもiPhoneでも読めます。
WWDC2020で発表されたSwift 5.3に対応した第6版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
(2020年7月4日に第6版にアップデートしました)
ブックストアから一度購入すると今後のアップデートは無料で読めます。
・・・
・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は左右にスクロールできます
サンプルはXcode 11.5のPlaygroundで作成し Xcode 11.6 とPlaygrounds 3.3.1 で動作を確認しました。
サンプルはすべてXcodeのiOS用プレイグラウンド書類用コードです。(iPadやMacのPlaygrounds 3.3でも直接入力し実行できます)
この記事の最後(有料部分)にあるリンクから完全なサンプルをダウンロードできます。
1 SwiftUIのドキュメント
WWDC2020以降にAppleデベロッパーサイトのSwiftUIの説明が少し追加になっていました。
英文ですがそれほど長文でもなく、どれも目を通しておくべき内容です。
「State and Data Flow」はOverviewが少し詳しくなり概念を示す図も追加され、必要なタスク別に適したツールがまとめられ各ツールへリンクしています。
「Managing User Interface State」SwiftUI のビューと表示データの関係を詳しく説明しています。
state属性が必要な場合と不要な場合、状態が変化した場合に表示もアニメーションを利用するコードなども具体的に説明しています。
「Managing Model Data in Your App」アプリのデータモデルとビューの関係の概要説明が追加になっています。
ちょうど今回の範囲に関連する内容です。
概要は
・モデル内のデータ変更をビューから見えるようにするには、モデルのクラスにObservableObjectプロトコルを採用する
・変更した場合に再表示が必要なプロパティを@Published属性にする
・モデルの変更を監視するにはビューで@ObservedObject属性のモデルのプロパティを追加しイニシャライザでインスタンスを渡す
・またはビューで@EnvironmentObject 属性のモデルのプロパティを追加しenvironmentObject(_:)メソッドでビュー階層全体にモデルを設定する
2019年にSwiftUIが登場しましたがドキュメントは不足したままでした。
これらのドキュメントでやっとデータフロー関連について最小限の概要は公式ドキュメントで解説されました(昨年の段階であってしかるべき内容と思います)。
ただしSwiftらしいコードに慣れている前提、かつプロパティラッパーなども理解している前提で書かれています。
注意点:Xcode 12のSDK(iOS14以降)で利用可能なSwiftUIの新機能が書かれている部分もあるので、新機能が実際にリリースされるまでは注意が必要です。Playgrounds 3.3.1ではまだ使えないプロパティなども書かれています。
2 今回のサンプル
今回サンプルは「SwiftUIのデータフローその2」で使ったカウントダウンタイマーを改良し、Apple Watchのタイマーのような動作をSwiftUIで実現します。
この画面はwatchOS 4.3.2のものです
残り時間の表示を兼ねたボタンで設定画面を表示しスタートや一時停止をおこないます。
タイマーなどのしくみと動作は「SwiftUIのデータフローその2」の解説を参照してください。
今回のサンプルをiPadで実行した例(カウントダウンは倍速の時短実行です)(音声なしの動画)です:
2-1 時間表示
今回は1分以上のカウントダウンも可能にするので、タイマーらしく 00:00 表示にしました。
"1"ではなく"01"と桁数を固定してゼロを表示方法は昔ながらのロジックを含めていくつかあると思いますが、簡単に実現するやりかたを『Swift逆引きハンドブック』で調べてみました。
「文字列化するときに桁数を指定するには」にそのものずばりの方法が書かれていて今回のコードではそれを使っています。
ただし今回は59分59秒まで表示の簡易実装です。
コードと解説は「6 残り時間表示」をご覧ください。
mosa仲間の林さんの著書『Swift逆引きハンドブック』はSwift2時代の本ですが、Swift 3に対応するための修正方法(かなり膨大です)も公開されていますし、今回のように現在でも役立ちます。
3 @EnvironmentObject属性
EnvironmentObject属性も@ObservedObject属性と同じく、モデルに変更があった場合にビューを更新するためのしくみです。
ObservedObjectがビューイニシャライザの引数で受け渡す必要があったのに比べ、一度の指定で表示中のビューで利用でき実用的です。
モデルの受け渡しにはビューの environmentObject(_:) メソッドを使い、ビューイニシャライザの引数は使いません。
モデルの型はclassを使いObservableObjectプロトコルに準拠させるところもObservedObjectと同じです。
つまりObservedObject属性をEnvironmentObject属性に変更して利用できます。
・モデル内のデータ変更をビューから見えるようにするには、モデルのクラスにObservableObjectプロトコルを採用する
・変更した場合に再表示が必要なプロパティを@Published属性にする
この二つは変わりません。
このためモデルのクラスは変更なしにそのまま使うことができます。
EnvironmentObject属性を使ったサンプルです。
// 07-01 SwiftUI版カウントダウン
import SwiftUI
import PlaygroundSupport
/// タイマーの動作間隔【単位:秒】
/// + 結果を早く確認するためテスト中は0.5秒とする
let interval = 0.5
/// カウントダウンの初期値
let startValue = 10
// モデル
class CountDounTimer: ObservableObject {
/// カウンターそのもの 変化した場合に再表示
@Published var counter = startValue
/// スタートボタンに表示するタイトル文字列
@Published var startButtonTitle = "スタート"
/// スタートボタンをディスエイブルにするフラグ
@Published var startButtonDisable = false
/// カウントダウン中フラグ
var nowCounting = false
/// Timerインスタンス
var timer: Timer?
/// カウントダウンボタン処理(一時停止を兼ねている)
func start() {
if 0 < counter {
if timer == nil {
countDounStart()
}
else {
nowCounting.toggle()
}
}
else {
// 何もしない
}
setTitle()
}
/// 実際のスタート処理
/// + タイマーの繰り返し処理を含む
/// + 指定間隔でcounterの値をマイナス1する
/// + counterがゼロになったらタイマーの繰り返し処理を止める
private func countDounStart() {
nowCounting = true
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: {_ in
if self.nowCounting {
self.counter -= 1
}
if 0 >= self.counter {
self.timer?.invalidate()
self.timer = nil
self.nowCounting = false
self.startButtonDisable = true
}
})
}
/// スタートボタンに表示するタイトル文字列を適切に設定する
private func setTitle() {
if nowCounting {
startButtonTitle = "一時停止"
}
else {
startButtonTitle = "スタート"
}
}
/// リセット処理
func reset() {
counter = startValue
startButtonDisable = false
setTitle()
}
}
// ビュー
/// SwiftUIのビュー
struct Sample07View: View {
/// CountDounTimerインスタンス
@EnvironmentObject var cd: CountDounTimer
var body: some View {
VStack {
Text("\(cd.counter)")
.font(Font.largeTitle.monospacedDigit())
.fontWeight(.black)
.padding()
Button(action: {
self.cd.start()
}) {
Text(self.cd.startButtonTitle)
}
.disabled(cd.startButtonDisable)
.padding()
Button(action: {
self.cd.reset()
}) {
Text("リセット")
}
.padding()
}
.font(.title)
}
}
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
Sample07View().environmentObject(CountDounTimer())
)
iPadのPlaygrounds 3.3.1で実行した画面です:
一番最後のライブビューにSwiftUIのビューを渡す部分でenvironmentObject(_:) メソッドを使いモデルのインスタンスを設定しています。
このコードでObservedObjectを使った「SwiftUIのデータフローその2」のサンプルと同じ実行結果になります。
3-1 environmentObject(_:) メソッド
environmentObject(_:)メソッドは直接指定したビューと、そこに含まれるビューにObservableObject準拠のモデルを供給します。
ビューでは @EnvironmentObject 属性を付けたプロパティで供給されたオブジェクトを使うことができます。
func environmentObject<B>(_ bindable: B) -> some View where B : ObservableObject
引数bindableはObservableObjectプロトコル準拠が求められますが型は任意です。
environmentObject(_:)メソッドはfont(_:)メソッドと働きが似ています。
直接メソッドを指定したビューとそのビューに配置されているビューに効果が及びます。
なお environmentObject(_:)メソッドでモデルを渡さないとビューの実行時にエラーが発生します。
3-2 深い階層のビュー
environmentObject(_:)メソッドの指定が一度だけで、ビュー階層内のどのビューでもモデルを引き渡せ動作することを確認するサンプルです。
class CountDounTimerModel: ObservableObject { ... }はまったく同じなので省略しています。
タイマーの表示とスタートなどの操作を行うビューを TimerView としました。
// ビュー
/// タイマーの表示と操作
struct TimerView: View {
/// CountDounTimerインスタンス
/// + environmentObject(_:) メソッドでを設定する
@EnvironmentObject var cd: CountDounTimerModel
var body: some View {
VStack {
Text("\(cd.counter)")
.font(Font.largeTitle.monospacedDigit())
.fontWeight(.black)
.padding()
Button(action: {
self.cd.start()
}) {
Text(self.cd.startButtonTitle)
}
.disabled(cd.startButtonDisable)
.padding()
Button(action: {
self.cd.reset()
}) {
Text("リセット")
}
.padding()
}
.font(.title)
}
}
// TimerViewだけを表示するビュー environmentObjectを指定しなくても動作する
struct Sample07View: View {
var body: some View {
TimerView()
}
}
このコードは実行しても最初のサンプルとまったく同じように動作するはずです。
(iPadでの実行画面は割愛します)
Sample07Viewでは @EnvironmentObject var cd: CountDounTimerModel プロパティがありませんが
PlaygroundPage.current.setLiveView(
Sample07View().environmentObject(countDounTimer())
)
の部分でenvironmentObjectを渡しています(このビューに含まれるビューすべてに渡しています)。
ひとつ深い階層のビュー(struct TimerView)だけが @EnvironmentObject var cd: CountDounTimerModel プロパティを持っています。
struct Sample07View では TimerView() 部分にenvironmentObject(_:)メソッドを指定していませんが同じように動作します。
このため実際のアプリのような多数のビューを持つ複雑な場合も必要なモデルデータを供給できます。
4 ポップオーバーの利用
次にWatchのようにカウントダウンの表示を兼ねたボタンを作りましょう。
Playgroundsではポップオーバーでスタートボタンなどを含むビューを表示して操作するようにします。
表示はこのようにカウントダウンの値とアイコンを表示します。
アイコンはSF Symblolsを使うとこのように「タイマー」であることが確実にわかります。
表示はWatchのイメージに合わせてタイトルバーのボタンのようにしています。
ボタンと表示を兼ねたビューなのでどこにでもレイアウトできます。
画面本体には例えば料理のレシピを表示するなどが想定できます。
この部分のコード
/// タイマーポップオーバーを開くボタン
struct TimerButton: View {
@EnvironmentObject var cd: CountDounTimerModel
@State private var showTimerPopover = false
var body: some View {
HStack {
Text("\(cd.counter)") // 残り時間を常時表示
Image(systemName: "timer") // タイマーアイコン
}
.font(Font.title.monospacedDigit())
.onTapGesture { self.showTimerPopover = true }
.popover(isPresented: $showTimerPopover) {
TimerView(isShow: self.$showTimerPopover)
.environmentObject(self.cd)
}
}
}
ポップオーバーの表示は「SwiftUIの画面切替」の「5 ポップオーバーとシート」の説明を参照してください。
ここではボタンではなくHStack のonTapGestureメソッドでポップオーバーを開くためのフラグをセットしています。
クロージャ内なので self.showTimerPopover = true としています。
WWDCで発表されましたが、Xcode 12以降ではstructならselfの指定は不要になるそうです。
現在の Xcode 11.6 ではself.が必要です。
ここではXcodeのPlaygroundで実行した場合のため(シートで表示される)に閉じるボタンを追加しています。
閉じるボタンはshowTimerPopoverのバイディングをポップオーバーのボタンでfalseにして実現しています。
ポップオーバーで表示するのは基本的に TimerView そのままで、バインディングを追加しただけです。
モデルのコードを省略したサンプルコード:
/// タイマーのスタート/停止と表示ビュー
/// + ポップオーバー/シート表示対応番
/// + イニシャライザの引数で閉じるためのバインディングを渡す
struct TimerView: View {
@Binding var isShow: Bool
/// countDounTimerインスタンス
/// + environmentObject(_:) メソッドでを設定する
@EnvironmentObject var cd: CountDounTimerModel
var body: some View {
VStack {
// 閉じるボタン
HStack {
Spacer()
Button(action: { self.isShow = false }) {
Text("閉じる")
.padding()
}
}
Text("\(cd.counter)")
.font(Font.largeTitle.monospacedDigit())
.fontWeight(.black)
.padding()
Button(action: {
self.cd.start()
}) {
Text(self.cd.startButtonTitle)
}
.disabled(cd.startButtonDisable)
.padding()
Button(action: {
self.cd.reset()
}) {
Text("リセット")
}
.padding()
}
.font(.title)
}
}
/// タイマーポップオーバーを開くボタン
struct TimerButton: View {
@EnvironmentObject var cd: CountDounTimerModel
@State private var showTimerPopover = false
var body: some View {
HStack {
Text("\(cd.counter)") // 残り時間を常時表示
Image(systemName: "timer") // タイマーアイコン
}
.font(Font.title.monospacedDigit())
.onTapGesture { self.showTimerPopover = true }
.popover(isPresented: $showTimerPopover) {
TimerView(isShow: self.$showTimerPopover)
.environmentObject(self.cd)
}
}
}
/// TimerButtonをレイアウト
struct Sample07View: View {
var body: some View {
VStack {
HStack {
Spacer()
TimerButton().padding()
}
Spacer()
}
}
}
このコードをiPadのPlaygroundsで実行しポップオーバーを表示した状態:
ポップオーバーの「スタート」と「リセット」ボタンはそのまま動作します。
スタートするとポップオーバーのカウントダウン表示とTimerButtonのカウントダウン表示が同時にカウントダウンします。
同じデータモデルを参照しているSwiftUIビューの特徴です。
もちろんポップオーバーを閉じてもカウントダウンを続行し、もう一度TimerButtonボタンをタップするとポップオーバーを表示します。
これはもちろん同じデータモデルのカウントダウン値を表示しているので、自動で(複数あればすべて)更新している結果です。
今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。