数回に分けて、Haptic や Notification も含めた Apple Watch アプリを作っていきます。
Sponsor Link
作るアプリ
カップラーメンを作るときのタイマーを作ってみます。
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 プロジェクトの作成
- [File]-[New]-[Project…] で開かれるダイアログで、”Multiplatform”-“Application”-“App” を選択して、”Next” を押下します
- Product Name を設定して、”Next” を押下します。(ここでは、MultiTimer としました)
- プロジェクトを保存するフォルダを選択して、”Create” を押下します
プロジェクトのターゲットとして、MultiTimer(iOS) と MultiTimer(macOS) が作られていることがわかります。
watchOS 向けのターゲットがありませんので、追加していきます。
watchOS アプリ向けターゲット追加
- [Editor]-[Add Target…] で開かれるダイアログで、”watchOS”-“Application”-Watch App” を選択して、”Next” を押下します。
- Product Nameを設定して、Interface: SwiftUI, Life Cycle: SwiftUI App, Language: Swift として、”Finish”を押下します。(iOSと同じく MultiTimer という名前を設定しました)
“Include Notification Scene”にチェックを入れたままにしておいてください。 - “Finish” を押下すると、”Activate MultiTimer WatchKit App (Complication) scheme”? と聞いてきます。”Activate” ボタンを押下してください
Watch App テンプレートコードの説明
ターゲットを追加すると3つのターゲットとコードが追加されます。追加されたターゲットとコードの概要を説明します。
Watch App を選択すると追加されるターゲットの意味
- 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]を選択して、動かしてみます。
お決まりの “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
//
// 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
//
// 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
//
// 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.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)
}
}
- ViewModel なので、 Model を保持します
- 残り秒数は、ViewModel で保持し、@Published をつけて、View 更新のトリガーにします
- Timer.publish を使うので、cancellable を保持します
- タイマー動作中かを判断して返すメソッドをつけました
- Combine 風に Timer を使いました。1秒毎に updateRemainSec が呼ばれます
- 残り秒数を String で返すメソッドです。SwiftUI では、このように表示対象の要素を判断込みで適切な String にして返すメソッドがあると便利です。
MVVM の V (View) を SwiftUI で作る
SwiftUI で View を作ります。
View に使う要素は、Image と Text です。watchOS でも問題なく使える要素です。
View 設計時に考慮した点は、以下です。
- 表示に対しての条件分岐は ViewModel 側で持っているため、View は表示にフォーカスして シンプルに作る
- ZStack を使って、画像とテキストを重ねて配置する。ZStack を使うことで、重なるようにも配置可能とする
- ZStack に .contentShape を設定することで、.onTapGesture でどこをタップされても対応する
//
// 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 に 追加する必要があります。
App を @StateObject を使うように手直し
タイマー用のビューを、ContentView ではなく 別名で作ったのと、ViewModel のライフサイクルを App と同じにするために @StateObject を使います。修正後の MutiTimerApp.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")
}
}
- ViewModel を App と同じライフサイクルで保持(@StateObjectで保持)
- ContenView ではなく、MultiTimerView を使う
完成したアプリ
画面をタップして、タイマーが開始され、途中でタップするとタイマーが停止するというアプリができました。
まとめ: Xcode12, SwiftUI, Multiplatform で Apple Watch アプリを作る
- Multiplatform でプロジェクトを作る
- Watch App ターゲットを追加する
- SwiftUI で作ると、iOS アプリとほとんど同じ感覚で開発できる
SwiftUI のおかげで、iOS 向けとほとんど同じコードで、Apple Watch 向けのアプリが作れました。
これだけだとシンプルすぎるので、以下の改善点を1つづつ対応して、アプリを拡張していこうと思います。
- アプリ画面を見ていないと、タイマーが終わったことに気づかない
- アプリがフォアグラウンドにないと、タイマーが終わったことに気づけない
- Apple Watch がスリープすると、タイマーが終わったことに気づけない
上記の点について、順番に対応していきます。
# 充実してきたところで、Multiplatform プロジェクトにした恩恵を得つつ、iPhone アプリや macOS アプリへの拡張も考えます。
説明は以上です。
Sponsor Link