AppleWatch: 時計アプリを作る
※記事にしました。
昔、たぶん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です)
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
は要らなくなりました。
これで見てないときでも秒針が更新されるようになりました。
これで見てないときでも秒針が更新されるようになりました。
気付くと止まってるというか、15秒おきぐらいに更新されるようになります。
何かあるんでしょうかね。
あと、「時計に戻る」の設定により最長でも1時間でアプリからフェイスに戻ってしまいます。
標準のタイマーとかは「Appに戻る」という設定があってフェイスに戻らないこともできるのですが、これが公開されているのかどうなのか。
クロックを消すのはまた後で書きます。
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で常時前面にしているので、もしかするとバッテリーを食い過ぎるかも知れません。
少し様子を見ます。
しかし、時計デバイスのアプリとして時計を作るというのは、なんだろなと思いますね…
これも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のデータにはアクセスしないので、何かの承認が必要になったりはしません。
HealthKitを使うので、プロジェクトのターゲットのWatchAppのCapabilityにHealthKitを追加する必要があります。HealthKitのデータにはアクセスしないので、何かの承認が必要になったりはしません。
以下のアプリに、HKWorkoutSession
を使う「前面表示を維持」スイッチを付けて審査に出しましたが、却下されました。最終的にHKWorkoutSession
関連のコードを削除し、スイッチは外しました。
ちなみに、「HealthKit利用の承認が必要にならない」ので、HealthKitのentitlementがなくても(=Signing&CapabilitiesのところにHealthKitを追加しなくても)動作します。
ただ、なしの状態でAppStoreにアップロードすると「HealthKitのAPIを使っているのにHealthKitのentitlementがない」って警告が出ます。
修正してから審査に出したので、その状態で審査に通るかどうかを確認できてはいないのですが、おそらくは通らないのだろうと思います。
ちなみに、「HealthKit利用の承認が必要にならない」ので、HealthKitのentitlementがなくても(=Signing&CapabilitiesのところにHealthKitを追加しなくても)動作します。
いつからか、entitlementがないと動作しなくなっていました。