SwiftUIのデータフローその2
タイマーで繰り返しカウントダウンするサンプルでコンバインのしくみを説明します。
ビューではなく外部からカウントダウンと同時にボタンタイトルを変更したり、ディスエイブルにするサンプルで確認しましょう。
※この記事ではSwift言語(最新の5.1)の基本的な知識を前提にしています。コード内のキーワードや書式などの不明点は Swift5初級ガイドなどを参照してください。
※SwiftUIについて全体的なことは『SwiftUI最初の一歩』、コードの基本とビューについては『SwiftUIの文法 その1 View』を参照してください
毎月札幌でiOSアプリ作りをアシストするセミナーをやっています。1時間にわたるセミナーの全内容を、物理的に参加できない方のためにnote上で公開します。
お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。サンプルは無料です。MacでもiPadでもiPhoneでも読めます。
WWDC2020で発表されたSwift 5.3に対応した第6版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
ブックストアから一度購入すると今後のアップデートは無料で読めます。
iOSアプリ作りをアシストするセミナーは今後も月一回のペースで続ける予定です。(2020年3月以降COVID-19感染拡大防止のため休止しています)
詳細は connpass.com の 札幌Swift でご確認ください。そして機会があればぜひ参加してください。
アプリ作りやプログラミング教育に関連する話題は 札幌Swift のfacebookページ で発信しています。
・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は左右にスクロールできます
Xcode は 11.3.1 を使っていています。
サンプルはすべてXcodeのiOS用プレイグラウンド書類用コードです。(iPadやMacのPlaygrounds 3.2でも直接入力し実行できます)
この記事の最後の有料部分にあるリンクから完全なサンプルをダウンロードできます。
1 SwiftUIのデータフローの復習
最初に『SwiftUIデータフロー入門』の内容をおさらいしましょう。
SwiftUIのビューはstructです。
UIKitなどのビューはclassでした。
SwiftUIではstructの特質(classとはいろいろ違いあり)を@State属性と@Binding属性で対処し、さらに状態管理のバグを発生させないしくみとしています。
1-1 SwiftUIのビューの状態は @State属性の変数で扱う
SwiftUIではトグルスイッチの状態や、入力した文字列は@State属性の変数で扱います。
@State属性なしのvarで変数宣言してもビューのbody内でその変数を変更することはできません。
1-2 @State属性の変数が変化するとビューを再表示する
@State属性の変数(プロパティ)が、画面操作や文字入力で変化すると自動でそのプロパティが属するビューが再表示される仕組みです。
1-3 @Stateはプロパティラッパーの仕組みを使っている
@Stateや@BindingはSwiftのプロパティラッパーの仕組みを使ってSwiftUIフレームワーク内で定義されています。
(Swift言語が持つ機能ではなくフレームワークが持つ属性です)
プロパティラッパーは属性の機能を型として実装します。
このためSwiftUIドキュメントには@StateはState型として載っています。
型なので大文字からはじまっています。
大文字ではじまる属性はプロパティラッパーで実装されたプライベートな(Swift言語がもともと持つ機能ではないの意味)属性です。
このため @State や @Binding はswift.orgが公開している公式ドキュメントには載っていません。
Stateは型としてSwiftUIフレームワークのドキュメントに載っています。
@propertyWrapper はSwift言語の機能なのでSwiftUIフレームワークには載っていません。swift.orgのドキュメントに載っています。
プロパティラッパーを適切に使うことで繰り返しをラップし、シンプルにコードを書くことが可能になります。
1-4 別のビューから@Stateのプロパティ を参照・変更するしくみが@Binding
@Binding属性は状態を所有せずに利用するためのしくみです。
@State属性のプロパティを$記号に続けて書くとバインディングが得られます。
$記号はバインディングに変換するのではなく、プロパティラッパーを使って公開している型のprojectedValueを利用するための書式です。
@State(プロパティラッパー)のprojectedValueはバインディングを返します。
@Binding属性のプロパティは@State属性のプロパティの値(状態)を参照できます。
@Binding属性のプロパティを代入などで変更すると、対応する@State属性のプロパティの値(状態)が変更した値になります。
同時に(状態が変わったので)ビューの再表示が実行されます。
1-5 @Bindingもプロパティラッパーを使っている
@Bindingもプロパティラッパーを使ってSwiftUIフレームワークに組み込まれています。
ドキュメントにはBinding型で載っています。
@Binding属性のプロパティのprojectedValueはバインディングを返します。
このしくみで、状態をさらに引き渡して利用できます。
1-6 @Stateと@Bindingで状態管理のバグをなくせる
@Stateと@Bindingを使うと状態を同期させるコードは不要になります。
状態同期のためのコードはプロパティラッパーが隠してくれています。
たくさん使われバグも枯れていることが期待できます。
このしくみでコードがシンプルになり、バグも入り込みません。
以上がコントロール類を含むビューの状態管理(データフロー)の概要でした。
2 ビュー以外から更新するしくみ
WWDCのデータフローのセッションでは次の図で説明がありました。
利用者による操作が状態(State)の変化をもたらし、それによりビューが更新され、利用者が認識するループです。
このセッションでは次の図も登場しました。
今度は利用者の操作だけでなく、タイマーや通知も反映させ状態を変化させます。
Combine(コンバイン)を使った新しいしくみです。
Combineは独立したフレームワークの名称でもあります。
いろいろ難解なのでここではサンプルに使う範囲だけ説明します。
2-1 サンプルはカウントダウンタイマー
ここでは『Swift5初級ガイド』のサンプルでも採用しているカウントダウンタイマーをサンプルとします。(スピーチ機能は使いません)
タイマー(Timer型)は Foundation フレームワークのクラスです。
詳しくは『iOSアプリのしくみ・メモリ管理とタイマー入門』を参考にしてください。
サンプルのカウントダウンタイマーコードは初期値から1秒など指定時間ごとにカウントダウンし、ゼロで停止します。
サンプルの構造はできるだけシンプルに
Ⓐカウントダウンする数値表示
Ⓑスタートボタン
Ⓒリセットボタン
の3つのUI部品の構成とします。
サンプルではカウントダウンの機能と画面(ビュー)を分離します。
2-2 Timerの動作確認
まずTimer関連のコードから試しましょう。
// 03-01 タイマーの動作確認コード
import Foundation
import PlaygroundSupport
let interval = 0.5
var counter = 10
var timer: Timer?
PlaygroundPage.current.needsIndefiniteExecution = true
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: {_ in
counter -= 1
print(counter)
// ゼロになったらカウントダウン終了
if 0 >= counter {
timer?.invalidate()
timer = nil
}
})
プレイグラウンドを実行し各行の「結果」表示を観察してください。
(このサンプルはLive Viewに何も表示しません)
時短実行のためinterval定数を使っています。
カウントダウンする時間間隔intervalを1.0(単位は秒です)にすると毎秒カウントダウンします。0.5にすると半分の時間でゼロになります。
単純にカウントダウンするだけのサンプルです。
リセットのコードはないので、停止してから再度実行してください。
Timerクラスのクラスメソッド scheduledTimer(withTimeInterval:repeats:block:) を使っています。
Timer型はもともとの名称はNSTimerでした。
ドキュメントでもNSTimerの部分が残っています。
このメソッドは実行すると直ちにタイマーの処理を開始します。
このサンプルではrepeatsをtrueにしているのでblock引数のクロージャを繰り返し実行します。
タイマーを停止するメソッドは invalidate() です。
scheduledTimer(withTimeInterval:repeats:block:)が返すインスタンスでinvalidate()メソッドを実行すると停止します。
2-3 ビューを更新させるしくみ
WWDC2019の「Data Flow Through SwiftUI」セッションとは現在リリースされているコンバイン関連の型名が変更になってしまっていて混乱しました。
ここではもちろん最新環境で実際に使われている型名だけを使います。
三つのキーワードが使われています、順に確認しましょう。
一つ目のキーワードはObservableObjectです。
Combineフレームワークの ObservableObject プロトコルに準拠した型を用意しそのインスタンスからSwiftUIのビューを繰り返し更新させカウントダウンを実現させるサンプルを作りましょう。
値がカウントダウンで変更になったら再表示するので、その値がまず必要です。
// カウンター変数を準備
@Published var counter = 0
二つ目のキーワードが@Publishedです。
@PublishedはCombineフレームワークのカスタム属性です。@Published属性はclassのプロパティーに対して設定します。
三つ目のキーワードは@ObservedObjectです。
@ObservedObject属性のプロパティを持つビューは、そのプロパティ(中身はObservableObjectのインスタンス)の@Published属性付きのプロパティが変化すると自動で再表示されます。
ObservedObject属性はObservableObjectに準拠した型にしか付けられません。
ObservableObject と ObservedObject はかなり紛らわしいですね。
サンプルではCountDounTimerクラスがObservableObjectプロトコルに準拠しています。
※この記事公開時にクラス名の先頭が大文字になっていませんでした。
2020年7月18日に修正しました。
CountDounTimerクラスのインスタンスが@Published属性付きのcounterプロパティを持つ場合、CountDounTimerクラスのインスタンスを @ObservedObject属性のプロパティで持つSwiftUIのビューはcounterの値が変化すると自動で再表示します。
2-4 基本的なコード
この図の時計部分がCountDounTimerクラスです。
サンプルコード 03-02 です。
import SwiftUI
import PlaygroundSupport
// 03-02 ObservableObjectを使ってタイマーによる変更を表示する
let interval = 0.5
class CountDounTimer: ObservableObject {
@Published var counter = 10
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: {_ in
self.counter -= 1
print(self.counter)
// ゼロになったらカウントダウン終了
if 0 >= self.counter {
self.timer?.invalidate()
self.timer = nil
}
})
}
}
// タイマー組み込み
struct Sample03View: View {
@ObservedObject var cd: CountDounTimer
var body: some View {
VStack {
Text("\(cd.counter)")
.font(Font.largeTitle.monospacedDigit())
.fontWeight(.black)
.padding()
Button(action: {
// タイマースタート
self.cd.start()
}) {
Text("スタート")
}
.padding()
}
.font(.title)
}
}
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(Sample03View(cd:CountDounTimer()))
1行目と2行目の import 文と最後の文はすべてのサンプルで必要です。
注意:Xcodeでは最初の実行はLive Viewの表示まで時間がかかります。
また最初はLive View上にマウスポインタを移動しても矢印カーソルのままのため操作できません。
一度終了し再度実行してください。
マウスポインタが「指」の場合は操作が可能です。
実行すると 10 の下にスタートボタンを表示します。
スタートボタンをタップすると10がカウントダウンしゼロで止まります。
ObservableObjectに準拠するCountDounTimerクラスでカウントダウンを扱います。
@Published var counter = 10
counterがタイマーで繰り返し-1されるプロパティです。
CountDounTimerはクラスなので var 宣言で問題なく増減や任意の値に変更できます。
@Published属性にして、counterが変化したらビューが再表示され最新の値を画面に表示します。
var timer: Timer?はCountDounTimerのインスタンスプロパティです。
timerプロパティはfunc start()でscheduledTimer(withTimeInterval:repeats:block:)が返すインスタンスを代入しています。
これはカウントダウンでcounterがゼロになったら繰り返し処理を止めるために使います。
func start()はタイマーの動作確認のコードにcounterがゼロになった場合の処理を追加したものです。
self.timer?.invalidate()はscheduledTimer(withTimeInterval:repeats:block:)で開始した繰り返し処理を停止させるコードです。
selfはこのコードがクロージャのため必要になります。
self.timer = nil で不要になったtimerインスタンスを解放しています。
今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。