[SwiftUI][watchOS] Timer アプリを AppleWatch 向けに作る(その1)

watchOS 7 もリリースされたので、改めて、SwiftUI を使った Apple Watch のアプリ開発を説明していきます。

数回に分けて、Haptic や Notification も含めた Apple Watch アプリを作っていきます。

作るアプリ

カップラーメンを作るときのタイマーを作ってみます。

Watch App スクリーン

「Watch App スクリーン」

3分限定のタイマーになっていて、画面をタップしたらスタート。180 ( = 60秒 x 3) をカウントダウンしていきます。

途中で画面をタップしたら、ストップ。

あまり複雑にせずに、これくらいの仕様で開発を開始してみます。

環境&対象

以下の環境で動作確認を行なっていきます。

  • watchOS 7.0 (with macOS Catalina)
  • Xcode 12.2

リリースされたばかりの Xcode12.2 を使って作ります。

watchOS 7 も 2020年9月にリリースされたばかりですが、使っていきます。

この環境では、SwiftUI で UI を作ることも可能なので、このタイマーアプリも SwiftUI を使って作っていきます。

# SwiftUI は、watchOS 6 からサポートされています。

プロジェクトの作成

まずは、Xcodeでプロジェクトを作成します。

Xcode12 で Multiplatform 向けのプロジェクト作成が充実してきましたが、watchOS 向けの設定は、Multiplatform プロジェクトに含まれていません。

将来的に、iOS や iPadOS, macOS にまで拡張することを前提に、Multiplatform 向けプロジェクトから開始してみます。

Multiplatform プロジェクトの作成

  1. [File]-[New]-[Project...] で開かれるダイアログで、"Multiplatform"-"Application"-"App" を選択して、"Next" を押下します
  2. Product Name を設定して、"Next" を押下します。(ここでは、MultiTimer としました)
  3. プロジェクトを保存するフォルダを選択して、"Create" を押下します

プロジェクトのターゲットとして、MultiTimer(iOS) と MultiTimer(macOS) が作られていることがわかります。

watchOS 向けのターゲットがありませんので、追加していきます。

watchOS アプリ向けターゲット追加

  1. [Editor]-[Add Target...] で開かれるダイアログで、"watchOS"-"Application"-Watch App" を選択して、"Next" を押下します。
  2. Product Nameを設定して、Interface: SwiftUI, Life Cycle: SwiftUI App, Language: Swift として、"Finish"を押下します。(iOSと同じく MultiTimer という名前を設定しました)
    "Include Notification Scene"にチェックを入れたままにしておいてください。
  3. "Finish" を押下すると、"Activate MultiTimer WatchKit App (Complication) scheme"? と聞いてきます。"Activate" ボタンを押下してください

Watch App ターゲット追加

「Watch App ターゲット追加」

Watch App テンプレートコードの説明

ターゲットを追加すると3つのターゲットとコードが追加されます。追加されたターゲットとコードの概要を説明します。

Watch App を選択すると追加されるターゲットの意味

Watch App Target

「Watch App Target」
3つのターゲットが追加されています。

MultiTimer
以下の WatchKit App と WatchKit Extension を束ねるためのターゲットです。Stub ですが、実行する時には、このターゲットを選択します。
MultiTimer WatchKit App
Storyboard 使用時に必要なリソースを持つターゲットです。
MultiTimer WatchKit Extension
内部ロジックコード等を持つ、アプリケーションとしては、メインとなるターゲットです。

Apple Watch 向けのアプリのターゲットが上記のように3つに分かれているのには、歴史的な背景があります。

watchOS 6 以前の Apple Watch 向けのアプリは、iPhone 上のアプリ(コンパニオンアプリと呼ばれます)とペアで動作するアプリしかありませんでした。
内部計算は iPhone 側で行われ、画面表示だけが Apple Watch 上で処理されるという構成でした。

その時には、上記の WatchKit App 部分が Apple Watch 上で実行され、WatchKit Extension が iPhone 上で実行されていました。

そのとき (watchOS 6 以前)の環境との継続性を考えて、現在も WatchKit App と WatchKit Extension に分かれて構成されています。

watchOS 6 以降は、Apple Watch 上で単独で動くアプリが開発できるようになり、WatchKit App, WatchKit Extension どちらも Apple Watch 上で実行できるようになりました。

技術的には、以下のような使い分けになります。

WatchKit App
Storyboard と Storyboard に必要な Asset を入れておく
WatchKit Extension
上記以外

SwiftUI で UI を構築することを考えると、WatchKit App に入れておくべきものは、あまりないです。

ターゲットの動作確認

追加した Watch App のターゲットが動くかどうか確認しておきます。

Scheme "MultiTimer WatchKit App"-"Apple Watch Series 6 - 40mm" を選択してから、[Product]-[Run]を選択して、動かしてみます。

