23
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOSAdvent Calendar 2020

Day 11

【SwiftUI】Watch Connectivity 使って Apple Watch から iPhone にメッセージ送信してみる!!

Last updated at Posted at 2020-12-10

本記事は 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

スクリーンショット 2020-12-10 23.31.00.png

開発環境

  • 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 ボタンをタップします。

スクリーンショット 2020-12-10 20.36.55.png

PAIRED WATCHES の部分に表示されたら大丈夫です。

スクリーンショット 2020-12-10 20.39.40.png

実装

プロジェクト作成

Xcode 12 で iOS と Watch のアプリを作る場合は,
下記のように新規プロジェクト作成時に watchOS の項目の
iOS App with Watch App を選択します。

スクリーンショット 2020-12-10 20.11.39.png

今回は,SwiftUI で作成するので Interface には SwiftUI,
Life Cycle では SwiftUI App を選択します。

スクリーンショット 2020-12-10 20.12.24.png

これで Hello, World! と表示される最低限のアプリがそれぞれできます。

Watch 側のアニマルリストの実装

動物たちの List をせっかくなのでカルーセルスタイルで作ります。
Watch App の特権なのです。

ContentView.swift
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"))
    }
}

実行すると下記のようになります。かわいい(´∀`)

スクリーンショット 2020-12-10 20.43.18.png

iPhone と Apple Watch 側の疎通

WCSession をそれぞれのアプリ側で展開すると
ペアリングがうまくいっていれば通信可能な状態になります。
まずは通信可能にする実装を行います。

Watch側の実装

ViewModel のクラスを作って WCSession の設定を行います。
WCSessionDelegate の必須デリゲートメソッドはひとつだけです。

AnimalListViewModel
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 のクラスの初期化を行います。

ContentView.swift
import SwiftUI

struct ContentView: View {
    // 省略
 
    var viewModel = AnimalListViewModel() // 追加
    
    var body: some View {
        // 省略
    }
}

これでアプリを実行すると WCSession の設定が行われますので,
Watch 側の通信の用意は完了です。

iPhone側の実装

iOS 側でも ViewModel のクラスを作って WCSession の設定を行います。
WCSessionDelegate の必須デリゲートメソッドは3つです。

MessageListViewModel.swift
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 クラスの初期化を行います。

ContentView.swift
import SwiftUI

struct ContentView: View {
    
    var viewModel = MessageListViewModel()  // 追加
    
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

また,Apple Watch 側と疎通可能かを調べる View も追加します。
ボタンタップで疎通可能であれば YES が表示されるイメージです。
(HStack 部分は切り出した方が良いですね・・)

ContentView.swift
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")
        }
    }
}

実行するとこんな感じになります。

topview.png

ペアリングの設定後に両方のアプリを実行し,Check ボタンをタップして
表示が YES に変わり,疎通確認ができれば OK です。

スクリーンショット 2020-12-10 21.17.36.png

メッセージの送受信の実装

今回は 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)
ContentView.swift
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 クラスに実装します。
実行して受信できるか確認もできます。(ちょっとだけ時間かかります)

MessageListViewModel.swift
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 のチェックを外します。

スクリーンショット 2020-12-10 21.59.39.png

ここから SwiftUI の知識が少しいります。
受信したメッセージ(動物名と絵文字)を受信するたびに
List に表示する実装をします。

受信したメッセージはまとめて String 型で処理するとして,
受信したメッセージを [String] の配列に append して格納していくことにします。

メッセージを受信するたびに ContentView に伝えるため,
ViewModel を Observable に準拠させて,配列の宣言時に @Published をつけます。

MessageListViewModel.swift
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 をつけます。

ContentView.swift
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 のコード見てみてくださいー。

MessageRow.swift
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)
        }
    }
}

これで実装完了です🎉🎉

動作確認

こんな感じになりました。成功ですー💪

result.gif

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 のターゲットにチェックをつけることです。
両ターゲットにて共通してモデルを使いたいためです。

スクリーンショット 2020-12-10 22.47.03.png

Data 型にするためにエンコードとデコード使いたいので
Codable に準拠させています。内容自体はシンプルです。
独自のモデルを UserDefaults に保存するときにも使う方法です。

AnimalModel.swift
import Foundation

struct AnimalModel: Codable, Hashable {
    var name: String
    var emoji: String
}

Watch 側の動物リストのタップ時の処理部分で下記のように
Data 型に変換したモデルを送信するコードを書きます。

ContentView.swift(Watch側)
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)
        }
    }
}

最後にデータまわりをモデル使うように書き換えます。

ContentView.swift(iPhone側)
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")
        }
    }
}
MessageRow.swift
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 で作ってみました。
HStackVStackList など SwiftUI の基本コンポネントはもちろん,
ObservableObject & @Published@ObservedObject なども使えたので
初学者さんにも基本を抑えるのにいい素材かもしれないなと思いました。

Watch アプリは規模が大きすぎないので SwiftUI で色々なことを試すのには
最適だと思ってて,去年から単体でもリリースできるようになってるので,
なかなか SwiftUI 触れないという方はぜひ開発してみてはいかがでしょうか。

こういう記事の文字の打ち込みは通勤時に電車の中で書くことが多いので,
フルリモートでは,『さぁ記事書こう!』という気分になれずなかなかきついです。
来年は平穏取り戻したいですね。

ご覧いただきありがとうございました。
明日は @blacklemontttt さんの記事です!!

23
14
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
23
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?