watchOS x SwiftUIで表計算アプリをつくる
ここで開発していく
↑GraphSampleって名前、よく考えたら表はグラフとは違うので、なんか変な名前だ……
まあでも今更変えるのも大変だからいいか
設計図
NavigationLink(destination: SpreadSheetView()) {
HStack {
Spacer()
Image(systemName: "plus.circle")
Spacer()
}
}
これで画面遷移ができる。
A→Bの画面遷移において、BからNavigationBarの戻るで戻ったときはdismiss()
に処理を書くと、戻るときに何か処理が書けるみたいだけど、A→Bにはそういう仕組みがないみたい。
何とかできないかな、と思っていたら、onAppear ()
を使う方法があるらしい
データの持たせ方がよくわかってないな
サンプルアプリではとりあえずアプリ内のどこかで持っていればいいだけ(永続化しなくて良い)なんだけど、だとしてもどこに持てばいいんだろう?
とりあえず初日は画面遷移ができたので、終わり。
明日はデータを持たせてみよう
この本の6章がデータ管理について全体像を示してくれていた
ブログでも同内容を公開してくれていた
とりあえず@State
だと思っていたけど、View内で処理が完結する(アドレスの受け渡しを行わない)データに使うもので、今回俺がやろうとしてることには不適当みたい
↑の本(ブログ)で全体像を示してくれている
とりあえず今つくってるサンプルアプリなら、HomeView
に@State
で持たせて、
遷移先に@Binding
つきで使ってもらうことになるか。
List
をIdentifiable
使って書き直した
let sheets = [
Sheet(date: Date(), row: 1, column: 1),
Sheet(date: Date(), row: 1, column: 1),
Sheet(date: Date(), row: 1, column: 1)
]
return List(sheets, rowContent: SheetSummaryRow.init)
これで
return List {
ForEach(0..<sheets.endIndex) { index in
SheetSummaryRow(sheet: sheets[index])
}
}
と同じ意味になるのはかなり気持ち悪い
return List(sheets, rowContent: SheetSummaryRow.init)
この書き方はエレガントだけど、今回のケースの場合、最後の行を別途つくりたいので、使えなさそう。
→いやVStack
使えば使えるか
return VStack {
List(sheets) {
SheetSummaryRow(sheet: $0)
}
NavigationLink(destination: SpreadSheetView()) {
HStack {
Spacer()
Image(systemName: "plus.circle")
Spacer()
}
}
.listRowPlatterColor(.gray)
}
VStack
だとこんな感じのViewになる。
追加ボタンを常に表示しておきたいならこれもありだけど、俺のイメージとは違うかな
データの配列に対して、それと一対一で紐づくList
のつくり方はわかったけど、そのList
の最後に+ボタンを一行入れたい。
List
のイニシャライザをRangeで指定して、最後のときに+ボタンにする他ないのかな
結局これで行く
return List {
ForEach(0..<sheets.count) {
SheetSummaryRow(sheet: sheets[$0])
}
NavigationLink(destination: SpreadSheetView()) {
HStack {
Spacer()
Image(systemName: "plus.circle")
Spacer()
}
}
.listRowPlatterColor(.red)
}
NavigationLink
にonTapGesture
使ったら遷移しなくなったけど、そうかタップイベント食っちゃうのか
このonTapGesture
でとりあえずシートを1つ増やす処理が書きたかった
return List {
ForEach(0..<sheets.count) {
SheetSummaryRow(sheet: sheets[$0])
}
NavigationLink(destination: SpreadSheetView()) {
HStack {
Spacer()
Image(systemName: "plus.circle")
Spacer()
}
}
.listRowPlatterColor(.red)
.onTapGesture {
self.sheets.append(Sheet(date: Date(), row: 1, column: 1))
}
}
が、エラー。
ForEach<Range<Int>, Int, SheetSummaryRow> count (1) != its initial count (0). `ForEach(_:content:)` should only be used for *constant* data. Instead conform data to `Identifiable` or use `ForEach(_:id:content:)` and provide an explicit `id`!
どうやらForEach
にidを渡してなかったのでエラーになっていた模様。
SwiftUIのForEach
はIDを使って一意性を保証しないとループできないらしい
(↑この辺よく理解できてない)
ForEach(0..<sheets.count, id: \.self) {
SheetSummaryRow(sheet: sheets[$0])
}
とりあえずこれでシートが追加できるようになった
@Bindingを使ったプロパティを持っているViewは、Previewが死ぬことに気づいた
import SwiftUI
struct SheetSummaryRow: View {
@Binding var sheet: Sheet
var body: some View {
HStack {
Image(systemName: "squareshape.split.3x3")
.foregroundColor(.green)
Divider()
Text(String(sheet.dateAndTime))
}
}
}
struct SheetSummaryRow_Previews: PreviewProvider {
static var previews: some View {
let sheet = Sheet(date: Date(), row: 1, column: 1)
SheetSummaryRow(sheet: sheet) // Cannot convert value of type 'Sheet' to expected argument type 'Binding<Sheet>'
}
}
プレビュー用のラッパーをつくれと言われてるけど、マジかって感じだ。
まあプレビューについては本質的じゃないからいいや。今日はここまで。
明日は表本体をつくっていく
ワークアウトアプリみたいな、3画面を横にスワイプして移動するようなナビゲーションをpage-based navigationと呼ぶらしい
実はリングが他アプリでも取れる。
(まあ今回は使いどころないけど)
ページ方式の移動について
いちいちテストデータつくるために書いていた下記を
Sheet(date: Date(), row: 1, column: 1)
引数なしのイニシャライザにしてやった。便利
init() {
self.date = Date()
self.row = 1
self.column = 1
let innerArray = Array(repeating: 1, count: column)
self.value = Array(repeating: innerArray, count: row)
}
さて今日はいよいよ表形式のViewを正式につくっていく
@Binding
を使えた
struct HomeView: View {
@State var sheets = [Sheet]()
var body: some View {
return List {
ForEach(0..<sheets.count, id: \.self) { index in
NavigationLink(destination: SpreadSheetView(sheet: self.$sheets[index])) {
SheetThumbnailRow(dateAndTime: self.sheets[index].dateAndTime)
}
}
HStack {
Spacer()
Image(systemName: "plus.circle")
Spacer()
}
.listRowPlatterColor(.red)
.onTapGesture {
self.sheets.append(Sheet())
}
}
.navigationTitle("All Sheets")
}
}
struct SpreadSheetView: View {
@Binding var sheet: Sheet
var body: some View {
List {
HStack {
Image(systemName: "person")
Spacer()
Text(String(sheet.value[0][0]))
}
.listRowPlatterColor(.green)
}
.navigationBarTitle(Text("表"))
.onTapGesture {
sheet.value[0][0] += 1
}
}
}
ポイントは
SpreadSheetView(sheet: self.$sheets[index])
@Binding var sheet: Sheet
Array
の要素を渡してるので、Bindingできるのか?と思っていたけど、できていた。
Viewを跨いでも値が更新されている。
また値が更新されたらViewも更新される。
ForEachをネストするときはGroup
を使うといいらしい
ふと二次元配列をforEach
すると何が出てくるのかよくわからなくなったので、試した
let nestedArray = [[1, 1, 1], [1, 1, 1], [1, 1, 1]]
nestedArray.forEach { print($0) }
[1, 1, 1]
[1, 1, 1]
[1, 1, 1]
Sheet
にこんな便利メソッド追加したら、The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
と言われるようになった
func sumColumn(column: Int) -> Int {
guard 0 < values[0].count else { return 0 }
let columnValues = values.map { $0[column] }
return columnValues.reduce(0) { $0 + $1 }
}
結局SpreadSheetView
側にこんなコンピューテッドプロパティをつくった
private var sumColumns: [Int] {
return sheet.values.map { $0.reduce(0) { $0 + $1 } }
}
一行でかけてスタイリッシュだったが、これだと行のsumを出してしまう。列のsumを出したい
結局こうしました
private var sumColumns: [Int] {
var sums = [Int]()
(0..<sheet.column).forEach { column in
var sum = 0
(0..<sheet.row).forEach { row in
sum += sheet.values[row][column]
}
sums.append(sum)
}
return sums
}
デジタルクラウン対応をやっていこう
どうやらこんな感じでできるらしい
どうやらBinding対象の数値はFloat/Doubleになる模様。
min/maxを設定するならこちら
struct SpreadSheetRow: View {
var rowNumber: Int
@State var temp = 0.0
@Binding var values: [[Int]]
var body: some View {
// HStack {
// Text(String(rowNumber))
// Divider()
// Spacer()
// ForEach(0..<values[rowNumber].count) { columnNumber in
// Text(String(values[rowNumber][columnNumber]))
// .onTapGesture {
// values[rowNumber][columnNumber] += 1
// }
// Spacer()
// }
// }
Text(String(temp))
.focusable()
.digitalCrownRotation($temp,
from: 0.0,
through: 15.0,
by: 1.0,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true)
}
}
フォーカスをどうしたらいいんだ?
フォーカスを移動する方法がわからない。
今日はここまで。
骨組みはかなりできた。
Digital Crown、既にやりづらさも感じているので、場合によっては操作方法の変更を検討しよう。
あとはpage-based navigationの実装方法を考える。
そのぐらいか。終わりが見えてきた
focusについて調べていこう
この英語ブログを読み解いていく
Text
の.border
はフラグに応じた出しわけができなさそう
.overlay
を使うと意図したことができる
.overlay(isFocused ? RoundedRectangle(cornerRadius: 10).stroke(Color.red, lineWidth:1) : nil)
SwiftUIのfocusの操作全然わからない
watchOSとtvOSで@Namespace
を使って.prefersDefaultFocus
というmodifierがあるようだけど、この挙動も理解できない。
Picker
を使うことでやりたいことが実現できた。こっちにする
このようなコードで、タップしたらそこをDigital Crownで操作するようなセルがつくれる!
struct SpreadSheetRow: View {
var range = 1..<100
@State var temp = 0
var body: some View {
Picker("sample", selection: $temp) {
ForEach(range) {
Text(String($0))
}
}
.pickerStyle(InlinePickerStyle())
}
}
PickerStyle
による違いを試した
DefaultPickerStyle
↓タップすると
InlinePickerStyle
この状態で操作可能
WheelPickerStyle
この状態で操作可能
InlinePickerStyle
と同じ
MenuPickerStyle
/RadioGroupPickerStyle
/SegmentedPickerStyle
はwatchOSだと使えず、コンパイルエラーになる
Labelを隠したい、と思っていたが、.labelsHidden()
というのがある模様
labelsHidden()
、Picker
のドキュメントになかったと思ったら、View
のメソッドみたい
Pickerを使うことで表計算の編集画面ができた。
focusがrowごとについちゃうけど、これがNameSpace使うと解決できるのかな?
親ViewからNamespaceを渡して、.focusScope(namespace)
でfocusする空間を共有できるのでは、と思ったが、変わらなかった
うーんちょっと対策が見つからない。
わかりづらいってだけで実用上は問題ないからこれで進めるかあ
+ボタンをタップしたら新シートをつくって、新しいViewを開くという処理を書く
こんなメソッドを生やしてみた
func newSheet() -> Binding<Sheet> {
sheets.append(Sheet())
return $sheets[sheets.count - 1]
}
しかしビルドは通ったが、新しいシートがつくられなかった。残念
Modifying state during view update, this will cause undefined behavior.
配列に追加して画面遷移する、というただこれだけのことがこんな難しいのか……
無理やりやるんならやりようはあるけど、どうしても強引になっちゃうな。
まあ+ボタンタップすると一つシートが増えて、そのシートを選んで編集に行く、という流れでも問題はないか。
理想的には+ボタンからシート編集に行って欲しいけど……
残件は3つ
- 閲覧モードと編集モードをつくる
- 設定画面をつくる(page-based navigation)
- 行列を増やせる設定画面をつくる(モーダル)
Apple Watchの実機デバッグ、高確率で失敗するけど、
原因はほぼiPhone - Apple Watch間の接続不良な気がするので、
iPhoneから何かしらApple Watchに対する操作して、iPhoneをMacBookから抜き差しすると改善する気がする
navigationBarItems(leading:trailing:)
が追加できなくて苦しんでいたが、toolbar(content:)
に改名されていた
うーんなんかセルに対してDigital Crownが反応しなくなるときが結構ある。
Edit Modeにして、セルをいじる前に別のところタップして全体をDigital Crown操作したあと、セルをタップしても無視されてしまう。
セル自体を指でスクロールすると動くは動く
この残件は明日
- 設定画面をつくる(page-based navigation)
- 行列を増やせる設定画面をつくる(モーダル)
Storyboard使わないとできない?
こんな実装もあるよう
Tabview
を使ったらできた
toolbar
が無視されるようになった……
- 最初に返すのは表示したいView、タブに表示するViewをtabItemで返すみたい
- どこにも書いてないけど、watchOSだとtabItemは無視っぽい
struct View1: View {
var body: some View {
Text("View 1")
}
}
struct View2: View {
var body: some View {
Text("View 2")
}
}
struct TabItem: View {
var body: some View {
TabView {
View1()
.tabItem {
Label("Menu", systemImage: "list.dash")
}
View2()
.tabItem {
Label("Order", systemImage: "square.and.pencil")
}
}
}
}
そうか。TabView
を導入した時点で、TabView - SheetView というヒエラルキーになるから、
toolbarを表示したければ、TabViewに指定してやらないとダメなのか。
SpreadSheetView
の閲覧/編集をトグル一個で切り替えられるようにしているが、
その切り替え時にフォーカスがあやしくなるのがやはり気になる。
別ページにしたら改善するかな? 試してみよう
試してみたが、あまり当たり判定は変わらない。
むしろ親ページ入れて、トグルでモード切り替える状態の方が反応がいい気がする。
当初のデザイン的にもそれが近いので、それで進めよう
ボタン一つだけの設定画面をつくろう(将来的にボタンが何個かできるイメージ)としていて、
デフォルトだと真ん中寄せになるので、frame調整だとちょっとめんどくさいので、
VStackとSpacer()で調整しようとして、Text()
とSpacer()
だけだと、めちゃくちゃ上になっちゃうので、
どうしたもんかと思っていたらSpacer()の個数を増やすとちょうど良くなった。
こんな方法もあるんだね
struct SheetSettingView: View {
var body: some View {
VStack {
Spacer()
Text("Change Setting")
.padding()
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.green, lineWidth: 2)
)
Spacer()
Spacer()
Spacer()
Spacer()
}
.navigationBarTitle(Text("Setting"))
}
}
モーダル遷移にはsheetモディファイアを使う。
モディファイアなのか……
@State private var isShowingSheet = false
的な状態変数が要るのね
↑モーダル閉じたら勝手にフラグオフにしてくれるのはやってくれるみたい
Sheet
にこんなメソッドを追加してみた
var row: Int { didSet { adjustArray(row: row) }}
var column: Int { willSet { adjustArray(column: newValue) }}
mutating func adjustArray(row: Int) {
guard 0 < row, row != values.count else { return }
if row < values.count {
(row..<values.count).forEach {
values.remove(at: $0)
}
} else if values.count < row {
(values.count..<row).forEach {_ in
let innerArray = Array(repeating: 1, count: column)
values.append(innerArray)
}
}
print(self)
}
SpreadSheetRow
のここでArrayの範囲外エラーでクラッシュした……
ForEach(0..<values[rowNumber - 1].count) { columnNumber in
どうやら値更新の際に、SpreadSheetView
→SpreadSheetRow
で渡してるrowNumber
が古くて、配列の範囲外になる。
3->2への更新で、3を渡していた。
行の削除は諦めて、行の追加だけ動かすようにした。
それでもViewの更新ができてないときがある。
TabViewで並列にしている子View同士は更新スキップする何らかの処理があるのかな……
一度HomeViewまで戻って再びシート開くと行が必ず更新される。ここはもう謎
行と列の追加削除は、行の追加だけが実装できた。
行の削除、列の追加削除は挫折。
Listの要素の削除はこれでできる
.onDelete { indexSet in
self.sheets.remove(atOffsets: indexSet)
}
.onMove
も実装完了。
.onMove { indices, newOffset in
self.sheets.move(fromOffsets: indices, toOffset: newOffset)
}
watchOSだとEditButton
が使えないらしいので、ダメかと思ったら、シミュレーターの操作だとドラッグ操作がちょっと難しいというだけで、ちゃんとドラッグ可能にになっていた。
デバッグメッセージで死ぬほどこれが出てるのが、行列の追加削除のところの実装のヒントなんだと思われる。が……
ForEach<Range<Int>, Int, SpreadSheetRow> count (4) != its initial count (3). `ForEach(_:content:)` should only be used for *constant* data. Instead conform data to `Identifiable` or use `ForEach(_:id:content:)` and provide an explicit `id`!
実機で操作していて気づいたが、.onMove
はどうやら長押しでEditModeに入る挙動らしい