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

6 min read読了の目安(約5400字

つくりました。
表計算アプリ、というほど大したものではないんですが、表形式で数値をいじるアプリです。
今回のアプリでは、列ごとに合計値を出しています。

このアプリ自体は大して役に立ちませんが、watchOSで何らかの表をつくりたいときは応用が効くかと思います。

ソースコード

https://github.com/0si43/GraphSample

スクリーンショット

細かいことはスクラップで

この表計算アプリは

https://zenn.dev/st43/articles/b32c8a2621700f

という個人プロジェクトの一週目ということではじめました。

一週目としてはめちゃくちゃ重いもので、大変は大変だったんですが、今となっては充実感があります。
この開発中に知ったことをちゃんと書けば、10記事以上書けると思いますが、それもしんどいので、ざっくりしたところをこの記事に書こうと思います。

もし細かいところが気になる方は、Githubのソースコードと、下記のスクラップをご確認ください。
(来週になったら僕も忘れてると思います)

https://zenn.dev/st43/scraps/1cfcc9e6c8379a
https://zenn.dev/st43/scraps/53a6b76498be7c

当初はアプリが実機で動作してるところとってYouTubeにあげてこの記事に貼ろうなどと妄想してたんですが、
全然間に合いそうもないので雑なスクショだけで許してください。

watchOS x SwiftUIという二重苦

watchOSもはじめて、SwiftUIは本一冊浅く読んだだけで使ったことなし、という状態からスタートしました。
まずwatchOS開発はあんまり情報が出てきません。
SwiftUIの情報は、流石に登場から2年ほどたつのでだいぶ充実していますが、それでも「watchOSの場合」となると、かなり限定的でした。
英語検索必須ですね。

watchOS開発のしんどいところ

画面がとにかく狭いです。
全然情報が入りません。

↓は試しに10行 * 10列で出してみた表です。

うーん厳しいですねー。
watchOSの開発やってから、iOS開発に戻るとViewがめちゃくちゃ大きく感じます。

そしてユーザーからの入力手段というのが思った以上にないことにも気づきました。
これは個人的には大きな発見でした。
そもそもソフトウェアキーボードがなくて、数字入力のみで、あとは音声か手書きかというのがApple Watchの仕様です。
(英語だとキーボードっぽいのがある?みたいですが、日本語未対応)

数字入力はデジタルクラウンを回すことでカチカチ増やす想定で、結局これで実装できたんですが、
ダメだったら-/+ボタン(SwiftUIにStepperってやつがあるみたいですね)をつくろうと思っていました。
当初はデジタルクラウンの操作をdigitalCrownRotation()を使おうとして苦しんだんですが、
発想を変えてPickerを置いてみたら、ゴニョゴニョ書かなくてもデジタルクラウンの操作対象になってくれたので嬉しかったです。

ただ前述の通り、画面が狭いので、ボタン1つ配置するだけで骨なので、
基本的には1Viewに対して1操作ぐらいのシンプルさで実装するが吉だと思いました。
(今回のアプリは表がつくりたかったので、真逆の実装になってますけど……)

SwiftUIのレイアウトは基本スタック

SwiftUIについて、UIKitとの対応でついつい考えがちだったんですが、
実際に使ってみると、僕のUIKit脳を変えないといけないなと思いました。

スタックで全部配置して、その中で不都合が出てきたらゴニョゴニョやる、というのが基本になると思います。
ただゴニョゴニョやる、というのも個人的にはやらない方が幸せかなと思いました。
SwiftUIの恩恵をフルで享受するために、レイアウトのピクセル単位の細かい調整は捨てるべきです。

SwiftUIの可能性を感じた

今回SwiftUIでwatchOS向けのアプリをつくったんですが、思った以上に制約がなく、当初実装しようと思ってた機能はほぼほぼできました。
(挫折した点は後述します)

単一のコードベースで、iOS/iPadOS/watchOS/MacOS(/tvOS)に対応する! という夢のような謳い文句でしたが、
プレインなアプリだったらもうできちゃうんじゃないですかね。

ただモバイルアプリ市場も飽和してるので、プレインなアプリだとなかなか勝ち筋がなく、
またそういう用途ならFlutterでクロスプラットフォームにしたいねというのもあると思うので、実際お前がやるか? となると、何とも言えないところではあります。

宣言的UIとは身を委ねること

宣言的UIを触るのが実ははじめてだったんですが、フレームワークに身を委ねることだな、と思いました。
ゴニョゴニョ指定することもできるんですが、ゴニョゴニョやるのは向こう側であって、
こちらは最低限の指示を出せばいい、というのは、最終的にできあがったコードを見たときにちょっと感動がありました。
UI周りのコードに感じていたゴチャゴチャ感が解消されていました。

ただ、一個皮を剥がせば、裏ではSwiftUIの標準Viewがゴニョゴニョ感を吸収しているだけなので、
何か問題が起こったときは結局そのカオスの中に潜っていくことは必要になる予感は感じました。

妥協したとこ

ラフスケッチで描いた設計図がこちらで、ほぼほぼこの通りに実装できました。

ただ妥協した部分も当然あって、それは下記です。

  • フォーカスのコントロール
  • シートを追加して編集モードに遷移する
  • 行の削除/列の追加削除

詳述していきます。

フォーカスのコントロールができない?

スクショを見ていただくと、3 x 3の表の、各行の一列目の要素にカーソル当たってるっぽい表示になっているんですが、
実際にカーソルがあたっている要素はその中の一つです。
「その中の一つ」と書いてるのは、僕もどこに当たるのかがいまだによくわかってないためです。
一番最初/最後に生成した行に当たってるのかと思うと、どうもそうでもないようで、よく法則性がわかりません。

シートを追加して編集モードに遷移する

最終的に、シート一覧画面(HomeView)で、プラスボタンを押すと、新しいシートが作成できる、という仕様になりました。
本当は押したときに新しいシートを開いて、すぐ編集できる状態にしたかったんですが、この簡単そうな動作が案外実装するのが難しかったです。

struct HomeView: View {
    @State var sheets = [Sheet()]
    
    var body: some View {
        return List {
            ForEach(0..<sheets.count, id: \.self) { index in
                NavigationLink(destination: SpreadSheetTabView(sheet: self.$sheets[index])) {
                    SheetThumbnailRow(dateAndTime: self.sheets[index].dateAndTime)
                }
            }
            .onMove { indices, newOffset in
                self.sheets.move(fromOffsets: indices, toOffset: newOffset)
            }
            .onDelete { indexSet in
                self.sheets.remove(atOffsets: indexSet)
            }
            HStack {
                Spacer()
                Image(systemName: "plus.circle")
                Spacer()
            }
            .listRowPlatterColor(.red)
            .onTapGesture {
                self.sheets.append(Sheet())
            }
        }
        .navigationTitle("All Sheets")
    }
}

このViewの

.onTapGesture {
  self.sheets.append(Sheet())
}

でシートの追加をして、シートの配列には@Stateがついているので、Viewが更新されます。
UIKitだったら、 タップしたときに反応するメソッドを一個つくって、
そのメソッドで配列のappend, その要素を次の画面に渡しながらプッシュ遷移、とやれば以上終了、の何も難しくないことなんですが、
SwiftUIだと@Stateで配列を状態監視している関係で、案外難しかったです。

無理矢理実装するなら、遷移先のViewに新規作成フラグを持たせて、
trueで渡されたら、遷移先のViewのonAppearでシートの配列に新規追加して、その要素でViewをつくる……と思ったんですが、
このやり方だと遷移先のViewはもう表示されてるので、親のViewと同じ問題が起きますね。
(更新したい配列を更新するたびにViewの更新が走るので永遠に更新し続ける)

行の削除/列の追加削除

これも状態監視している要素の追加/削除に絡んで挫折したところなんですが、
シートが持っている行・列を追加削除すると、それに対応して要素も追加削除される仕様にするつもりでした。

プロパティオブザーバーのwillSetで行、列を監視しておいて、増減があったら値の配列を追加削除するというロジックにしました。
ロジック自体は上手く動いたんですが、値をBindingしていたViewの更新のところでなぜか上手く行番号/列番号が連携してくれなくて、配列の範囲外エラーでクラッシュしてしまいました。

行の追加については上手く動いてくれたので、これだけ追加しました。

データの状態監視して、変更があればViewを更新してくれる仕組みはすごいと思いましたが、いいことだけではないですね。

来週のOne Week Challenge

今週はちょっと負担大きかったですね。。。
本業がiOS開発なので、勘所あるので、そんなでもないかなと思ってましたが、そんなことはありませんでした。

来週はガクッとハードルを落として、「Railsチュートリアルをやる」というテーマにします。
来週チュートリアルやって、再来週でちょっとしたアプリをつくる、という感じの流れで。