本記事は iOS Advent Calendar 2020 の 11日目の記事です。
昨日は @uhooi さんで WidgetのオススメPreview一覧(iOS) でした。
Widget 実装してみたいけどまだ触れていないので
年末年始で記事を参考に触ってみたいです!
はじめに
Qiita の Advent Calendar は毎年大事にしていて,
普段触れられない技術や項目について調べたり実装したりしています。
Apple Watch 触ってみることが多いですね。
今年は,新しい技術というわけではないのですが,
SwiftUI で Watch Connectivity ってどう実装すればいいんだろう?
と思ったので SwiftUI,Watch Connectivity の復習も兼ねて
Apple Watch から iPhone に簡単なメッセージを送信するアプリを作ってみました。
ちなみに運の悪いことに
業務としてまだ SwiftUI には触れていません。
まだなかなか iOS 12のサポート切れなくて…ね😓
皆さんはいかがでしょうか?
Watch Connectivity とは
Watch Connectivity とは簡単に言えば,
Apple Watch と iPhone 間で通信する仕組みのことです。
ファイルや小サイズのデータを送信できます。
詳しくは下記レファレンスをご覧ください。
https://developer.apple.com/documentation/watchconnectivity
2年前にも Watch Connectivity に関する記事を
書いてるのでよろしかったらご覧ください。
Apple Watchを使って日報の各アクティビティをSlackに投稿させてみる
https://qiita.com/MilanistaDev/items/b97cab77d6add96c96dc
今回作るサンプルアプリ・開発環境
Apple Watch 側でメッセージを送信して,iPhone 側で受け取って,
受信した時間と共にリストに表示するサンプルアプリを作ってみました。
GitHub にサンプルアップしているので気になる方はご覧ください。
https://github.com/MilanistaDev/WatchConnectivitySampleForSwiftUI
開発環境
- macOS Big Sur 11.0.1
- Xcode 12.2
- iOS 14
- watchOS 7
シミュレータでもいいですが iPhone と Apple Watch が
ペアリング状態にできることが必須です。
シミュレータでペアリングの確認方法
Xcode の Window のメニューから Devices and Simulators を選択。
(⌘ + Shift + 2 のショートカットでも良いです。)
シミュレータとして使いたい iPhone を選択します。
+ ボタンをタップしてペアリングしたい Apple Watch を選択し,
Pair ボタンをタップします。
PAIRED WATCHES の部分に表示されたら大丈夫です。
実装
プロジェクト作成
Xcode 12 で iOS と Watch のアプリを作る場合は,
下記のように新規プロジェクト作成時に watchOS の項目の
iOS App with Watch App を選択します。
今回は,SwiftUI で作成するので Interface には SwiftUI,
Life Cycle では SwiftUI App を選択します。
これで Hello, World! と表示される最低限のアプリがそれぞれできます。
Watch 側のアニマルリストの実装
動物たちの List をせっかくなのでカルーセルスタイルで作ります。
Watch App の特権なのです。
import SwiftUI
struct ContentView: View {
let animals = ["ネコ", "イヌ", "ハムスター", "ドラゴン", "ユニコーン"]
let emojiAnimals = ["🐱", "🐶", "🐹", "🐲", "🦄"]
var body: some View {
List(0 ..< animals.count) { index in
Button {
// タップ時の処理
} label: {
HStack {
Text(self.emojiAnimals[index])
.font(.title)
.padding()
Text(self.animals[index])
}
}
}
.listStyle(CarouselListStyle())
.navigationBarTitle(Text("Animal List"))
}
}
実行すると下記のようになります。かわいい(´∀`)
iPhone と Apple Watch 側の疎通
WCSession
をそれぞれのアプリ側で展開すると
ペアリングがうまくいっていれば通信可能な状態になります。
まずは通信可能にする実装を行います。
Watch側の実装
ViewModel のクラスを作って WCSession
の設定を行います。
WCSessionDelegate
の必須デリゲートメソッドはひとつだけです。
import WatchConnectivity
final class AnimalListViewModel: NSObject {
var session: WCSession
init(session: WCSession = .default) {
self.session = session
super.init()
self.session.delegate = self
session.activate()
}
}
extension AnimalListViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print(error.localizedDescription)
} else {
print("The session has completed activation.")
}
}
}
ContentView 側で ViewModel のクラスの初期化を行います。
import SwiftUI
struct ContentView: View {
// 省略
var viewModel = AnimalListViewModel() // 追加
var body: some View {
// 省略
}
}
これでアプリを実行すると WCSession
の設定が行われますので,
Watch 側の通信の用意は完了です。
iPhone側の実装
iOS 側でも ViewModel のクラスを作って WCSession
の設定を行います。
WCSessionDelegate
の必須デリゲートメソッドは3つです。
import SwiftUI
import WatchConnectivity
final class MessageListViewModel: NSObject {
var session: WCSession
init(session: WCSession = .default) {
self.session = session
super.init()
self.session.delegate = self
session.activate()
}
}
extension MessageListViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print(error.localizedDescription)
} else {
print("The session has completed activation.")
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
}
func sessionDidDeactivate(_ session: WCSession) {
}
}
同じく ContentView 側で ViewModel クラスの初期化を行います。
import SwiftUI
struct ContentView: View {
var viewModel = MessageListViewModel() // 追加
var body: some View {
Text("Hello, world!")
.padding()
}
}
また,Apple Watch 側と疎通可能かを調べる View も追加します。
ボタンタップで疎通可能であれば YES が表示されるイメージです。
(HStack 部分は切り出した方が良いですね・・)
import SwiftUI
struct ContentView: View {
var viewModel = MessageListViewModel()
@State private var isReachable = "NO"
var body: some View {
NavigationView {
VStack {
HStack {
Button(action: {
// iPhone と Apple Watch が疎通できるか
// true の場合メッセージ送信可能
self.isReachable = self.viewModel.session.isReachable ? "YES": "NO"
}) {
Text("Check")
}
.padding(.leading, 16.0)
Spacer()
Text("isReachable")
.font(.headline)
.padding()
Text(self.isReachable)
.foregroundColor(.gray)
.font(.subheadline)
.padding()
}
.background(Color.init(.systemGray5))
Spacer()
}
.navigationTitle("Receiver")
}
}
}
実行するとこんな感じになります。
ペアリングの設定後に両方のアプリを実行し,Check ボタンをタップして
表示が YES に変わり,疎通確認ができれば OK です。
メッセージの送受信の実装
今回は Apple Watch から iPhone 側に送信するので
Watch 側が送信側,iPhone 側が受信側という形で実装を進めます。
Watch (送信)側の実装
List のセル(相当)がタップされた際に絵文字と動物名を iPhone 側に送信します。
メッセージ送信のための関数が WCSession
に用意されていて下記になります。
[String : Any]
で用意すればいいことがわかります。
open func sendMessage(_ message: [String : Any], replyHandler: (([String : Any]) -> Void)?, errorHandler: ((Error) -> Void)? = nil)
import SwiftUI
struct ContentView: View {
let animals = ["ネコ", "イヌ", "ハムスター", "ドラゴン", "ユニコーン"]
let emojiAnimals = ["🐱", "🐶", "🐹", "🐲", "🦄"]
var viewModel = AnimalListViewModel()
var body: some View {
List(0 ..< animals.count) { index in
Button {
// タップ時の処理
self.sendMessage(index: index) // 追加
} label: {
HStack {
Text(self.emojiAnimals[index])
.font(.title)
.padding()
Text(self.animals[index])
}
}
}
.listStyle(CarouselListStyle())
.navigationBarTitle(Text("Animal List"))
}
private func sendMessage(index: Int) {
let messages: [String: Any] =
["animal": animals[index],
"emoji": emojiAnimals[index]]
// 動物名と絵文字を突っ込んだ配列を送信する
self.viewModel.session.sendMessage(messages, replyHandler: nil) { (error) in
print(error.localizedDescription)
}
}
}
iPhone (受信)側の実装
受信側はまずは受信する処理を実装し,
受信したメッセージを List に表示する実装が必要です。
まずは受信したメッセージを処理します。
WCSessionDelegate
のデリゲートメソッドが用意されています。
Objective-C じゃん・・・
- (void)session:(WCSession *)session didReceiveMessage:(NSDictionary<NSString *, id> *)message;
よって iPhone 側の ViewModel クラスに実装します。
実行して受信できるか確認もできます。(ちょっとだけ時間かかります)
import SwiftUI
import WatchConnectivity
final class MessageListViewModel: NSObject {
// 省略
}
extension MessageListViewModel: WCSessionDelegate {
// 省略
// 追加:メッセージ受信
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
// メインスレッドで処理
DispatchQueue.main.async {
let receivedAnimal = message["animal"] as? String ?? "UMA"
let receivedEmoji = message["emoji"] as? String ?? "❓"
print(receivedEmoji + receivedAnimal) // 🐱ネコ
}
}
}
Watch 側でいくらセルをタップしても下記のようなエラーが起きる場合,
Companion app is not installed.
Target の WatchKit Extension の Deployment Info 部分の
Supports Running Without iOS App Installation のチェックを外します。
ここから SwiftUI の知識が少しいります。
受信したメッセージ(動物名と絵文字)を受信するたびに
List
に表示する実装をします。
受信したメッセージはまとめて String
型で処理するとして,
受信したメッセージを [String]
の配列に append して格納していくことにします。
メッセージを受信するたびに ContentView に伝えるため,
ViewModel を Observable
に準拠させて,配列の宣言時に @Published
をつけます。
import SwiftUI
import WatchConnectivity
final class MessageListViewModel: NSObject, ObservableObject {
// 配列に変化があれば変更を通知
@Published var messages: [String] = []
//省略
}
extension MessageListViewModel: WCSessionDelegate {
// 省略
// メッセージ受信
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
DispatchQueue.main.async {
let receivedAnimal = message["animal"] as? String ?? "UMA"
let receivedEmoji = message["emoji"] as? String ?? "❓"
print(receivedEmoji + receivedAnimal) // 🐱ネコ
// 受信したメッセージを配列に格納し配列を更新
self.messages.append(receivedEmoji + receivedAnimal)
}
}
}
受信したメッセージを List に表示させる実装をします。
メッセージ配列の変更を受け取れるように
ViewModel 宣言時に @ObservedObject
をつけます。
import SwiftUI
struct ContentView: View {
// @ObservedObject をつけてメッセージ配列の変更通知を受け取る
@ObservedObject var viewModel = MessageListViewModel()
@State private var isReachable = "NO"
var body: some View {
NavigationView {
VStack {
HStack {
// 省略
}
.background(Color.init(.systemGray5))
// 受信したメッセージを表示する
List {
ForEach(self.viewModel.messages, id: \.self) { animal in
MessageRow(animal: animal)
}
}
.listStyle(PlainListStyle())
Spacer()
}
.navigationTitle("Receiver")
}
}
}
一応メッセージ表示用のセル相当の View に実装は下記の通りです。
時間を文字列に変換するは,DateFormatter 使ってるだけです。
GitHub のコード見てみてくださいー。
struct MessageRow: View {
let animal: String
var body: some View {
VStack(alignment: .leading) {
Text(animal)
.font(.body)
.padding(.vertical, 4.0)
// 受信時のタイムスタンプ
Text(Date().toString())
.font(.footnote)
.foregroundColor(.gray)
}
}
}
これで実装完了です🎉🎉
動作確認
こんな感じになりました。成功ですー💪
One more thing
先ほどは Watch Connectivity のデータ送信に
Dictionary
型 [String: Any]
を使いましたが,
Data
型でも送信できる API が用意されています。
open func sendMessageData(_ data: Data, replyHandler: ((Data) -> Void)?, errorHandler: ((Error) -> Void)? = nil)
[String: Any]
を Data
型にしてもいいけど味気ないので,
代わりに動物名と絵文字を格納したモデルを Data
型に変換して渡してみます。
受け渡しに利用するモデルを作成します。
作成時に注意する点は iOS アプリのターゲットと
WatchKit Extension のターゲットにチェックをつけることです。
両ターゲットにて共通してモデルを使いたいためです。
Data
型にするためにエンコードとデコード使いたいので
Codable に準拠させています。内容自体はシンプルです。
独自のモデルを UserDefaults に保存するときにも使う方法です。
import Foundation
struct AnimalModel: Codable, Hashable {
var name: String
var emoji: String
}
Watch 側の動物リストのタップ時の処理部分で下記のように
Data
型に変換したモデルを送信するコードを書きます。
import SwiftUI
struct ContentView: View {
let animals = ["ネコ", "イヌ", "ハムスター", "ドラゴン", "ユニコーン"]
let emojiAnimals = ["🐱", "🐶", "🐹", "🐲", "🦄"]
var viewModel = AnimalListViewModel()
var body: some View {
List(0 ..< animals.count) { index in
Button {
// タップ時の処理
// [String: Any] はこっち
// self.sendMessage(index: index)
// Data型はこっち
self.sendMessageData(index: index)
} label: {
HStack {
Text(self.emojiAnimals[index])
.font(.title)
.padding()
Text(self.animals[index])
}
}
}
.listStyle(CarouselListStyle())
.navigationBarTitle(Text("Animal List"))
}
// 省略
private func sendMessageData(index: Int) {
let animal = AnimalModel(name: animals[index], emoji: emojiAnimals[index])
guard let data = try? JSONEncoder().encode(animal) else {
return
}
self.viewModel.session.sendMessageData(data, replyHandler: nil) { (error) in
print(error.localizedDescription)
}
}
}
これで Watch 側で送信できるようになったので,
iPhone 側で受信するためのコードを ViewModel に追記します。
同じく,受信リストに受信した内容を伝えるために
モデルを格納した配列を定義し,@Published
を付けます。
import SwiftUI
import WatchConnectivity
final class MessageListViewModel: NSObject, ObservableObject {
// 配列に変化があれば変更を通知
@Published var messages: [String] = [] // [String: Any]用
@Published var messagesData: [AnimalModel] = [] // Data型用
// 省略
}
extension MessageListViewModel: WCSessionDelegate {
// 省略
// メッセージ受信 Data型
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
DispatchQueue.main.async {
guard let message = try? JSONDecoder().decode(AnimalModel.self, from: messageData) else {
return
}
self.messagesData.append(message)
}
}
}
最後にデータまわりをモデル使うように書き換えます。
import SwiftUI
struct ContentView: View {
// @ObservedObject をつけてメッセージ配列の変更通知を受け取る
@ObservedObject var viewModel = MessageListViewModel()
@State private var isReachable = "NO"
var body: some View {
NavigationView {
VStack {
HStack {
// 省略
}
.background(Color.init(.systemGray5))
List {
// ForEach(self.viewModel.messages, id: \.self) { animal in
// MessageRow(animal: animal)
// }
ForEach(self.viewModel.messagesData, id: \.self) { animal in
// モデルを渡すように変更
MessageRow(animalModel: animal)
}
}
.listStyle(PlainListStyle())
Spacer()
}
.navigationTitle("Receiver")
}
}
}
import SwiftUI
struct MessageRow: View {
//let animal: String
// モデルを受け取る
let animalModel: AnimalModel
var body: some View {
VStack(alignment: .leading) {
// モデルの中の項目を使う
Text(animalModel.emoji + animalModel.name)
.font(.body)
.padding(.vertical, 4.0)
// 受信時のタイムスタンプ
Text(Date().toString())
.font(.footnote)
.foregroundColor(.gray)
}
}
}
実行結果は同じですね。
おわりに
今回は,Watch Connectivity を使ってApple Watch から iPhone に
メッセージを送信するサンプルアプリを SwiftUI で作ってみました。
HStack
や VStack
,List
など SwiftUI の基本コンポネントはもちろん,
ObservableObject
& @Published
と @ObservedObject
なども使えたので
初学者さんにも基本を抑えるのにいい素材かもしれないなと思いました。
Watch アプリは規模が大きすぎないので SwiftUI で色々なことを試すのには
最適だと思ってて,去年から単体でもリリースできるようになってるので,
なかなか SwiftUI 触れないという方はぜひ開発してみてはいかがでしょうか。
こういう記事の文字の打ち込みは通勤時に電車の中で書くことが多いので,
フルリモートでは,『さぁ記事書こう!』という気分になれずなかなかきついです。
来年は平穏取り戻したいですね。
ご覧いただきありがとうございました。
明日は @blacklemontttt さんの記事です!!