[SwiftUI][watchOS] Timer アプリを AppleWatch 向けに作る(その2 Haptic 追加) 当面、凍結

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

第2回目は、Haptic での通知 を追加していきます。

[SwiftUI][watchOS] Timer アプリを AppleWatch 向けに作る(その1)
注意
Haptic だけでは十分でないと考えて、アプリがバックグラウンドにいても、時間によって通知を行うようにコードを拡張しました。

ですが、時間によってバックグラウンドタスクを実行する精度が、実機では 実用に堪えませんでした。

App に Background タスク向けの Budget がどのくらい割り当てられるかはシステム次第です。シミュレータであれば、問題なく設定時刻に実行されるのですが、実機では、どうやっても安定しませんでした。 (例えば、30秒後を指定して、40秒後に通知 など)

ということで、バックグラウンドのタスクのタイミングをもう少し制御できるようになるまで、しばらく 置いておくことにしました。

現状を考えると、時間で何かを通知するアプリを watchOS 上に作ることができるのは、Apple だけかもしれません。

振り返り

画面タップで、スタート/ストップするアプリを作りました。

タイマーの情報が画面にのみ表示されているので、アプリの画面を見ていないとタイマーが終わったことがわかりません。

以下の拡張を順番に行い、より Apple Watch アプリっぽくしていきます。(今回は、Haptic です)

  • 振動(Haptic) で タイマー終了 を伝える
  • Notification で タイマー終了 を伝える

Haptic 追加

Haptic とは?

Apple Watch は振動で通知することができるデバイスの1つです。振動での通知は、ハプティックフィードバック(Haptic feedback)と呼ばれます。

Apple Watch は、手首に装着するデバイスですので、iPhone よりも振動による通知を有効に使えるデバイスです。

Haptic の実装方法

Apple Watch 本体の情報については、WKInterfaceDevice というクラスが扱います。

Apple のドキュメントは、こちら

Haptic もこのクラスを経由して使用します。

play メソッドを使って、Haptic を発生することができます。

WKInterfaceDevice#play

func play(_ type: WKHapticType)

引数に、Haptic のタイプを指定することで、さまざまなタイプの振動を使うことができます。

Haptic の種類

以下のような Haptic が用意されています。

Haptic name 目的
notification Watch App がフォアグラウンドになって、
directionUp 特定量の数値増加や、数値が境界値を超えたことを表す
directionDown 特定量の数値の減少や、数値が境界値を下回ったことを表す
success タスクの正常終了や、質問への回答ができたことを表す
failure タスクの異常終了や、質問へ回答できなかったことを表す
retry うまくいかなかったことに対して、ユーザーがリトライするように促す
start アクションの開始を表す
stop アクションの終了を表す
click クリックを表す
navigationGenericManeuver 新しいガイドのステップを表す
navigationLeftTurn ユーザーが、左に曲がるように促す
navigationRightTurn ユーザーが、右に曲がるように促す

タイマーの開始時と終了時に振動させたいので、シンプルに、"Start" と "Stop” を使うことにしました。

Haptic の実装

タイマー開始とタイマー終了のタイミングで、Haptic を使って通知するようにします。

タイマーの開始と終了は、どちらも ViewModel (MultiTimerViewModel) で実装していますので、該当箇所に追加しました。

MultiTimerViewModel.swift

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

import Foundation
import Combine
import SwiftUI
import os
import WatchKit
import UserNotifications

class MultiTimerViewModel: NSObject, ObservableObject {
    @Published var multiTimerModel: MultiTimerModel = MultiTimerModel()
    @Published var remainSec:Int = 0
    
    let logger = Logger(subsystem: "com.smalldesksoftware.multitimer.TimerViewModel", category: "viewmodel")
    
    // haptic type
    let startHaptic: WKHapticType = .start
    let stopHaptic: WKHapticType = .stop

    var cancellable: Cancellable?

    var isIdle: Bool {
        return multiTimerModel.startDate == nil
    }

    func start() {
        logger.debug("timer start called")
        self.multiTimerModel.startDate = Date()
        cancellable?.cancel()
        self.cancellable = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink() { date in
                self.updateRemainSec(date)
            }
        // (1)
        WKInterfaceDevice.current().play(startHaptic)
    }
    
    func stop() {
        logger.debug("timer stop called")
        self.multiTimerModel.startDate = nil
        self.cancellable?.cancel()
        self.cancellable = nil
        self.remainSec = 0
        // (2)
        WKInterfaceDevice.current().play(stopHaptic)
    }

    func updateRemainSec(_ date:Date) {
        guard let sec = self.multiTimerModel.remainSec(date) else { return }
        logger.debug("remain sec: \(sec)")
        self.remainSec = sec
        
        if sec > 0 {
            return
        }
        
        self.remainSec = 0
        self.stop()
    }
    
    func remainSecAsString() -> String {
        if remainSec <= 0 { return String(multiTimerModel.duration) }
        return String(remainSec)
    }
}
コード解説
  1. タイマー開始時に、.start の Haptic 通知
  2. タイマー終了時に、.stop の Haptic 通知

実機でテスト

Haptic は、シミュレータで実行すると 音だけになります。実際で実行すると 音 と 振動 が発生されます。振動を確認するためには、実機での実行が必要となります。

Apple Watch アプリの 実機での実行
iPhone アプリを必要としない Independent App を作ってはいますが、Xcode から実行する際は、Apple Watch とリンクされた iPhone が必要となります。

