LoginSignup
81
54

More than 3 years have passed since last update.

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

Posted at

はじめに

昨年の 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 だけで開発を開始できます。
00_Independent_app.png

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

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

キーボードオプション iPhoneなどで入力可能
01_ 02_input_on_iphone.jpg

ペアリングしている 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 04_nippochan_slack.png

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

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

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

今回作るアプリ

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

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

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

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

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

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

Slack の Webhook URL の取得は下記リンクからできます。
投稿時のアイコンとユーザネームはここで設定します。
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 にコードがあります。
気になったらインストールして見てみてください。
コードは汚いです。改善点ばかりです。
こうした方がいいなどアドバイスいただけると勉強になります🙇‍♂️

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

画面

アクティビティリスト選択画面1

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

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

リストのタップで Slack の投稿アクションとなります。
デフォルトの勤怠系アクティビティデータセットが用意されています。
08_Carousel.gif
ユーザが追加できるアクティビティは編集・削除可能です。
カルーセルの右側にある画像をタップするとアクティビティ編集ができます。
左側にスワイプするとリストから削除できます。
1番最後は設定用のカルーセルで設定画面に遷移します。編集・削除不可です。

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

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

編集・削除の動きはこんな感じです。
09_edit_and_delete.gif

設定画面2

設定画面は静的なリスト形式としカルーセルにはしていません。
実際は VStackNavigationLinkText View を 3つ並べてるだけです。
destinationView にそれぞれ アクティビティ追加画面Slack 投稿用の情報入力画面
アプリバージョン画面 の View を渡しているのでそれぞれ Push 遷移します。
10_Settings.gif

アクティビティ追加/編集画面3

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

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

追加の場合は,ActivityModel をひとつ作成してデータの Array に追加しています。
編集の場合は,リスト画面のセルタップで該当の ActivityModel
この画面に渡してデータの再追加をしています。
11_Add_Activity.gif

Slack 設定画面4

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

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

それぞれの項目に説明画面を ? ボタンをタップしたらモーダルで表示します。
12_Slack_Settings.gif

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

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

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 使ってもっと簡単に書けそうな気もします。

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

データの管理

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

通信周り6

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

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

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

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.isShowDialogtrue になると
Slack 投稿成功 or 失敗アラートが表示されます。

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

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

成功ダイアログが出るまでがすごく遅いのが気になります。
が,うまく投稿できています。
15_PostActivity.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 ライフを!
乱文でしたが,ご覧いただきありがとうございました!

81
54
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
81
54