👏

Apple Watch Ultraのアクションボタンで自作appの関数を呼ぶ

2025/02/01に公開

概要

Apple Watch Ultra に付いているアクションボタンの動作はユーザーにより変更可能ではありますが、ショートカット機能を介しての変更であり、万能ではありません。そこで本記事では自作appの関数をショートカットに紐付けして、アクションボタンの操作から自作appの関数を呼ぶ方法を紹介いたします。

またiPhoneでは容易に出来る事がApple Watchでは一手間必要な部分があり、本記事ではApple Watchとショートカット機能、そしてApp Intents を利用しての手順を紹介します。
(具体的には Apple Watch上に簡易的なタイマーアプリを作り、その中のスタートスイッチの機能をアクションボタンに紐付けます)

環境

Xcode:16.2
iOS:18.3
WatchOS:11.3

本記事の対象読者

・Xcodeでのswift プログラミング初心者 (私自身がそうなので)
・App Intentsに興味がある方

課題1

WatchアプリのショートカットはiPhoneのショートカットappから編集が可能ですが、現状(2025/1月末)では、以下のようなデメリットがあります。

  1. iPhoneのショートカットappにて新規でショートカットを作成する際にアクションの入り口として表示されるのはiPhoneアプリだけ(当方の誤認識であればコメント等でご指摘ください)

  2. Watch appのショートカットを作成出来るのは、『アプリを開く』から、Watch アプリ名を指定し(全アプリではなく、App Intentsによりショートカットの入り口が設定されているものに限る)、Watchアプリに切り替えることだけです。

解決案

そこで今回は、Watch OS appを作成する際にWatch App with New Companion iOS App を選び、Watch app単独ではなく、iPhone appと紐付いた Watch app として作成し、iPhone側にも App Intents を設定し、ショートカットアプリで自作appが呼べるようにしてみました。

コードの中身としては、以下のようにまずiPhone側のコードに App Intents を埋め込みます。(赤枠の部分です)

このコードにより、ショートカットアプリでのアクション検索で 自作app が見えるようになります。

課題2

自作関数をショートカットとひもづけるには、どのようにコーディングすれば良いか?

解決案2

解決案を模索する中で、以下の記事が大変参考になりました。

https://zenn.dev/koichi_51/articles/b8419f61a1daf8

ショートカットの仕組みである App Intents と自作関数を紐付けるには、以下のようなコーディングが必要のようです。

  1. 具体的にはアクションボタンで呼びたい関数(startTimer())を含むクラスを ObservableObject として定義

  2. そのクラス自身を shared として定義 (このパターンは シングルトンパターンでしょうか?)

3.関数を呼ぶ側にもコードを加え、さきほどの関数がView側で呼べるように、ObservedObject 型でかつ shared を付加し、参照出来るようにします。

4.watch アプリの終盤にショートカットで起動する関数の紐付けを定義するために以下の赤枠コードを追加します。そのショートカット定義の中で、watch アプリに切り替わるのを待って2.で定義した関数をよびだします。

以下に各ファイルのコードを示します。
■iPhone アプリ側の ContentView

import SwiftUI
import AppIntents

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

struct StartTimerIntent: AppIntent {
    static var title: LocalizedStringResource = "Start demo"
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult {
        return .result()
    }
}

■Watchアプリ側のContentView

import SwiftUI
import AppIntents

struct ContentView: View {
   
    @ObservedObject private var timerManager = TimerManager.shared

       var body: some View {
           VStack(spacing: 20) {
               Text("Timer")
                   .font(.largeTitle)
                   .padding()
               
               Text("\(timerManager.seconds) seconds")
                   .font(.title)
                   .foregroundColor(.green)
               
               HStack(spacing: 20) {
                   Button(action: {
                       timerManager.startTimer()
                   }) {
                       Text("Start")
                   }
                   .buttonStyle(.borderedProminent)
                   
                   Button(action: {
                       timerManager.stopTimer()
                   }) {
                       Text("Stop")
                   }
                   .buttonStyle(.borderedProminent)
                   
                   Button(action: {
                       timerManager.resetTimer()
                   }) {
                       Text("Reset")
                   }
                   .buttonStyle(.bordered)
               }
           }
           .padding()
       }
}

#Preview {
    ContentView()
}

struct StartTimerIntent: AppIntent {
    static var title: LocalizedStringResource = "Start Timer2"
    static var description = IntentDescription("Starts the timer.")
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult {
        await MainActor.run {
            TimerManager.shared.startTimer()
        }
        return .result()
    }
}

■ショートカットで呼び出す関数の定義 (TimeManager.swift)

import Foundation
class TimerManager: ObservableObject {
    static let shared = TimerManager()
    @Published var seconds: Int = 0
    
    private var timer: Timer?
    private var startDate: Date?
    
    private init() {}
    
    func startTimer() {
        guard timer == nil else { return }
        startDate = Date()
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.seconds += 1
        }
    }
    
    func stopTimer() {
        timer?.invalidate()
        timer = nil
        startDate = nil
    }
    
    func resetTimer() {
        stopTimer()
        seconds = 0
    }
}

インストールと動作確認

Apple Watchのシミュレータでは2025/1月末現在では、アクションボタンへの紐付けにショートカットが選べないため、動作確認は、実機での確認になります。iPhoneとWatchの共用アプリの場合Watch App のターゲットを実機にして、ビルドと実行を選ぶと自動的に、iPhoneとWatchに並行でインストールされるようです。

無事にインストールされ、Watch上でタイマーが起動したら、いったんxcodeでのアプリは停止します。

次にiPhoneでのショートカットアプリを起動し、下記のようにWatchアプリの新規ショートカットを作成するために、(1)watchカテゴリを選択後(2)の『+』マークを押し、アクション検索のシートが開いたら、(3)操作し易いようにシートを上まで引き上げて,(4)自作したアプリを選択。

次に表示された Start demo を押すと、画面右上が『完了』になりますので、その『完了』を押すと、画面が切り替わり、ショートカット画面に1つ新たなものが加わってるハズです。

次にapple watch 側の操作になります。下記の手順に従いアクションボタンに紐付いた動作を
(7)の状態と同じであることを(『ショートカット』でかつ『Start demo』になっている)確認。

特に(5)でアクションの下に表示されるショートカットを押下し、ショートカットを選択する手順(6)を忘れずにしてください。

すべての設定が完了したのちに、apple Watchのアクションボタンを押すと、下記のように画面が数秒オレンジ色になり、選択したショートカット名が表示された後に、タイマーのアプリに切り替わり、時間計測が始まるハズです。

実は趣味でシュノーケリングを楽しんでいるのですが、シュノーケリングの際には手にグルーブをはめているため、画面操作は非常にしにくいため、メカスイッチであるアクションボタンは大変有り難いのですが、既存のショートカット機能でのカスタマイズは変更出来る範囲が狭かったため、自作関数をアクションボタンで呼べないかと思ったのがこの記事のきっかけです。

App Intentsに関しては、下記の公式サンプルもあるのですが、中身が非常に濃く、読み解くのが大変でしたので、記事中に参照させて頂いた koichiさんの記事が大変参考になりました。

ショートカット機能は今まで使って無かったので、思い違いがあるかもしれませんので、誤りがあれば是非ご指摘ください。

参考リンク

https://developer.apple.com/documentation/appintents/acceleratingappinteractionswithappintents

Discussion