はじめに
昨年の 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 だけで開発を開始できます。
入力インターフェースにキーボードオプションが増えた
Apple Watch 単体でアプリが動くようになったのに加え,
残念だった入力インタフェースにキーボードオプションが追加されました。
この発表があったとき,ひとりで会場で感動してました。
キーボードオプション | iPhoneなどで入力可能 |
---|---|
ペアリングしている 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に投稿 |
---|---|
スマホや 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 側だけで完結させる
- 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
実装
開発環境
- 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
とかでよく実装するやつですね。
デジタルクラウンをグリグリした際の触覚フィードバックがクセになります☺️
var body: some View {
List {
// カルーセルビューにデータを渡す処理
}
.listStyle(CarouselListStyle())
}
リストのタップで Slack の投稿アクションとなります。
デフォルトの勤怠系アクティビティデータセットが用意されています。
ユーザが追加できるアクティビティは編集・削除可能です。
カルーセルの右側にある画像をタップするとアクティビティ編集ができます。
左側にスワイプするとリストから削除できます。
1番最後は設定用のカルーセルで設定画面に遷移します。編集・削除不可です。
List
の削除はこんな感じで書けますね。移動(onMove
)も実装した方がいいかもです。
var body: some View {
List {
// カルーセルビューにデータを渡す処理
}
.onDelete { index in
// 該当の index のデータを削除
self.activityData.remove(at: index)
}
}
設定画面2
設定画面は静的なリスト形式としカルーセルにはしていません。
実際は VStack
で NavigationLink
の Text
View を 3つ並べてるだけです。
destinationView にそれぞれ アクティビティ追加画面,Slack 投稿用の情報入力画面,
アプリバージョン画面 の View を渡しているのでそれぞれ Push 遷移します。
アクティビティ追加/編集画面3
こちらはアクティビティの追加・編集機能で同じ View が使われます。
トップのリスト画面に表示するアクティビティリストに
新しいアクティビティを追加したり既存のアクティビティの編集ができます。
VStack
と HStack
を組み合わせる作ってて楽しい画面のひとつです。
日報ちゃんでは iPhone 側で入力していたところですが
watchOS 6 からキーボード入力ができるようになったため Watch 側で実装しています。
アクティビティ名と絵文字を設定して下部のボタンをタップするだけで編集・追加完了です。
追加の場合は,ActivityModel
をひとつ作成してデータの Array に追加しています。
編集の場合は,リスト画面のセルタップで該当の ActivityModel
を
この画面に渡してデータの再追加をしています。
Slack 設定画面4
この画面で Slack 投稿時に必要な情報を入力し,送信時のパラメタとして用います。
同じくテキスト入力はペアリングしている iOS 13 の iOS 端末で行います。
入力するのは,Slack の Webhook URL と GitHub のアカウント名,
ユーザネームとお好きな色コードです。
これらの情報は Slack に投稿した際の情報に使われます。
それぞれの項目に説明画面を ? ボタンをタップしたらモーダルで表示します。
最初は VStack の中に Text
,Button
,TextField
を
ひたすら並べて作ってから共通化できないかを考えるといった感じでした。
(SwiftUI でよくある,複雑,曖昧エラーでビルド通らなくなった😵)
この画面のそれぞれの項目のように,
項目名,?ボタン,TextField
のブロックが同じ場合は,
下記のように共通化して変化する部分は値渡しなどで対応しました。
// 各項目の識別を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
使ってもっと簡単に書けそうな気もします。
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 的に特別な実装はありません。
データの管理
実装を簡単にするために User Defaults にアクティビティリスト,
Webhook URL,GitHubアカウント,ユーザネーム,カラーコードを保存しています。
データの扱いは課題です。
データの操作は View 側でやりたくない。
通信周り6
このアプリで通信が発生するのはユーザがアクティビティリストから
アクティビティを選んでタップしたときのみです。
リストの 1番下の設定画面への遷移用のカルーセルビューは NavigationLink
を
使い,先に述べた通り該当の View へ Push 遷移する実装を行っています。
アクティビティのカルーセルビューは Button
タップで Slack に投稿するように実装しています。
通信が終わったら結果を受けて,成功 or 失敗アラートを表示させます。
アラートを表示するかどうかのフラグ値(isPresented
)は
今回の場合すぐに toggle
関数で切り替えず,通信が終わった際に切り替えたいです。
また,アラートに表示するタイトル,メッセージも同様なので
通信クラスに ObservableObject
プロトコルに準拠させて,
それぞれの値の変化を通知してもらうことにします。
通信クラスの実装を行います。
URLSession
を使います。ここは一般的な通信処理を書きます。
@Published
をつけてプロパティを宣言してイベント発行可能にします。
通信結果のところで,そのプロパティを更新するだけです。
通信結果によって,イベントが発行されます。
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 失敗アラートが表示されます。
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 から起動できますね。(しごとにんげんと勘違いさせられます)
Slack にアクティビティを投稿
成功ダイアログが出るまでがすごく遅いのが気になります。
が,うまく投稿できています。
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 ライフを!
乱文でしたが,ご覧いただきありがとうございました!
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SendActivityView.swift ↩
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SettingsView.swift ↩
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/AddActivityView.swift ↩
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/SlackSettingsView.swift ↩
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/View/AppInfoView.swift ↩
-
https://github.com/MilanistaDev/NippochanSwiftUI/blob/develop/NippochanSwiftUI%20WatchKit%20Extension/API/PostActivity.swift ↩