はじめての Swift UI × watchOS 〜タイマーアプリを作る〜

2020/11/25に公開

せっかく Apple Watch を持っているので何かアプリを作りたいと思い Swift UI を勉強中です。 Swift UI の入門として作ったタイマーアプリについてチュートリアル形式でまとめます。

※ 本業は Web フロントエンドなので、Swift 自体が初めてです。間違い等あれば、気軽にコメントいただけると嬉しいです🙏

作るもの

簡単なタイマーアプリを作ります。
ピッカーで秒数を選んでStartを押すとタイマーが起動して、設定された秒数が経過すると、通知音とともにタイマーが終了するという簡単なものです。

コードは全て以下リポジトリにあります。

https://github.com/kawamataryo/swift-ui-sample-timer

1. Xcode プロジェクトのスタート

Xcode を開き Create a new Xcode project で新しいプロジェクトを作成します。
最初の画面で何を作るのか選択できるので、Watch App を選択して Next を押します。

適当に Product Name などを埋めていきます。

これでプロジェクトの準備は完了です。
この状態でビルドすると、Apple Watch のプレビューに Hello World と表示されるはずです。

2. 秒数ピッカー画面の作成

準備が整ったので画面を作っていきます。

最初にタイマーの起動時間を設定するための秒数ピッカーを作ります。
contentViewを以下のように変更します。

struct ContentView: View {
    @State var timeVal = 1

    var body: some View {
        VStack {
            Text("Timer \(self.timeVal) seconds").font(.body)
            Picker(selection: self.$timeVal, label: Text("")) {
                    Text("1").tag(1).font(.title2)
                    Text("5").tag(5).font(.title2)
                    Text("10").tag(10).font(.title2)
                    Text("30").tag(30).font(.title2)
                    Text("60").tag(60).font(.title2)
                }
            Button(action: {},label: {
                Text("Start")
            })
        }
    }
}

この状態で、プレビューを確認すると以下のようにタイマーの秒数選択画面が表示出来ているはずです。

簡単にコードの説明をします。
Swift UI では、Vue や React のように宣言的 UI でアプリの画面を構築していきます。
このコードで使ったコンポーネントは以下の通りです。

  • VStack: 内部の要素を縦方向に並べる
  • Text: テキストを表示する
  • Picker: 内部の要素をピッカー形式で表示する
  • Button: ボタンを表示する

また、それぞれのコンポーネントに引数やクロージャーで、設定値や別のコンポーネントを与え階層構造を作って UI を組み立てます。

以下コードの場合は、Text というコンポーネントの Struct を()内のテキストで初期化し、.fontでフォントサイズを設定しています。
とても直感的で分かりやすいですね。

Text("Timer \(timeVal) seconds").font(.body)

3. Timer画面への移動

次に Start ボタンを押した後に、タイマー画面への画面遷移をさせたいのでその部分を実装します。

まず新しい View を作ります。
@State として受け取った値を表示するだけの画面です。

struct TimerView: View {
    @Binding var timerScreenShow:Bool
    @State var timeVal:Int

    var body: some View {
        Text("\(self.timeVal)")
    }
}

次に最初の画面から、先ほど作った View に移動するためのコードを実装します。

struct ContentView: View {
    @State var timeVal = 1
    @State var timerScreenShow:Bool = false

    var body: some View {
        VStack {
            Text("Timer \(self.timeVal) seconds").font(.body)
            Picker(selection: self.$timeVal, label: Text("")) {
                    Text("1").tag(1).font(.title2)
                    Text("5").tag(5).font(.title2)
                    Text("10").tag(10).font(.title2)
                    Text("30").tag(30).font(.title2)
                    Text("60").tag(60).font(.title2)
                }
            NavigationLink(
                destination: TimerView(timerScreenShow: self.$timerScreenShow, timeVal: self.timeVal),
                isActive: self.$timerScreenShow,
                label: {
                    Text("Start")
                })
        }
    }
}

ポイントは、ButtonNavigationLinkに変更して、ボタン押下で TimerView への遷移を実行している点です。
そのために@Stateとして、タイマー画面の表示状態を制御するtimerScreenShowを宣言しています。

timerScreenShowと、timeValNavigationLinkにて、TimerViewを初期化する際に、引数として渡しています。

この状態でプレビューを起動すると以下のような画面になります。
ちゃんとTimerViewに遷移していますね。

4. Timer機能の完成

次に Timer 機能を、完成させます。

TimerViewを以下のように変更します。

struct TimerView: View {
    @Binding var timerScreenShow:Bool
    @State var timeVal:Int

