Open8

AppleWatch: 時計アプリを作る

kabeyakabeya

※記事にしました。
https://zenn.dev/kabeya/articles/e0d215b0711e17

昔、たぶん2000年頃、新潮文庫にYONDA? CLUBというキャンペーンがあって、時計をもらいました。

電池を替えバンドを替え、かなり長いこと使ってましたが、5年ほど前に文字盤がひび割れてどうにもならなくなってしまっていました。

WatchAppを初めて作るのでそれを模してやってみよう、AppleWatchの時計はClockolgyというアプリで作るのが定番らしいのですが、まずはいっぺん自作でやってみよう、という話です。

時計自体は割と簡単です。
こんな感じでアプリができます。

struct ContentView: View {
    let calendar: Calendar = Calendar(identifier: .gregorian)
    @State var currentDate = Date.now
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        ZStack {
            Image("YONDA")
                .resizable(capInsets: EdgeInsets(top: -10, leading: -10, bottom: -10, trailing: -10), resizingMode: .stretch)
                .aspectRatio(contentMode: .fit)
                .padding(-10.0)
                .imageScale(.large)
                .foregroundColor(.accentColor)
                .clipShape(Circle()
                    .inset(by: -10))
            let angles = getAngles()
            ShortHand(angle: angles.short)
            LongHand(angle: angles.long)
            ScondHand(angle: angles.second)
        }
        .onReceive(timer) { input in
            self.currentDate = input
        }
    }
    
    func getAngles() -> (long: Angle, short: Angle, second: Angle) {
        let components = self.calendar.dateComponents([.hour, .minute, .second], from: self.currentDate)
        let totalSeconds = components.minute! * 60 + components.second!
        
        let secondDegree = Double(components.second!) / 60 * 360
        let longDegree = Double(totalSeconds) / 3600 * 360
        let hourDegree = Double(components.hour! % 12) / 12 * 360
        let secondsDegree = longDegree / 12
        let shortDegree = hourDegree + secondsDegree
        return (long: Angle(degrees: longDegree), short: Angle(degrees: shortDegree), second: Angle(degrees: secondDegree))
    }
    
    func LongHand(angle: Angle) -> some View {
        HandTriangle(width: 8, height: 80)
            .fill(.black)
            .frame(width: 8, height: 80)
            .offset(x: 4, y: 40)
            .rotationEffect(angle)
    }
    func ShortHand(angle: Angle) -> some View {
        HandTriangle(width: 10, height: 60)
            .fill(.black)
            .frame(width: 12, height: 40)
            .offset(x: 6, y: 20)
            .rotationEffect(angle)
    }
    func ScondHand(angle: Angle) -> some View {
        HandTriangle(width: 4, height: 76)
            .fill(Color(red: 0.6, green: 0.2, blue: 0.2))
            .frame(width: 12, height: 40)
            .offset(x: 6, y: 20)
            .rotationEffect(angle)
    }
    
    func HandTriangle(width: CGFloat, height: CGFloat) -> some Shape {
        Path { path in
            path.move(to: CGPointZero)
            path.addLine(to: CGPoint(x: -width/2, y: 0))
            path.addLine(to: CGPoint(x: -2, y: -height))
            path.addLine(to: CGPoint(x: 2, y: -height))
            path.addLine(to: CGPoint(x: width/2, y: 0))
            path.closeSubpath()
        }
    }
}

これだけだと右上にクロックが出てしまいますが、時計の絵・針そのものは上記ぐらいで済みます。
クロックを消すのはまた後で書きます。
(あと家にいるので、集中モードアイコン=家アイコン、が出ています。これは消せないかも知れません)

問題はこの時計アプリ、シミュレータでこそずっと動きますが、実機だとすぐに秒針が止まります。
腕を持ち上げると「見つかった?」って感じで動き直します。

色々やんないといけないことがあるんでしょうね。
その辺これから調べていくことになると思いますが、それを書きとめていこうと思います。

(Xcode 14.3.1, watchOS 9.4です)

kabeyakabeya

TimelineViewというのを使えばよさそうです。

struct ContentView: View {
    let calendar: Calendar = Calendar(identifier: .gregorian)

   var body: some View {
        ZStack {
            Image("YONDA")
                .resizable(capInsets: EdgeInsets(top: -10, leading: -10, bottom: -10, trailing: -10), resizingMode: .stretch)
                .aspectRatio(contentMode: .fit)
                .padding(-10.0)
                .imageScale(.large)
                .foregroundColor(.accentColor)
                .clipShape(Circle()
                    .inset(by: -10))
            TimelineView(.periodic(from: Date.now, by: 1.0)) { context in
                let angles = getAngles(date: context.date)
                ZStack {
                    ShortHand(angle: angles.short)
                    LongHand(angle: angles.long)
                    ScondHand(angle: angles.second)
                }
            }
            
        }
    }
    
