👯

「2台のiPhoneを近づけてSharePlay開始」について調べたメモ

2024/12/13に公開

SharePlayはiOS 17からは「2台のiPhoneを近づけてSharePlay開始」が可能になった。

https://support.apple.com/ja-jp/guide/iphone/ipha845e253a/ios

こんな感じでNameDropと同様のエフェクトでSharePlayの開始がトリガーされる:


デバイスを近づけてSharePlay(WWDC23 "Add SharePlay to your app" より)

以前はFaceTimeで接続した上での利用が必須だった。「アプリの体験を共有する」という限りなく可能性のある機能なので、FaceTimeなしで近くの人にシュッと共有できるようになった今、もっと使われてもいいのでは、と思う。

なおSharePlayはiOSだけでなくvisionOSやmacOS, tvOSでも利用可能。フレームワークとしては Group Activities を使用する。

どういう体験が共有できるのか?

当初から想定されていた代表的なユースケースは「動画視聴体験の共有」だ。要は動画を一緒に見れる。

ただそれだけじゃなく、基本的にデータを送り合って実現する体験なら何でもSharePlayで共有できる。

たとえばAppleが公開しているサンプル DrawTogether では、お絵描きを共有できる:

https://developer.apple.com/documentation/groupactivities/drawing_content_in_a_group_session

後述するが、このアプリではストロークをSharePlayで送り合うことでお絵かき体験の共有を実現している。(iOS 17から可能になったSharePlayでのファイル送信を利用した、画像追加機能もある)

「2台のiPhoneを近づけてSharePlay開始」の実装

「2台のiPhoneを近づけてSharePlay開始」には特別な実装は必要ない。普通にSharePlayに対応すれば、2台のiPhoneを近づけた際にSharePlay開始がトリガーされる。

以下、サンプルがどういう実装をしているのかコードを読んだメモ。

「動画視聴体験の共有」の実装

日本のiOSエンジニアにおけるSharePlay伝道師である @tokorom さんが公開しているサンプルが参考になる:
https://github.com/tokorom/SharePlaySample

「動画視聴体験の共有」における実装の肝はここ:

playerViewController?.player?.playbackCoordinator.coordinateWithSession(session)

解説すると、AVPlayerAVPlayerPlaybackCoordinator 型の playbackCoordinator プロパティを持っていて、

var playbackCoordinator: AVPlayerPlaybackCoordinator { get }

その AVPlayerPlaybackCoordinatorcoordinateWithSession(_:) メソッドを呼ぶことでグループ視聴セッションを開始している。

coordinateWithSession(_:) メソッドの定義はこちら:

func coordinateWithSession<T>(_ session: GroupSession<T>) where T : GroupActivity

Begins coordination of a player with a group session.
(グループセッションとプレーヤーの調整を開始します。)

「カスタムな体験の共有」の実装

以下はDrawTogetherサンプルのコードを読んだメモ。

https://developer.apple.com/documentation/groupactivities/drawing_content_in_a_group_session

お絵かきの実装

Canvas という ObservableObject に準拠するクラスで、Stroke の配列やアクティブなストロークを管理している

class Canvas: ObservableObject {
    @Published var strokes = [Stroke]()
    @Published var activeStroke: Stroke?
    ...
    let strokeColor = Stroke.Color.random

    ...

    func addPointToActiveStroke(_ point: CGPoint) {
        let stroke: Stroke
        if let activeStroke = activeStroke {
            stroke = activeStroke
        } else {
            stroke = Stroke(color: strokeColor)
            activeStroke = stroke
        }

        stroke.points.append(point)

        if let messenger = messenger {
            Task {
                try? await messenger.send(UpsertStrokeMessage(id: stroke.id, color: stroke.color, point: point))
            }
        }
    }

    func finishStroke() {
        guard let activeStroke = activeStroke else {
            return
        }

        strokes.append(activeStroke)
        self.activeStroke = nil
    }

StrokeStrokeView を用いてSwiftUIビューとして描画できる

struct StrokeView: View {
    @ObservedObject var stroke: Stroke

    var body: some View {
        stroke.path
            .stroke(stroke.color.uiColor, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
    }
}

実際の画面への描画は、CanvasViewStrokeView を描画している

ドラッグジェスチャーもここでハンドルしている

struct CanvasView: View {
    @ObservedObject var canvas: Canvas

    var body: some View {
        GeometryReader { _ in
            ForEach(canvas.strokes) { stroke in
                StrokeView(stroke: stroke)
            }

            ...
            if let activeStroke = canvas.activeStroke {
                StrokeView(stroke: activeStroke)
            }
        }
        ...
    }

    var strokeGesture: some Gesture {
        DragGesture()
            .onChanged { value in
                canvas.addPointToActiveStroke(value.location)
            }
            .onEnded { value in
                canvas.addPointToActiveStroke(value.location)
                canvas.finishStroke()
            }
    }
}

SharePlayを用いたお絵かき同期の実装

Canvas クラスから、SharePlayを利用してお絵描きを同期している部分のコードを抜粋(簡潔にするため、画像を利用する機能まわりは割愛):

アクティブな参加者が増えたら現状の描画情報を送信

groupSession.$activeParticipants
    .sink { activeParticipants in
        let newParticipants = activeParticipants.subtracting(groupSession.activeParticipants)

        Task {
            try? await messenger.send(CanvasMessage(strokes: self.strokes, pointCount: self.pointCount), to: .only(newParticipants))
        }
    }
    .store(in: &subscriptions)

UpsertStrokeMessage を受信

他の参加者のストロークを自身のキャンバスに反映

for await (message, _) in messenger.messages(of: UpsertStrokeMessage.self) {
    handle(message)
}
func handle(_ message: UpsertStrokeMessage) {
    if let stroke = strokes.first(where: { $0.id == message.id }) {
        stroke.points.append(message.point)
    } else {
        let stroke = Stroke(id: message.id, color: message.color)
        stroke.points.append(message.point)
        strokes.append(stroke)
    }
}

CanvasMessage を受信

他の参加者のキャンバス情報を自身のキャンバスに反映

for await (message, _) in messenger.messages(of: CanvasMessage.self) {
    handle(message)
}
func handle(_ message: CanvasMessage) {
    guard message.pointCount > self.pointCount else { return }
    self.strokes = message.strokes
}

おまけ

SharePlayを初めて使ったときに「わかりにくい!」と思った部分をツリーに書き残しておきました:
https://x.com/shu223/status/1867051261314654711

コードも読んだ今となっては「グループセッションへの参加」と「アクティビティの開始」の2段階あるんだなとかわかるけど、一般ユーザーに浸透するのはまだ厳しいかもしれない...

Discussion