Closed103

WristCounterつくる

蔀

WatchOS向けのカウンターアプリをつくるよ!

蔀

watchOSの純正アプリと同じことがしたいときに、SwiftUIのどれを使えばいいのかがわかりづらい

蔀

この下にドットがあって、横スワイプでページ切り替えるのは、TabView で実装する

蔀

ミニマムなカウンターならこれでOK

struct CounterView: View {
    @State var number = 0
    var body: some View {
        Text(String(number))
        .font(.largeTitle)
        .onTapGesture {
            number += 1
        }
    }
}
蔀

モデルをどこに持たせるか

蔀

今回のモデルはそんなにデータを持っていない

  • 名前
  • カウント数
  • カウントした時間

↑これをカウンター数分持つ

蔀

一方操作するViewは多い

  • 設定画面
  • カウンター画面 x 可変個
蔀

とりあえずこれで行こうかな

  • App@StateObjectとしてViewModelを持たせる
  • ViewModel内にCounterとしてデータを保持する
  • 設定画面にはenvironmentObjectモディファイヤー使って、ViewModelを丸ごと渡す
  • カウンター画面にはCounterだけ渡す
蔀

NavigationTitleが表示されずに苦しんだが、TabViewNavigationStackの組み合わせ問題だったらしい。
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に変更

蔀

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をカウンターの裏に出すというのが当初の構想だったんだけど、いざ出してみると微妙。
別ページにした方がマシだな

蔀

https://twitter.com/0si43/status/1654330425119215617?s=20

設定画面の縦方向が、純正ワークアウトアプリほど上手くheightが効かなくて苦しんでいたが、どうもセーフエリアで守られていたらしい
思ってたよりセーフエリアが狭かった

https://developer.apple.com/documentation/watchos-apps/supporting-multiple-watch-sizes

蔀

.ignoresSafeArea(edges: .bottom)

これを入れて、Spacer()で調整してやるのが良さそう

蔀

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が効かないっぽい

https://developer.apple.com/documentation/swiftui/configure-your-apps-navigation-titles#Renaming

ここに「 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)で前みたいにできた

蔀

Alertのミニマムな実装

.alert(
     "Title",
     isPresented: $showAlert
 ) {
     Button(role: .destructive) {
         // Handle the deletion.
     } label: {
         Text("Delete")
     }
     Button("Retry") {
         
     }
 } message: {
     Text("message")
 }
蔀

presenting: detailsってのが引数に取れるみたいだけど、これ何に使うんだ?
→たぶん、メッセージの中で「xxを削除しますか?」みたいに出したいときに、xxの名前を渡す、とかができるのか

蔀

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まで無料枠があるので、今年いっぱいは使えるかな

https://developer.apple.com/jp/xcode-cloud/

蔀

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向けのものに変更してみた。どうなるか

蔀

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入れたらキーチェーンに保存された気がしたけど、キャッシュになってるみたい

https://qiita.com/usamik26/items/c655abcaeee02ea59695

蔀
  • デフォルト挙動はキャッシュで15分保存っぽい
  • git config --listうってcredential.helper=osxkeychainがあればキーチェーンに保存はされる
    • この設定がなければ、git config --global credential.helper osxkeychain
蔀

平文で~/.git-credentialsに保存されてつらい……普通にキーチェーンにあるものを無期限で見てほしい

git config --global credential.helper cache

蔀

watchOS開発記事候補

  • navigationTitleがBindingできない件
  • Xcode CloudでwatchOSアプリのバイナリをあげる
蔀

WristCounterって名前がApp Store上ですでに登録があるらしく、WristCounter Watch Appから変更できなかった。
まあStrore上だけみたいだから、これは妥協するか

蔀

レビュー通ったのでストア公開したら、一瞬「Ready for Sale」になって、「Removed from Sale」にすぐ移動した
これは配信状況で国と地域を設定するの忘れてたからっぽい
価格および配信状況 > 配信可否 を設定する

https://developer.apple.com/forums/thread/84878

このスクラップは2023/05/07にクローズされました