AppleWatch で HelloWorld

「AppleWatch で HelloWorld 」

お決まりの "Hello, World!" が表示されることが確認できます。

なお 実機で動作させるためには、リンクされた iPhone が必要となります。

サンプルコードの確認

"MultiTimer WatchKit App" フォルダは、MultTimer WatchKit App をターゲットとするコード等が含まれていますが、先ほど書いたように、Storyboard と Asset が含まれるフォルダなので、SwiftUI で開発していくときには、触りません。

"MultiTimer WatchKit Extension" フォルダが、MultiTimer WatchKit Extension をターゲットとするコードやリソースが含まれていますので、開発時にはこのフォルダが主対象となります。

Apple Watch 上で "Hello, World!" を表示するアプリの動作は、"MultiTimerApp.swift" と "ContentView.Swift" で実現されています。しばらくは、他のswiftファイルは無視して大丈夫です。

各ファイルの中身を確認していきます。

まずは、MultiTimerApp.swift を確認します。

MultiTimerApp.swift

//
//  MultiTimerApp.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import SwiftUI

@main
struct MultiTimerApp: App {
    @SceneBuilder var body: some Scene {
        WindowGroup {
            NavigationView {
                ContentView()
            }
        }
        // (1)
        WKNotificationScene(controller: NotificationController.self, category: "myCategory")
    }
}

Life Cycle に SwiftUI を選択しているので、App に準拠した MultiTimerApp が定義されています。
iOS 向けに生成されるテンプレートコードと大きく違うのは、(1) の WKNotificationScene が追加されている点です。

AppleWatch では、通知表示用にこのような定義が必要となります。

開発当初は、通知する予定もないのですが、そのまま置いておきます。(定義だけされて使用されなくとも問題はありません)

次は、ContentView.swift です。

ContentView.Swift

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

こちらは、iOS 向けに生成されるテンプレートコードとほとんど同じです。

# 相違点は、World の最初の文字が 大文字か小文字か の違いです・・・

watchKit 向けに少しの違いはありましたが、大部分が同じであることがわかりましたので、実際のアプリを作っていきます。

タイマーアプリ設計

アーキテクチャとしては、MVVM(Model-View-ViewModel) で作っていきます。

アプリとしては、以下が基本方針です。

  • 画面をタップすると、タイマー動作開始
  • 画面を(再)タップすると、タイマー停止

欲しい機能が見つかった時に、追加を検討します。

MVVM の M(Model) を作る

タイマーアプリのモデルなので、以下を持つようにします。

  • タイマーの時間(3分ですが、単位を秒として、180 を保持)
  • タイマー開始時間

以下のコードを持つファイルを、MultiTimerModel.swift として作成します。

なお、追加するファイルは、ターゲット "MultiTimer WatchKit Extension" に含まれるように追加します。

MultiTimerModel.swift

//
//  MultiTimerModel.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import Foundation
//import WatchKit

struct MultiTimerModel {
    // timer duration (in second)
    var duration: Int =  60 * 3
    
    // timer start time, nil means "not started"
    var startDate: Date? = nil

    func remainSec(_ date: Date) -> Int? {
        guard let start = startDate else { return nil }
        return duration - Int(Date().timeIntervalSince(start))
    }
}

タイマーの時間 と タイマー開始時間を使って、残り時間を Int? で返すメソッドも作りました。

タイマーが開始されていれば Int が返されますが、タイマーが開始されていない時には、nil が返されます。

MVVM の VM (ViewModel) を作る

ViewModel 設計時に考慮した点は、以下です。

  • タイマーの制御は、ViewModel が行う
  • Combine 対応で用意された Timer.publish を使う
  • 画面更新向けに、残り秒数を @Published で保持する (モデルは、タイマー時間長と開始時間しか持たず、タイマー動作中にプロパティが変更されないため)
  • 画面表示用に、残り秒数を String で返すメソッドを用意する(タイマー未動作時には、タイマー時間長を String で返す)
MultiTimerViewModel

//
//  MultiTimerViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import Foundation
import Combine
import SwiftUI

class MultiTimerViewModel: ObservableObject {
    // (1)
    @Published var multiTimerModel: MultiTimerModel = MultiTimerModel()
    // (2)
    @Published var remainSec:Int = 0
    // (3)
    var cancellable: Cancellable?
    // (4)
    var isIdle: Bool {
        return multiTimerModel.startDate == nil
    }

    func start() {
        self.multiTimerModel.startDate = Date()
        cancellable?.cancel()
        // (5)
        self.cancellable = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink() { date in
                self.updateRemainSec(date)
            }
    }
    
    func stop() {
        self.multiTimerModel.startDate = nil
        self.cancellable?.cancel()
        self.cancellable = nil
        self.remainSec = 0
    }