Xcode の Scheme に、"AppleWatch via iPhone" という表示で実行ターゲットがありますので、そのターゲットを指定して実行することで、接続された iPhone 経由で Apple Watch での実行が可能となります。

バックグラウンド処理の検討

Apple Watch の仕様なのですが、ハプティックは、アプリがフォアグラウンドにあるときにのみ、実行されます。

タイマーをスタートさせた後、別アプリに切り替えてしまうと、終了タイミングになっても ハプティックが実行されないことが確認できます。

Apple Watch はデバイスの性質上、バックグラウンドのタスクを大量に動かすことができなくなっていますが、指定したタイミングで処理を実行させることはできます。

ただし、指定時間については、厳密に守られる保証はありません。

バックグランド処理予約

タイマーがスタートした時点で、終了予定時刻がわかるので、そのタイミングで処理が実行されるように予約します。

今回のアプリでは、アラーム開始の関数 start の中で、バックグラウンドタスクの予約を行うようにします。

なお、この予約を行っても、アプリがフォアグラウンドにいる時は、呼ばれません。
# 調べたドキュメントには、呼ばれると書かれているドキュメントと 呼ばれないと書かれているドキュメントがありました。
# シミュレータを使って試す限りでは、アプリがフォアグラウンドにある時には呼ばれませんでした。

func start@ViewModel

    func start() {
        logger.info("timer start called")
        self.timerModel.startDate = Date()
        cancellable?.cancel()
        self.cancellable = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink() { date in
                self.updateRemainSec(date)
            }
        WKInterfaceDevice.current().play(timerModel.startHaptic)
        scheduleBackgroundTask()
    }

    fileprivate func scheduleBackgroundTask() {
        self.logger.debug("schedule background task remain")
        let nextUpdateDate = Date(timeIntervalSinceNow: Double(self.multiTimerModel.duration))
        WKExtension.shared().scheduleBackgroundRefresh(withPreferredDate: nextUpdateDate, userInfo: nil) { (error) in
            if (error != nil) {
                self.logger.error("failed to schedule in scheduleBackgroundRefresh")
            }
        }
    }
注意
同時に複数の予約は存在できません。複数の予約を行うと、最後の予約のみが有効となります。

バックグランドで行う処理

バックグラウンドで行う処理は、WKExtensionDelegate#handle に実装します。(バックグランド処理のタイミングでコールされます)

処理自体は、ViewModel(MultiTimerViewModel) に extension として実装します。

extension MultiTimerViewModel

extension MultiTimerViewModel: WKExtensionDelegate {
    func handle(_ backgroundTasks: Set) {
        logger.debug("# of task is \(backgroundTasks.count)")
        self.updateRemainSec(Date())
        for task in backgroundTasks {
            // task might be WKApplicationRefreshBackgroundTask,WKSnapshotRefreshBackgroundTask or  others, anyway need to handle.
            if task.isKind(of: WKSnapshotRefreshBackgroundTask.self) {
                task.setTaskCompletedWithSnapshot(true)
            } else {
                task.setTaskCompletedWithSnapshot(false)
            }
        }
    }
}

handle メソッドは、WKApplicationRefreshBackgroundTask のみではなく、WKSnapshotRefreshBackgroundTask 等他の Task 実行時にも呼び出されます。

必要があれば、タイプを確認して、処理を分けることが必要です。

# このアプリでは、いずれにしてもアップデート処理を行います。

この handle がきちんと呼び出されるためには、MultiTimerViewModel を WKExtensionDelegate に指定しなければいけません。

プロジェクトの Life Cycle として、WatchKit App Delegate を選択している時は、Info.plist にてクラス名を設定します。

今回は、Life Cycle にも SwiftUI を指定していますので、以下のように App の中で指定します。

# もちろん、MultiTimerViewModel は、WKExtensionDelegate に 準拠する必要があります

example code

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

import SwiftUI
import os

@main
struct MultiTimerApp: App {
    // (NEW) specify WKExtensionDelegate
    @WKExtensionDelegateAdaptor(MultiTimerViewModel.self) var delegate 
    @StateObject var multiTimerViewModel: MultiTimerViewModel = MultiTimerViewModel()

    let logger = Logger(subsystem: "com.smalldesksoftware.multitimer.TimerApp", category: "app")

    @SceneBuilder var body: some Scene {
        WindowGroup {
            NavigationView {
                MultiTimerView(multiTimerViewModel: multiTimerViewModel)
            }
        }
        WKNotificationScene(controller: NotificationController.self, category: "myCategory")
    }
}

こうすることで、TimerViewModel が WKExtensionDelegate に指定されたものとして、必要なメソッドが呼ばれるようになります。

動作確認

上記を実装してから、アプリを立ち上げてタイマーを開始させれば、その後ホーム画面に戻っても 時間になると Haptic で通知されることが確認できます。

完成

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

Timer App movie

「Timer App movie」

まとめ: Haptic を実装する方法

Haptic を実装する方法
  • WKInterfaceDevice を使って、Haptic を使う
  • Haptic は、アプリがフォアグラウンドにないと動作しない (アプリが動作していないため)
  • WKExtension.shared().scheduleBackgroundRefresh を使うと、時間指定で処理を実行することができる
  • 時間指定で実行された WKExtensionDelegate#handle で Haptic を使った通知ができる

次回は、Notification を追加します。

説明は以上です。

コメントを残す

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