    var body: some View {
        if timeVal > -1 {
        VStack {
            Text("\(self.timeVal)").font(.system(size: 40))
                .onAppear() {
                    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                        if self.timeVal > -1 {
                            self.timeVal -= 1
                        }
                    }
                }
            Button(action: {
                self.timerScreenShow = false
            }, label: {
                Text("Cancel")
                    .foregroundColor(Color.red)
            })
            .padding(.top)
        }
        } else {
            Button(action: {
                self.timerScreenShow = false
            }, label: {
                Text("Done!")
                    .font(.title)
                    .foregroundColor(Color.green)
            }).onAppear() {
                WKInterfaceDevice.current().play(.notification)
            }
        }
    }
}

いきなりコードが増えたのですが、1 つづつ説明します。

最初の if 文の分岐ではContentViewから受け取ったtimeValの値によって View を切り分けています。
これは、タイマー終了時の画面を表示するためです。

if timeVal > -1 {
  // ...
} else {
  //...
}

次にタイマー部分のコードです。
タイマー部分では、timeValの値を表示しながら、onAppear()(要素の表示にフックして処理を実行するもの)で、Timer.scheduledTimerを実行して、1 秒ごとにtimeValの値をディクリメントしていきます。

Swift UI では、@State@Binding のアノテーションをつけて宣言した変数が変化すると、View が再描画されるのでこれだけで、タイマーが実装できます。

Text("\(self.timeVal)").font(.system(size: 40))
    .onAppear() {
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            if self.timeVal > -1 {
                self.timeVal -= 1
            }
        }
    }

最後に、完了部分のコードです。
ButtonコンポーネントにonAppear()で、WKInterfaceDevice.current().play(.notification)を実行しています。これは完了を音で知らせるための通知音の起動です。

また、actionとしてself.timerScreenShowfalseを代入しています。
これで「Done!」のボタンを押すと最初の画面に遷移する動きが実現できています。

Button(action: {
    self.timerScreenShow = false
}, label: {
    Text("Done!")
        .font(.title)
        .foregroundColor(Color.green)
}).onAppear() {
    WKInterfaceDevice.current().play(.notification)
}

この状態でプレビューを起動すると以下のような画面になります。

タイマー機能が完成ですね。

5. UIの改善

今のままでも、タイマーとして機能するのですが、ちょっとまだ画面がシンプルすぎますよね。
プラスアルファとして、Apple Watch でよくみる進捗バーを追加してみます。

最初にバー部分を別 View で実装します。
要素を重なりで配置するZStackで、円を作るCircleを 2 つ重ねて実装しています。
引数として受け取るのは、初期値のinitialと、進捗のprogressです。

struct ProgressBar: View {
    let progress: Int
    let initial: Int

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 15.0)
                .opacity(0.3)
                .foregroundColor(Color.red)
            Circle()
                .trim(from: 0.0, to: CGFloat(min(Float(self.progress) / Float(self.initial), 1.0)))
                .stroke(style: StrokeStyle(lineWidth: 15.0, lineCap: .round, lineJoin: .round))
                .foregroundColor(Color.red)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear)
        }
    }
}

これをTimerViewに組み込みます。
Timer の値をTextで表示している部分をZStackで囲み、先ほどの ProgressBar を表示しています。
また、初期値設定用のinitialTimeというプロパティを新たに追加しています。

struct TimerView: View {
    @Binding var timerScreenShow:Bool
    @State var timeVal:Int
    let initialTime:Int

    var body: some View {
        if timeVal > -1 {
            VStack {
                ZStack {
                    Text("\(self.timeVal)").font(.system(size: 40))
                        .onAppear() {
                            Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                                if self.timeVal > -1 {
                                    self.timeVal -= 1
                                }
                            }
                        }
                    ProgressBar(progress: self.timeVal, initial: self.initialTime).frame(width: 90.0, height: 90.0)
                }
                Button(action: {
                    self.timerScreenShow = false
                }, label: {
                    Text("Cancel")
                        .foregroundColor(Color.red)
                })
                .padding(.top)
            }
        } else {
            Button(action: {
                self.timerScreenShow = false
            }, label: {
                Text("Done!")
                    .font(.title)
                    .foregroundColor(Color.green)
            }).onAppear() {
                WKInterfaceDevice.current().play(.notification)
            }
        }
    }
}

initialTimeは、ContentViewTimerViewを描画するときに渡します。

destination: TimerView(timerScreenShow: self.$timerScreenShow, timeVal: self.timeVal, initialTime: self.timeVal),

この状態でプレビューをみると、タイマーの数値に合わせて良い感じにバーの状態も変わるはずです。

これでタイマーアプリの完成です 🎉

おわりに

以上「はじめての Swift UI × watchOS 〜タイマーアプリを作る〜」でした。

View の構築については、Vue, React で慣れ親しんだ宣言的 UI なので あまり抵抗なく実装出来そうです。ただ、他のビルドやメモリ処理、データの永続化、ネイティブ API の使い方はまだ何もわかりません😅

これから色々アプリを作っていく段階で勉強していきたいです。

参考

Discussion