    func getAngles(date: Date) -> (long: Angle, short: Angle, second: Angle) {
        let components = self.calendar.dateComponents([.hour, .minute, .second], from: date)
        let totalSeconds = components.minute! * 60 + components.second!
        
        let secondDegree = Double(components.second!) / 60 * 360
        let longDegree = Double(totalSeconds) / 3600 * 360
        let hourDegree = Double(components.hour! % 12) / 12 * 360
        let secondsDegree = longDegree / 12
        let shortDegree = hourDegree + secondsDegree
        return (long: Angle(degrees: longDegree), short: Angle(degrees: shortDegree), second: Angle(degrees: secondDegree))
    }
    
    func LongHand(angle: Angle) -> some View {
        HandTriangle(width: 8, height: 80)
            .fill(.black)
            .frame(width: 8, height: 80)
            .offset(x: 4, y: 40)
            .rotationEffect(angle)
    }
    func ShortHand(angle: Angle) -> some View {
        HandTriangle(width: 10, height: 60)
            .fill(.black)
            .frame(width: 12, height: 40)
            .offset(x: 6, y: 20)
            .rotationEffect(angle)
    }
    func ScondHand(angle: Angle) -> some View {
        HandTriangle(width: 4, height: 76)
            .fill(Color(red: 0.6, green: 0.2, blue: 0.2))
            .frame(width: 12, height: 40)
            .offset(x: 6, y: 20)
            .rotationEffect(angle)
    }
    
    func HandTriangle(width: CGFloat, height: CGFloat) -> some Shape {
        Path { path in
            path.move(to: CGPointZero)
            path.addLine(to: CGPoint(x: -width/2, y: 0))
            path.addLine(to: CGPoint(x: -2, y: -height))
            path.addLine(to: CGPoint(x: 2, y: -height))
            path.addLine(to: CGPoint(x: width/2, y: 0))
            path.closeSubpath()
        }
    }
}

Timerは要らなくなりました。

これで見てないときでも秒針が更新されるようになりました。

kabeyakabeya

これで見てないときでも秒針が更新されるようになりました。

気付くと止まってるというか、15秒おきぐらいに更新されるようになります。
何かあるんでしょうかね。

あと、「時計に戻る」の設定により最長でも1時間でアプリからフェイスに戻ってしまいます。
標準のタイマーとかは「Appに戻る」という設定があってフェイスに戻らないこともできるのですが、これが公開されているのかどうなのか。

kabeyakabeya

クロックを消すのはまた後で書きます。

Objective-Cを使っていたので、ブリッジヘッダやらなんやら説明するの面倒だなと思っていましたが、ブリッジヘッダなしで無理矢理消すようにできました。ついでに集中モードアイコンも消せました。

struct ContentView: View {
// 中略
     func hide(clock: Bool, indicators: Bool) {
        guard let appClass: AnyClass = NSClassFromString("PUICApplication") else {
            return
        }
        let sharedApplicationSelector = NSSelectorFromString("sharedApplication")
        guard let sharedApplicationMethod = class_getClassMethod(appClass, sharedApplicationSelector) else {
            return
        }
        let sharedApplicationMethodImp = method_getImplementation(sharedApplicationMethod)
        let sharedApplication = unsafeBitCast(sharedApplicationMethodImp, to: (@convention(c) (AnyClass?, Selector) -> Any).self)
        
        let app = sharedApplication(appClass, sharedApplicationSelector)
        
        if (clock) {
            let setStatusBarTimeHiddenSelector = NSSelectorFromString("_setStatusBarTimeHidden:animated:completion:")
            guard let setStatusBarTimeHiddenMethod = class_getInstanceMethod(appClass, setStatusBarTimeHiddenSelector) else {
                return
            }
            let setStatusBarTimeHiddenMethodImp = method_getImplementation(setStatusBarTimeHiddenMethod)
            let setStatusBarTimeHidden = unsafeBitCast(setStatusBarTimeHiddenMethodImp, to: (@convention(c) (Any, Selector, ObjCBool, ObjCBool, OpaquePointer?) -> Void).self)
            
            setStatusBarTimeHidden(app, setStatusBarTimeHiddenSelector, ObjCBool(true), ObjCBool(false), nil)
        }
        if (indicators) {
            let setStatusBarIndicatorsHiddenSelector = NSSelectorFromString("_setStatusBarIndicatorsHidden:animated:completion:")
            guard let setStatusBarIndicatorsHiddenMethod = class_getInstanceMethod(appClass, setStatusBarIndicatorsHiddenSelector) else {
                return
            }
            let setStatusBarIndicatorsHiddenMethodImp = method_getImplementation(setStatusBarIndicatorsHiddenMethod)
            let setStatusBarIndicatorsHidden = unsafeBitCast(setStatusBarIndicatorsHiddenMethodImp, to: (@convention(c) (Any, Selector, ObjCBool, ObjCBool, OpaquePointer?) -> Void).self)
            
            setStatusBarIndicatorsHidden(app, setStatusBarIndicatorsHiddenSelector, ObjCBool(true), ObjCBool(false), nil)
        }
    }