    func updateRemainSec(_ date:Date) {
        guard let sec = self.multiTimerModel.remainSec(date) else { return }
        self.remainSec = sec
        
        if sec > 0 { return }
        
        self.remainSec = 0
        self.stop()
    }
    // (6)
    func remainSecAsString() -> String {
        if remainSec <= 0 { return String(multiTimerModel.duration) }
        return String(remainSec)
    }
}
コード解説
  1. ViewModel なので、 Model を保持します
  2. 残り秒数は、ViewModel で保持し、@Published をつけて、View 更新のトリガーにします
  3. Timer.publish を使うので、cancellable を保持します
  4. タイマー動作中かを判断して返すメソッドをつけました
  5. Combine 風に Timer を使いました。1秒毎に updateRemainSec が呼ばれます
  6. 残り秒数を String で返すメソッドです。SwiftUI では、このように表示対象の要素を判断込みで適切な String にして返すメソッドがあると便利です。

MVVM の V (View) を SwiftUI で作る

SwiftUI で View を作ります。

View に使う要素は、Image と Text です。watchOS でも問題なく使える要素です。

View 設計時に考慮した点は、以下です。

  • 表示に対しての条件分岐は ViewModel 側で持っているため、View は表示にフォーカスして シンプルに作る
  • ZStack を使って、画像とテキストを重ねて配置する。ZStack を使うことで、重なるようにも配置可能とする
  • ZStack に .contentShape を設定することで、.onTapGesture でどこをタップされても対応する
MultiTimerView.swift

//
//  MultiTimerView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct MultiTimerView: View {
    @ObservedObject var timerViewModel: TimerViewModel
    var body: some View {
        GeometryReader { geom in
            ZStack(alignment: .bottomTrailing) {
                Image("Ramen")
                    .resizable()
                    .scaledToFit()
                    .frame(height: geom.size.height * 0.7)
                    .position(x: (geom.size.width / 2)*0.6, y: geom.size.height/2-20)
                Text(timerViewModel.remainSecAsString())
                    .font(.largeTitle)
                    .padding(.trailing, 10)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .contentShape(Rectangle())
            .onTapGesture {
                self.toggleTimer()
            }
        }
    }
    
    func toggleTimer() {
        if timerViewModel.isIdle {
            timerViewModel.start()
            return
        }
        timerViewModel.stop()
    }
}

画像には、フリー素材を持ってきて、Assets に "Ramen" という名前で追加しました。配置もなんとなくの配置です。 Xcode12 は、プレビューがあるので、数値の調整がしやすいですね。

画像は、MultiTimer WatchKit Extension の Assets に 追加する必要があります。

Timer Mock

「Timer UI Mock」

App を @StateObject を使うように手直し

タイマー用のビューを、ContentView ではなく 別名で作ったのと、ViewModel のライフサイクルを App と同じにするために @StateObject を使います。修正後の MutiTimerApp.swift は以下のようになりました。

MultiTimerApp.swift

//
//  MultiTimerApp.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/16
//  © 2020  SmallDeskSoftware
//

import SwiftUI

@main
struct MultiTimerApp: App {
    // (1)
    @StateObject var multiTimerViewModel: MultiTimerViewModel = MultiTimerViewModel()
    @SceneBuilder var body: some Scene {
        WindowGroup {
            NavigationView {
                // (2)
                MultiTimerView(multiTimerViewModel: multiTimerViewModel)
            }
        }

        WKNotificationScene(controller: NotificationController.self, category: "myCategory")
    }
}
コード解説
  1. ViewModel を App と同じライフサイクルで保持(@StateObjectで保持)
  2. ContenView ではなく、MultiTimerView を使う

完成したアプリ

画面をタップして、タイマーが開始され、途中でタップするとタイマーが停止するというアプリができました。

Timer App movie

「Timer App movie」

まとめ: Xcode12, SwiftUI, Multiplatform で Apple Watch アプリを作る

Xcode12, SwiftUI, Multiplatform で Apple Watch アプリを作る方法
  • Multiplatform でプロジェクトを作る
  • Watch App ターゲットを追加する
  • SwiftUI で作ると、iOS アプリとほとんど同じ感覚で開発できる

SwiftUI のおかげで、iOS 向けとほとんど同じコードで、Apple Watch 向けのアプリが作れました。

これだけだとシンプルすぎるので、以下の改善点を1つづつ対応して、アプリを拡張していこうと思います。

  • アプリ画面を見ていないと、タイマーが終わったことに気づかない
  • アプリがフォアグラウンドにないと、タイマーが終わったことに気づけない
  • Apple Watch がスリープすると、タイマーが終わったことに気づけない

上記の点について、順番に対応していきます。

# 充実してきたところで、Multiplatform プロジェクトにした恩恵を得つつ、iPhone アプリや macOS アプリへの拡張も考えます。

説明は以上です。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です