WristCounterつくる
WatchOS向けのカウンターアプリをつくるよ!
公式ドキュメント
公式のサンプルコード
この下にドットがあって、横スワイプでページ切り替えるのは、TabView
で実装する
Stepper
を使う
TextField
Toggle
と ShareLink
SwiftUIのwatchOS版、↓でまとめられていた
画面遷移の方法は、
- ナビゲーション遷移:
NavigationLink
- モーダル遷移:
.sheet
- 横スワイプで遷移:
TabView
縦スワイプで遷移するのどうするんだ?
これで指定するらしい
TabView {
// …
}
.tabViewStyle(.carousel)
NavigationView
がiOS 16からNavigationStack
推奨になったぽい
TabViewのデフォルトタブを変える方法、思ったよりめんどくさいな
画面全体をタップ範囲にするの、ややめんどくさかった
ZStack {
Text(String(number))
.font(.largeTitle)
Color.clear
.contentShape(Rectangle())
.onTapGesture {
number += 1
}
}
ZStack、下に書いたViewが上に表示されるらしい。逆だと思ってた
ZStack {
BackView()
FrontView()
}
ミニマムなカウンターならこれでOK
struct CounterView: View {
@State var number = 0
var body: some View {
Text(String(number))
.font(.largeTitle)
.onTapGesture {
number += 1
}
}
}
Intが2147483647でオーバーフローして気づいたけど、watchOSは32bitのCPUで動いてるっぽい?
これでguard入れた
guard tapTimes.count < Int32.max else { return }
モデルをどこに持たせるか
NavigationTitleが表示されずに苦しんだが、TabView
とNavigationStack
の組み合わせ問題だったらしい。
NavigationStack
を一番外にする
NavigationStack {
TabView {
ForEach(viewModel.counters) { counter in
CounterView()
.environmentObject(counter)
}
}
.tabViewStyle(.carousel)
}
TabViewで今選択されているページを取得するのが結構大変だった
NavigationStack {
TabView(selection: $viewModel.countersIndex) {
ForEach(Array(viewModel.counters.enumerated()), id: \.element) { index, counter in
CounterView()
.environmentObject(counter)
.tag(index)
}
}
.tabViewStyle(.carousel)
}
.tag(Tab.counter)
モデルをHashableに準拠させなきゃいけなかったのも地味に嫌だった
Intでタグ持つのやっぱ良くないな……UUIDベースにするか
あ、よく見たらTabViewがIntを要求してるのか
TabViewのページ数取得、制約が大きくてやりづらいので、方針を変えた
NavigationStack {
TabView() {
ForEach(viewModel.counters) { counter in
CounterView()
.environmentObject(counter)
.onAppear {
viewModel.activeCounter = counter
}
}
}
.tabViewStyle(.carousel)
}
.tag(Tab.counter)
@EnvironmentObject
の使い方を誤用してて、なんかNavigationTitleがずっと変わらない現象が起こってた
struct CounterView: View {
@EnvironmentObject var counter: Counter
}
これでViewModel内のCounterオブジェクトを渡してたんだけど、よくないっぽい。
@ObservedObject
に変更
実機デバッグができなくて苦しんでいたが、Apple Watchのパスコードを一時的にオフにしたら進んだ……
Navigationtitleがやっぱり更新できてない。Bindingできてなさそう
カウント数だけ更新されてる
子Viewでタイトルつけずに、親Viewでやることに。
NavigationStack {
TabView() {
ForEach(viewModel.counters) { counter in
CounterView(counter: counter)
.navigationTitle(counter.name)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
viewModel.activeCounter = counter
}
}
}
.tabViewStyle(.carousel)
}
PIckerをカウンターの裏に出すというのが当初の構想だったんだけど、いざ出してみると微妙。
別ページにした方がマシだな
設定画面の縦方向が、純正ワークアウトアプリほど上手くheightが効かなくて苦しんでいたが、どうもセーフエリアで守られていたらしい
思ってたよりセーフエリアが狭かった
NavigationTitleが更新されない問題、解決した
元々こんな感じにしていた。
これでも大体は上手くいくが、カウンター3つ生成して、設定画面から1つ目のカウンターを削除したときに、
Counter 1のタイトルが残る(データ自体はCounter 2)という不具合があった
struct ContentView: View {
var body: some View {
TabView() {
SettingView()
NavigationStack {
TabView() {
ForEach(viewModel.counters) { counter in
CounterView(counter: counter)
}
}
.tabViewStyle(.carousel)
}
}
}
}
struct CounterView: View {
var body: some View {
{
// Viewの定義は省略
}
.navigationTitle(counter.name)
.navigationBarTitleDisplayMode(.inline)
}
}
CounterViewの方にNavigationStackをつけたらして欲しかった挙動をするようになった
struct CounterView: View {
@ObservedObject var counter: Counter
var body: some View {
NavigationStack {
// …
.navigationTitle(counter.name)
.navigationBarTitleDisplayMode(.inline)
}
}
}
NavigationTitleで言うと、Binding<String>でもできると書いてあるけど、.navigationTitle($viewModel.activeCounter.name)
でやるとコンパイルは通るけど、
文字が表示されなくなるのが謎。
($取ると上手くいく)
うーん@Stateオブジェクトつくってみたけど、それでもBindingされてる感じがしないな
初回にタイトル設定したらそれで終わりっぽい
どうもwatchOSだからnavigationTitleのBindingが効かないっぽい
ここに「 iOS or macOS」としか書かれてなくて、気にはなってたが、試してみると確かにiOSでは↓でタイトルが動的に変わった
import SwiftUI
struct ContentView: View {
@State var title = "Title"
var body: some View {
NavigationStack {
VStack {
TextField("change title", text: $title)
}
.padding()
.navigationTitle($title)
}
}
}
iPadOSは書いてないけど、試してみたら動いた。
watchOSはタイトルが表示されなくなる
設定画面のデザインを変えるか。。。
画面遷移でなんでもやろうとしているので、個別カウンターの設定とアプリ全体の設定画面の遷移で破綻した。
カウンター一覧の下にアプリ全体の設定画面を入れようとしていたが、どうしても左スワイプで個別カウンター設定に行ってしまうのを防げなかった。
全体的に変える
基本、複数カウンターの生成なんて特殊ケースなので、少しネストが深い位置でも許容できるだろう
ForEach
使ってると、上手くBindingできてなかった。
本当はコンピューテッドプロパティでやりたかったけど、下記のように変更
@Published var tapTimes = [Date]() {
didSet {
displayTimes = tapTimes.enumerated().map { index, time in
DisplayTime(text:
String(index + 1) + ": " + dateFormatter.string(from: time)
)
}
}
}
@Published var displayTimes = [DisplayTime]()
あれでもデバッグした感じちゃんと更新されてないな
ScrollView入れたらボタンが角丸長方形になってしまった
.buttonBorderShape(.capsule)
で前みたいにできた
色をランダムで出すextension。頭いい
extension Color {
static var random: Color {
return Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
Alertのミニマムな実装
.alert(
"Title",
isPresented: $showAlert
) {
Button(role: .destructive) {
// Handle the deletion.
} label: {
Text("Delete")
}
Button("Retry") {
}
} message: {
Text("message")
}
Chart使ってみる
ミニマムにやるならこんなん
Chart {
PointMark(
x: .value("", "A"),
y: .value("", 1)
)
PointMark(
x: .value("", "B"),
y: .value("", 2)
)
PointMark(
x: .value("", "C"),
y: .value("", 3)
)
}
うーんなんかイマイチな散布図しかできないな
struct ChartView: View {
var counter: Counter
var body: some View {
Chart {
ForEach(counter.tapTimes) { time in
PointMark(
x: .value("Date", time.date),
y: .value("Count", 1)
)
}
}
.chartXAxis {
AxisMarks(values: .automatic) { date in
AxisGridLine()
AxisTick()
AxisValueLabel(format: .dateTime.hour().minute().second())
}
}
.padding()
}
}
ちょっとキレイに描画するのムリだな。諦めるか
Listの移動、削除
List {
ForEach(viewModel.counters) { counter in
HStack {
Text(counter.name)
Spacer()
Text(String(counter.tapTimes.count))
}
}
.onMove { indexSet, index in
viewModel.move(fromOffsets: indexSet, to: index)
}
.onDelete { index in
viewModel.remove(at: index)
}
.deleteDisabled(!viewModel.canRemoveCounter)
}
有料アプリ申請するために、銀行口座・納税フォームの登録が必要だった。
基本、英語入力を想定したフォームに日本の銀行口座や住所入れるので結構大変だった。
下記の記事を参考にして登録。
せっかくなのでXcode Cloudを試すことにした。
2023/12まで無料枠があるので、今年いっぱいは使えるかな
うーん上手くアーカイブ作業ができない
Failed to export archive because this team does not have the bundle identifier "com.shetommy.WristCounter" registered in the Developer Portal. Register this bundle identifier in the Developer Portal at https://developer.apple.com/account and then try again.
というエラーになっていて、よく見ると、登録されているのは"com.shetommy.WristCounter.watchkitapp"だけだ
この画面からワークフロー編集しようとすると、ウィンドウが勝手に閉じてしまう
iOSの方のバンドルIDがなかったために色々ワークフローで問題起きてたっぽい
com.shetommy.WristCounterを手動で登録したらビルドが通った
なお、アーカイブ設定をよくみたらデフォルトはApp Storeへのバイナリアップデートがなかったので、別でワークフローを設定
死ぬほど問題指摘された
ITMS-90055: This bundle is invalid - The bundle identifier cannot be changed from the current value, 'com.shetommy.WristCounter.watchkitapp'. If you want to change your bundle identifier, you will need to create a new application in App Store Connect.
ITMS-90345: Metadata/Info.plist Mismatch - The value for bundle_identifier in the metadata.xml file does not match the value for CFBundleIdentifier in WristCounter [Payload/WristCounter.app].
ITMS-90396: Invalid Icon - The watch application 'WristCounter.app/Watch/WristCounter Watch App.app' contains an icon file 'Icon Image-AppIcon-watch-1024x1024@1x.png' with an alpha channel. Icons should not have an alpha channel.
ITMS-90717: Invalid App Store Icon - The App Store Icon in the asset catalog in 'WristCounter.app/Watch/WristCounter Watch App.app' can't be transparent nor contain an alpha channel.
とりあえずアイコンは透過設定が良くないらしい
申請まわり
普通にアーカイブすると、com.shetommy.WristCounterのバンドルIDを見ちゃうのか
App Store ConnectのApp情報からBundle IDをiOS向けのものに変更してみた。どうなるか
watchOS AppをXcodeからXcode Cloudにオンボードすることができません。
iOS版のターゲットを安直に消すとこんなエラー
Code Signing
NSLocalizedDescription=exportOptionsPlist error for key "method": expected one of {}, but found app-store
おおおこれやったらイケた
App Store ConnectのApp情報からBundle IDをiOS向けのものに変更してみた。どうなるか
なんかプライベートMacBookだとPATが保存されない
会社MacBookは一度PAT入れたらキーチェーンに保存された気がしたけど、キャッシュになってるみたい
- デフォルト挙動はキャッシュで15分保存っぽい
-
git config --list
うってcredential.helper=osxkeychain
があればキーチェーンに保存はされる- この設定がなければ、
git config --global credential.helper osxkeychain
- この設定がなければ、
storeモードにしたければこれ
git config --global credential.helper store
平文で~/.git-credentials
に保存されてつらい……普通にキーチェーンにあるものを無期限で見てほしい
git config --global credential.helper cache
watchOS開発記事候補
- navigationTitleがBindingできない件
- Xcode CloudでwatchOSアプリのバイナリをあげる
久々の新規申請なのでプライバシーポリシー必須なの忘れてた……
https://app-privacy-policy-generator.firebaseapp.com/ 使って生成して、shetommy.comにプラポリページを作成
WristCounterって名前がApp Store上ですでに登録があるらしく、WristCounter Watch Appから変更できなかった。
まあStrore上だけみたいだから、これは妥協するか
レビュー通ったのでストア公開したら、一瞬「Ready for Sale」になって、「Removed from Sale」にすぐ移動した
これは配信状況で国と地域を設定するの忘れてたからっぽい
価格および配信状況 > 配信可否 を設定する