watchOS 6 の注目点をふまえてSwiftUIでWatch Appを作ってみた!!

## はじめに
昨年の Advent Calendar で **日報ちゃん**という
Apple Watch の App を作りました。 
出社，お昼ごはん，退社などの各アクティビティを
Slack の勤怠チャンネルに投稿するというわざわざスマホやPCを使わずに
手首の操作で完結するアプリです。

WWDC19 で watchOS に大もきな変更がありました。
また，SwiftUI も発表されたので SwiftUI で作ってみようと思い取り組んでみました。

7月の iOS 開発合宿で作り始めたのですがしばらく放置されてしまってました。
Qiita Advent Calendar のおかげで **勤怠ちゃん** としてようやく形にできました。

## watchOS 6 のここに注目！
### watchOS 6 から iPhone 側のアプリ実装が不要になった

watchOS 5 までは iPhone 側のアプリ実装が必要でしたが，
Watch App が単体で開発可能になりました。
iPhone 側のコンテンツありきの開発であったため，より自由な尖ったものを開発できます。

Xcode で新規プロジェクトを作る際に Watch App だけで開発を開始できます。
<img width="732" alt="00_Independent_app.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/249104eb-2eb6-0e58-4797-a112fe4e784f.png">

### 入力インターフェースにキーボードオプションが増えた

Apple Watch 単体でアプリが動くようになったのに加え，
残念だった入力インタフェースにキーボードオプションが追加されました。
この発表があったとき，ひとりで会場で感動してました。

|キーボードオプション|iPhoneなどで入力可能|
|:--:|:--:|
|![01_](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/4386d49b-b47f-188d-a981-1b1951f75470.png)|![02_input_on_iphone.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/77287bda-ffd0-1c4b-c1a3-cd0523afa7cf.jpeg)|

ペアリングしている iPhone でキーボード入力する形になります。
Apple Watch 側でキーボードを選択すると通知が行き，ロック画面でも入力可能です。
ありそうでなかった機能です！

昨年の日報ちゃんは，アクティビティリストと Slack への投稿に
必要な情報(Webhook URL など)を iPhone 側で入力し，
Watch Connectivity の機能を使って Apple Watch に送っていましたが
これが不要になります。(iPhone 側のアプリいらない😎)

### SwiftUI が使える

Watch App の UI 実装はとても窮屈でした。
初めから SwiftUI みたいな開発が可能であるべきだったと感じました。
StackViewに色々コンポネントを突っ込むパズルみたいな感覚でしたよね😅
watchOS 6 以降に絞れば，SwiftUI で開発可能です。

iOS 側のアプリはまだ iOS 12 未満は切れないことも多いと思うので，
SwiftUI でアプリを作ってリリースするという知見を得るのも
先行して可能だと思っています。
2，3年後いきなり案件で使うとなるとさすがに厳しいと思います。
Combine も同じことが言えるかもしれません。自戒を込めて。

## 日報ちゃん

昨年の Advent Calendar のネタに作った iOS/watchOS アプリで
出社やお昼休憩などといったアクティビティを Apple Watch の簡単な操作で
Slack の勤怠チャンネルに投稿できます。

|Watchで選択|Slackに投稿|
|:--:|:--:|
|![03_watch_nippochan.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/c5d99fd1-cb6a-eca3-cdab-1297170930a5.png)|<img width="676" alt="04_nippochan_slack.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/1c3f3d73-df7d-4fe5-74e7-7839dd58d0f5.png">|

スマホや Mac の Slack アプリをわざわざ起動することなく
**手首で完結する** というのがウリです。

iPhone 側で投稿に必要な情報の入力，アクティビティの登録を行い，
Watch Connectivity の機能を使って Apple Watch 側にデータを渡しています。
Picker に表示されたリストからアクティビティを選んでボタンを押すと投稿完了です。
もしよろしければ，昨年の記事の方をご覧ください。

