Closed
85

watchOS x SwiftUIで表計算アプリをつくる

↑GraphSampleって名前、よく考えたら表はグラフとは違うので、なんか変な名前だ……
まあでも今更変えるのも大変だからいいか

設計図

NavigationLink(destination: SpreadSheetView()) {
    HStack {
        Spacer()
        Image(systemName: "plus.circle")
        Spacer()
    }
}

これで画面遷移ができる。
A→Bの画面遷移において、BからNavigationBarの戻るで戻ったときはdismiss()に処理を書くと、戻るときに何か処理が書けるみたいだけど、A→Bにはそういう仕組みがないみたい。
何とかできないかな、と思っていたら、onAppear ()を使う方法があるらしい

https://stackoverflow.com/questions/60549028/is-there-a-way-to-add-an-extra-function-to-navigationlink-swiftui

データの持たせ方がよくわかってないな
サンプルアプリではとりあえずアプリ内のどこかで持っていればいいだけ(永続化しなくて良い)なんだけど、だとしてもどこに持てばいいんだろう?

とりあえず初日は画面遷移ができたので、終わり。
明日はデータを持たせてみよう

とりあえず@Stateだと思っていたけど、View内で処理が完結する(アドレスの受け渡しを行わない)データに使うもので、今回俺がやろうとしてることには不適当みたい

↑の本(ブログ)で全体像を示してくれている

とりあえず今つくってるサンプルアプリなら、HomeView@Stateで持たせて、
遷移先に@Bindingつきで使ってもらうことになるか。

ListIdentifiable使って書き直した

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)
        }

NavigationLinkonTapGesture使ったら遷移しなくなったけど、そうかタップイベント食っちゃうのか

この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>'
    }
}

まあプレビューについては本質的じゃないからいいや。今日はここまで。
明日は表本体をつくっていく

いちいちテストデータつくるために書いていた下記を

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すると何が出てくるのかよくわからなくなったので、試した

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と言われるようになった

Sheet
    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
    }

デジタルクラウン対応をやっていこう

とりあえずデジタルクラウン
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について調べていこう

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())
    }
}

DefaultPickerStyle


↓タップすると

InlinePickerStyle


この状態で操作可能

WheelPickerStyle


この状態で操作可能
InlinePickerStyleと同じ

MenuPickerStyle/RadioGroupPickerStyle/SegmentedPickerStyleはwatchOSだと使えず、コンパイルエラーになる

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から抜き差しすると改善する気がする


うーんなんかセルに対してDigital Crownが反応しなくなるときが結構ある。
Edit Modeにして、セルをいじる前に別のところタップして全体をDigital Crown操作したあと、セルをタップしても無視されてしまう。
セル自体を指でスクロールすると動くは動く

この残件は明日

  • 設定画面をつくる(page-based navigation)
  • 行列を増やせる設定画面をつくる(モーダル)

toolbarが無視されるようになった……

  • 最初に返すのは表示したいView、タブに表示するViewをtabItemで返すみたい
  • どこにも書いてないけど、watchOSだとtabItemは無視っぽい

https://developer.apple.com/documentation/swiftui/view/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モディファイアを使う。
モディファイアなのか……

↑モーダル閉じたら勝手にフラグオフにしてくれるのはやってくれるみたい

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

どうやら値更新の際に、SpreadSheetViewSpreadSheetRowで渡してる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が使えないらしいので、ダメかと思ったら、シミュレーターの操作だとドラッグ操作がちょっと難しいというだけで、ちゃんとドラッグ可能にになっていた。

https://developer.apple.com/documentation/swiftui/dynamicviewcontent/onmove(perform:)

デバッグメッセージで死ぬほどこれが出てるのが、行列の追加削除のところの実装のヒントなんだと思われる。が……

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に入る挙動らしい

このスクラップは11日前にクローズされました
ログインするとコメントできます