    init() {
        hide(clock: true, indicators: true)
    }
// 後略
}

ContentView(というか、アプリの最初のビューですね)のinit()の中から、時計とその他インジケータを消すメソッドを呼びます。
Objective-Cのメソッドを無理矢理呼びます。
ちなみにこの_setStatusBarTimeHidden:animated:completion:および_setStatusBarIndicatorsHidden:animated:completion:は非公開メソッドなので、これを呼ぶアプリはApp Storeの審査に通らないかも知れません。
個人で楽しむ程度で抑えておいたほうが良い気がします。

最長でも1時間でアプリからフェイスに戻ってしまいます。

これもHealthKitを使うことで戻らないようにできました。
後ほど書きます。

YONDA時計ですが、文字盤に文字がないデザインのため実際何時何分なのか分かりにくい、という元々の欠点を除き、時計として普通に使えるようになりました。

ただしHealthKitで常時前面にしているので、もしかするとバッテリーを食い過ぎるかも知れません。
少し様子を見ます。

kabeyakabeya

しかし、時計デバイスのアプリとして時計を作るというのは、なんだろなと思いますね…

kabeyakabeya

これもHealthKitを使うことで戻らないようにできました。
後ほど書きます。

メインのクラスのファイルに以下のように書きます。

import SwiftUI
import HealthKit

class WorkoutSession: NSObject, ObservableObject {
    var session: HKWorkoutSession?
    
    func start() {
        let healthStore = HKHealthStore()
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .other
        configuration.locationType = .unknown

        do {
            self.session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
        }
        catch {
            return
        }
        
        self.session?.startActivity(with: Date.now)
    }
}

@main
struct MyWatchApp: App {
    @StateObject var workoutSession: WorkoutSession = WorkoutSession()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(workoutSession)
        }
    }
}

それでもって、ContentViewに以下のように書きます。

struct ContentView: View {
    @EnvironmentObject var workoutSession: WorkoutSession
// 中略
    var body: some View {
        ZStack {
// 中略
        }
        .onAppear {
            workoutSession.start()
        }
    }
// 後略
}

これでずっと前面にいるようになります。

あと、HealthKitを使うので、プロジェクトのターゲットのWatchAppのCapabilityにHealthKitを追加する必要があります。HealthKitのデータにはアクセスしないので、何かの承認が必要になったりはしません。

kabeyakabeya

HealthKitを使うので、プロジェクトのターゲットのWatchAppのCapabilityにHealthKitを追加する必要があります。HealthKitのデータにはアクセスしないので、何かの承認が必要になったりはしません。

以下のアプリに、HKWorkoutSessionを使う「前面表示を維持」スイッチを付けて審査に出しましたが、却下されました。最終的にHKWorkoutSession関連のコードを削除し、スイッチは外しました。

https://apps.apple.com/jp/app/マジ買う/id6475606080

ちなみに、「HealthKit利用の承認が必要にならない」ので、HealthKitのentitlementがなくても(=Signing&CapabilitiesのところにHealthKitを追加しなくても)動作します。
ただ、なしの状態でAppStoreにアップロードすると「HealthKitのAPIを使っているのにHealthKitのentitlementがない」って警告が出ます。
修正してから審査に出したので、その状態で審査に通るかどうかを確認できてはいないのですが、おそらくは通らないのだろうと思います。

kabeyakabeya

ちなみに、「HealthKit利用の承認が必要にならない」ので、HealthKitのentitlementがなくても(=Signing&CapabilitiesのところにHealthKitを追加しなくても)動作します。

いつからか、entitlementがないと動作しなくなっていました。