**【オフィスハック】Apple Watchを使って日報の各アクティビティをSlackに投稿させてみる**
[https://qiita.com/MilanistaDev/items/b97cab77d6add96c96dc](https://qiita.com/MilanistaDev/items/b97cab77d6add96c96dc)

## 今回作るアプリ

昨年作った日報ちゃんアプリと同じ機能を持ったアプリを作ります。
新規性は下記になります。

* Watch App Only
  - Watch では新しく追加された `TextField` を使いiPhone側で入力していたSlack投稿に必要データをWatch 側だけで完結させる
* SwiftUIを使う
  - UI は SwiftUI で実現
  - `List` のカルーセルタイプを使ってみる
  - `@ObservedObject` と `ObservableObject` と `@Published` を使う

画面数はアラートなどを除くと下記の6画面です。

* **アクティビティリスト画面**
* **アクティビティ編集画面**
* **設定一覧画面**
* **アクティビティ追加画面**
* **Slack投稿に必要な情報入力画面**
* **アプリバージョン画面**

`Picker` からの選択がカルーセル形式のアクティビティリストからの選択に
変わるだけで投稿したいアクティビティを投稿するというのは変わりません。

iPhone 側で入力していたデータ(アクティビティと Slack 投稿に必要なもの)は
Watch 側で入力します。Slack に投稿する際は `URLSession` を用います。
よって iPhone 側との通信は不要ですので Watch App だけで開発します。

Slack の Webhook URL の取得は下記リンクからできます。
投稿時のアイコンとユーザネームはここで設定します。
[https://slack.com/services/new/incoming-webhook](https://slack.com/services/new/incoming-webhook)

## 実装

### 開発環境

* macOS Catalina10.15.1
* Xcode 11.2.1
* iOS 13 or iPadOS 13 and later (文字入力補助のため)
* watchOS 6 and later 

### このアプリのコード

GitHub にコードがあります。
気になったらインストールして見てみてください。
コードは汚いです。改善点ばかりです。
こうした方がいいなどアドバイスいただけると勉強になります🙇‍♂️

https://github.com/MilanistaDev/NippochanSwiftUI

今回は，画面ごとに特徴ある実装だけ紹介して
コードの説明は絞っていきたいと思います。

### 画面

#### アクティビティリスト選択画面[^1]

SwiftUI の `List` で Watch だけで使えるカルーセルを使ってみます。
カルーセルはすごくカッコいいので Watch App では積極的に使っていきたいです。
セルごとに止まるような感じです。`CollectionView` とかでよく実装するやつですね。
デジタルクラウンをグリグリした際の触覚フィードバックがクセになります☺️

```swift:SendActivityView.swift
var body: some View {
    List {
        // カルーセルビューにデータを渡す処理
    }
    .listStyle(CarouselListStyle())
}
```

リストのタップで Slack の投稿アクションとなります。
デフォルトの勤怠系アクティビティデータセットが用意されています。
![08_Carousel.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/46e1e319-d1dc-2c43-0e29-0ef6a5e4d376.gif)
ユーザが追加できるアクティビティは編集・削除可能です。
カルーセルの右側にある画像をタップするとアクティビティ編集ができます。
左側にスワイプするとリストから削除できます。
1番最後は設定用のカルーセルで設定画面に遷移します。編集・削除不可です。

`List` の削除はこんな感じで書けますね。移動(`onMove`)も実装した方がいいかもです。

```swift:SendActivityView.swift
var body: some View {
    List {
        // カルーセルビューにデータを渡す処理
    }
    .onDelete { index in
        // 該当の index のデータを削除 
        self.activityData.remove(at: index)
    }
}
```

編集・削除の動きはこんな感じです。
![09_edit_and_delete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/d7531a9b-d1b1-5f9f-532b-10e563f55870.gif)

#### 設定画面[^2]

設定画面は静的なリスト形式としカルーセルにはしていません。
実際は `VStack` で `NavigationLink` の `Text` View を 3つ並べてるだけです。
destinationView にそれぞれ　**アクティビティ追加画面**，**Slack 投稿用の情報入力画面**，
**アプリバージョン画面** の View を渡しているのでそれぞれ Push 遷移します。
![10_Settings.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/d22a582f-8c0e-44e4-aad4-a9355c617272.gif)

#### アクティビティ追加/編集画面[^3]

こちらはアクティビティの追加・編集機能で同じ View が使われます。
トップのリスト画面に表示するアクティビティリストに
新しいアクティビティを追加したり既存のアクティビティの編集ができます。
`VStack` と `HStack` を組み合わせる作ってて楽しい画面のひとつです。

日報ちゃんでは iPhone 側で入力していたところですが
watchOS 6 からキーボード入力ができるようになったため Watch 側で実装しています。
アクティビティ名と絵文字を設定して下部のボタンをタップするだけで編集・追加完了です。

追加の場合は，`ActivityModel` をひとつ作成してデータの Array に追加しています。
編集の場合は，リスト画面のセルタップで該当の `ActivityModel` を
この画面に渡してデータの再追加をしています。
![11_Add_Activity.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/06dbd214-f4af-a5b0-d309-5490c2f42a60.gif)

#### Slack 設定画面[^4]

この画面で Slack 投稿時に必要な情報を入力し，送信時のパラメタとして用います。
同じくテキスト入力はペアリングしている iOS 13 の iOS 端末で行います。

入力するのは，Slack の Webhook URL と GitHub のアカウント名，
ユーザネームとお好きな色コードです。
これらの情報は Slack に投稿した際の情報に使われます。

それぞれの項目に説明画面を ? ボタンをタップしたらモーダルで表示します。 
![12_Slack_Settings.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/423baa96-0cbc-0eff-91a7-759e21e49162.gif)

最初は VStack の中に `Text`，`Button`，`TextField` を
ひたすら並べて作ってから共通化できないかを考えるといった感じでした。
(SwiftUI でよくある，複雑，曖昧エラーでビルド通らなくなった😵)

この画面のそれぞれの項目のように，
項目名，？ボタン，`TextField` のブロックが同じ場合は，
下記のように共通化して変化する部分は値渡しなどで対応しました。

```swift:SlackSettingContentsView.swift
// 各項目の識別をenumなどで表現
enum SettingType {
    case webhook
    case github
    case userName
    case favColor
}

struct SlackSettingContentsView: View {

    var title: String           // 項目名
    var placeHolder: String     // TextField の Placeholder
    @Binding var text: String   // TextField の入力値(親Viewへ通知)
    var type: SettingType
    @State var isPresented = false  // for help button

    var body: some View {
        Group {
            HStack {
                // 項目名
                Text(title)
                    .font(.subheadline)
                    .padding(Edge.Set.top, 2.0)
                // ボタンとアクション処理
                Button(action: {
                    self.isPresented.toggle()
                }) {
                    Image(systemName: "questionmark.circle.fill")
                }
                .frame(width: 20.0, height: 20.0)
                .cornerRadius(10.0)
                .sheet(isPresented: $isPresented, content: {
                    // タイプによって説明画面を出しわけ
                    ScrollView {
                        Text(self.showDescription(type: self.type))
                            .foregroundColor(self.textColor(type: self.type))
                    }
                })
            }
            // 項目名とボタンの下の TextField
            TextField(placeHolder, text: $text)
        }
    }
}
```

こうすると元の画面はシンプルになりました。
まだ， `ForEach` 使ってもっと簡単に書けそうな気もします。

```swift:Slack
struct SlackSettingsView: View {

    @State private var webHookUrl: String = ""
    @State private var gitHubLink: String = ""
    @State private var userName: String = ""
    @State private var favoriteColorHex: String = ""

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                SlackSettingContentsView(title: "Webhook URL*",
                                         placeHolder: "https://hooks.slack...",
                                         text: $webHookUrl,
                                         type: .webhook)
                SlackSettingContentsView(title: "GitHub's account*",
                                         placeHolder: "JohnnyDev",
                                         text: $gitHubLink,
                                         type: .github)
                SlackSettingContentsView(title: "Your Name",
                                         placeHolder: "John Smith",
                                         text: $userName,
                                         type: .userName)
                SlackSettingContentsView(title: "Favorite Color",
                                         placeHolder: "009944",
                                         text: $favoriteColorHex,
                                         type: .favColor)
                // 省略
            }
        }.navigationBarTitle(Text("Slack Settings"))
    }
}
```

#### アプリバージョン画面[^5]
こちらは現在のアプリのバージョンを表示するだけの画面です。
SwiftUI 的に特別な実装はありません。
![13.appversion.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/2fc0229d-ac4a-e8cc-74c5-542745a0e438.png)

### データの管理

実装を簡単にするために User Defaults にアクティビティリスト，
Webhook URL，GitHubアカウント，ユーザネーム，カラーコードを保存しています。
データの扱いは課題です。
データの操作は View 側でやりたくない。

### 通信周り[^6]

このアプリで通信が発生するのはユーザがアクティビティリストから
アクティビティを選んでタップしたときのみです。
リストの 1番下の設定画面への遷移用のカルーセルビューは `NavigationLink` を
使い，先に述べた通り該当の View へ Push 遷移する実装を行っています。
アクティビティのカルーセルビューは `Button` タップで Slack に投稿するように実装しています。

通信が終わったら結果を受けて，成功 or 失敗アラートを表示させます。
アラートを表示するかどうかのフラグ値(`isPresented`)は
今回の場合すぐに `toggle` 関数で切り替えず，通信が終わった際に切り替えたいです。
また，アラートに表示するタイトル，メッセージも同様なので
通信クラスに `ObservableObject` プロトコルに準拠させて，
それぞれの値の変化を通知してもらうことにします。

通信クラスの実装を行います。
`URLSession` を使います。ここは一般的な通信処理を書きます。
`@Published` をつけてプロパティを宣言してイベント発行可能にします。
通信結果のところで，そのプロパティを更新するだけです。
通信結果によって，イベントが発行されます。

```swift:PostActivity.swift
final class PostActivity: ObservableObject {

    // @Published をつけて宣言して値の変化のイベント発行可能にする
    @Published var isShowDialog = false // アラートを表示させるかのフラグ
    @Published var isSuccess = false    // 投稿成功か失敗かのフラグ
    @Published var message = ""         // 投稿成功or失敗時のエラーメッセージ

    /// Post User's Activity to Slack
    /// - Parameter activity: User's Activity
    func post(activity: ActivityModel) {
        let webhookUrl = {SlackのWebhook URLを取得}

        var request = URLRequest(url: URL(string: webhookUrl)!)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")

        // generate payload Json
        let params = PostJsonGenerator.generate(activityName: activity.name)

        do {
            request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
        } catch {
            self.isShowDialog = true
            self.isSuccess = false
            self.message = error.localizedDescription
        }

        let task = URLSession.shared.dataTask(with: request)
        { (data: Data?, response: URLResponse?, error: Error?) in
            if let error = error {
                self.isShowDialog = true
                self.isSuccess = false
                self.message = error.localizedDescription
                return
            }
            guard let _ = data, let response = response as? HTTPURLResponse else {
                self.isShowDialog = true
                self.isSuccess = false
                self.message = "No data or No response."
                return
            }
            if response.statusCode == 200 {
                self.isShowDialog = true
                self.isSuccess = true
                self.message = "Posted your activity!"
            } else {
                self.isShowDialog = true
                self.isSuccess = false
                self.message = "Status Code: " + String(response.statusCode)
            }
        }
        task.resume()
    }
}
```

リスト画面で通信クラスを宣言する際に `@ObservedObject` をつけます。
これで通信クラスのプロパティの変化，イベントを受け取れます。
通信が終わって `$postActivity.isShowDialog` が `true` になると
Slack 投稿成功 or 失敗アラートが表示されます。

```swift:SendActivityView.swift
struct SendActivityView: View {

    @State private var activityData: [ActivityModel] = []
    // 通信クラス宣言(イベント発行を受けられる)
    @ObservedObject var postActivity = PostActivity()

    var body: some View {
        List {
            ForEach(self.activityData){ activity in
                // 設定の場合は設定リスト画面へPush遷移
                if activity.name == "Settings" {
                    NavigationLink(destination: SettingsView()) {
                        ActivityCarouselView(activity: activity)
                    }
                } else {
                    // アクティビティの場合はボタンタップでSlackに投稿
                    Button(action: {
                        // Post actiity to Slack
                        self.postActivity.post(activity: activity)
                    }) {
                        ActivityCarouselView(activity: activity)
                    }
                }
            }
        }
        .alert(isPresented: $postActivity.isShowDialog) { () -> Alert in
            Alert(title: Text(postActivity.isSuccess ? "Success": "Failure"),
                  message: Text(postActivity.message),
                  dismissButton: .default(Text("OK")))
        }
        .listStyle(CarouselListStyle())
        .navigationBarTitle(Text("Activity List"))
        .onAppear {
            self.activityData = self.udConfig.loadActivityList()
        }
    }
}
```

### Complication

今回は Graphic Circular のみ画像設定だけしています。
これで Watch Face から起動できますね。(しごとにんげんと勘違いさせられます)
![14_complication.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/f311c868-1bb1-83fc-1923-3341c2444a1f.png)

## Slack にアクティビティを投稿

成功ダイアログが出るまでがすごく遅いのが気になります。
が，うまく投稿できています。
![15_PostActivity.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/8312deb7-ff7c-02d8-b25d-1a41b2e10c08.gif)


## watchOS での SwiftUI・開発で思ったコト

### `ScrollView` は必須？

画面が狭いので `ScrollView` が基本必要です。
44mm の Apple Watch だけで開発していてできたと思っても
40mm の Apple Watch で見切れていることもよくあります。
ネストがひとつ増えるのがちょっと嫌ですね。

### ライフサイクルの意識

今までの Watch App 開発もそうでしたが，
iOS のライフサイクルとは違うことを意識しないといけないです。
`onAppear()` など予期せぬところで呼ばれたりします。
入力インタフェースリスト画面が開いて戻ってきただけで呼ばれたりします。

### ビルド環境改善

今まで中々実機でビルドできない，そもそもインストールできないことも
多かったですが WWDC19 でも言ってた通りだいぶ改善されているように感じました。
あとは，Quick Time で画面収録できるようになったら嬉しいなぁ。

### SF Symbols

watchOS 6 以降であればアイコン画像の代わりに **SF Symbols** が使えます。
明らかに特殊なアイコン以外はデフォルトで大体揃ってる印象です。
iOS 13，watchOS 6 以上のサンプルアプリでは積極的に使っていきたいです。

勤怠ちゃんでは，アクティビティの編集ボタンのアイコン，
Slack の設定画面のヘルプ(?)ボタンのアイコンで使っています。

## 今後の課題・開発

watchOS 独自の問題なのか `@State` 周りのデータの更新が
うまくいってない部分があります。この改善は必要です。

`TextField` のバリデーションはやっていないです。
不備があると Slack への投稿時にエラーダイアログが出ます。
テストコードも含めていつか書きたい。

Slack の Webhook の URL を入力するのが大変苦行です。
iPhone 側でコピペできると思っていたけどできないみたいです。

バグとしてアクティビティの編集をして保存したにも関わらず，
リスト画面に戻ってきても更新されてない，再起動したら治ってる。的な問題です。
ここは `fatalError()` にしてます。

Slack の設定画面で保存したにも関わらず，再度開き直すと
`TextField` のデータが変わっていない。
User Defaults には正しく保存されているので再起動したら正しいデータになっている。

特にデータの扱いがよくなくコードが汚いので綺麗にする。
複数のチャンネルに投稿できる仕組みの追加(案件チャンネルにも投稿したいなどのニーズがある)。

## おわりに
今回は昨年作った Watch App を SwiftUI で新しく作った話について書きました。
SwiftUI の勉強にはやはり手を動かすのが一番です。
ちょっと寝かせてもう少しキャッチアップしてから
リファクタリングを繰り返してやっていきたいです。

皆さんも楽しい SwiftUI，Watch ライフを！
乱文でしたが，ご覧いただきありがとうございました！

[^1]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SendActivityView.swift
[^2]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SettingsView.swift
[^3]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/AddActivityView.swift
[^4]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SlackSettingsView.swift
[^5]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/AppInfoView.swift
[^6]: https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/API/PostActivity.swift