【Zoom Video SDK】初心者による導入メモ - iOS/SwiftUI編

https://qiita.com/official-events/86d8555146e1b14fef24

この記事は[【Zoom Video SDK】初心者による導入メモ - iOS/Swift編](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85)に続くiOS/SwiftUI編です。

Swift編ではZoom Video SDKの実装方法がわかったので今回はSwiftUIによる宣言的UIの手法で実装してみました。
SwiftUIではViewとModelを分離して効率良くUIを構築することができるので実用的なアプリを目指し、Zoomのグループビデオ＆テキストチャットを作成しました。

# 使用環境
- MacBook Air (M1, 2020)
- macOS Monterey 12.4
- Xcode 13.4.1
- iOS 15.5
- zoom-video-sdk-iOS-1.3.1

# 画面レイアウト
画面のサイズに依存しないレスポンシブデザインで、縦レイアウトと横レイアウトにも対応してみました。システムのダークモードにも連動します。
円形部分がマイクミュートで参加しているメンバーで、四角のマス部分がミュートを解除し会話に参加しいてるメンバーです。画面の下部はテキストチャットです。

- 縦レイアウト（ライトモード）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/2575f57c-ed6c-46a0-8e21-c9a83cdab903.png)

- 横レイアウト（ダークモード）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/27cd306e-4c15-e7b1-ae4f-a8df852ad95b.png)

# 実装のポイント

## Xcodeプロジェクトの設定
前回の[Xcodeプロジェクト設定](https://qiita.com/yuppejp/items/838e1becfc2774bd4e85#xcode%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E8%A8%AD%E5%AE%9A)を参考にプロジェクトを作成してください。AppleシリコンベースのMacでシミュレーターを使う方法も記載しています。

## アプリの構造
機能ごとにViewを分割し、HStack、VStackを使ってそれぞれを配置しています。ObservableObjectから派生させたZoomModelクラスに、ZoomVideoSDKのデリゲートを実装しています。このクラスインスタンスを環境変数としてVieに渡すことで、Zoomセッションの状態の変化（メンバーの参加/退出、ビデオon/offなど）に連動してViewを自動的に更新することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/57d11e40-992d-94cf-a556-915934c13b17.png)

:::note info
ObservableObjectを入れ子構造にすると思ったようにViewが更新されないため、実際にはもう少し細いかい単位でViewに分割しています。
:::

## ZoomSDKのデリゲートの実装
ObservableObjectに干渉しないようにZoomVideoSDKDelegateを手前で宣言します。DelegateはNSObjectが必要なためさらに手前にNSObjectを置きます。
このコード例ではメンバーの参加/退出でメンバーリストを更新しています。View側ではこのリストをObservedObjectで宣言しておくことで更新監視が働きメンバーリストの更新に連動してViewが自動的に更新されます。

```swift:ZoomModel.swift(抜粋)
class ZoomModel: NSObject, ZoomVideoSDKDelegate, ObservableObject {
    func onError(_ ErrorType: ZoomVideoSDKError, detail details: Int) {
        switch ErrorType {
        case .Errors_Success:
            print("Success")
        default:
            print("Error \(ErrorType) \(details)")
        }
    }

    func onUserJoin(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        print("onUserJoin")
        if let userArray = userArray {
            for user in userArray {
                print("  id: \(user.getID())")
                print("  name: \(String(describing: user.getName()))")
                
                // 一覧に追加
                let user = User(zoomUser: user)
                users.append(user)
            }
        }
    }
    
    func onUserLeave(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        print("onUserLeave")
        if let userArray = userArray {
            for user in userArray {
                print(user)
                
                // 一覧から削除
                for (i, item) in users.enumerated() {
                    if item.zoomUser == user {
                        users.remove(at: i)
                        break
                    }
                }
            }
        }
    }
}
```

## ZoomSDKのVideoCanvasをSwiftUIで使う
ZoomVideoSDKVideoCanvasはそのままではViewに置けないので、UIViewControllerRepresentableでラップしておきます。これでSwiftUIの一般的なViewとして扱うことができます。

```swift: ZoomVideoView.swift
struct ZoomVideoView: UIViewControllerRepresentable {
    let videoCanvas: ZoomVideoSDKVideoCanvas

    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = UIViewController()
        viewController.view.backgroundColor = .lightGray
        
        return viewController
    }
    
    func updateUIViewController(_ viewController: UIViewController, context: Context) {
        let videoAspect = ZoomVideoSDKVideoAspect.panAndScan
        videoCanvas.subscribe(with: viewController.view, andAspectMode: videoAspect)
    }
    
    final class Coordinator: NSObject {
        let parent: ZoomVideoView
        init(_ parent: ZoomVideoView) {
            self.parent = parent
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    static func dismantleUIView(_ viewController: UIViewController, coordinator: Coordinator) {
        coordinator.parent.videoCanvas.unSubscribe(with: viewController.view)
    }
}
```

## 発話中のユーザーの映像にアニメーション効果を付ける
onUserActiveAudioChangedイベントで発話中のユーザを検知してアニメーション効果を付けます。試したところ、このイベントは周囲の環境音や音楽にはあまり反応せず人間の肉声に反応するようですのでかなり精度の良い検知ができました。この発話イベントを使ってユーザの映像に緑の枠線のアニメーション効果を付けてみました。
![zoom.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/811060ba-a1ab-412f-6d5d-64b06f60a693.gif)

- モデル側
view側でアニメーション効果を発動させるために一つめのタイマーで0.3秒間隔でトグルさせます。そして、二つ目のタイマーで3秒経過したらfalseにしてアニメーションの停止をViewに伝えます。

```swift:ZoomModel.swift(抜粋)
    func onUserActiveAudioChanged(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        if let userArray = userArray {
            for user in userArray {
                if let audioStatus = user.audioStatus() {
                    let isTalking = audioStatus.talking
                    
                    for item in users {
                        if item.zoomUser == user {
                            item.isTalking = isTalking

                            let timer = Timer.scheduledTimer(withTimeInterval: 0.3,
                                                             repeats: true,
                                                             block: { (time: Timer) in
                                item.isTalking.toggle()
                            })
                            
                            Timer.scheduledTimer(withTimeInterval: 3,
                                                 repeats: false,
                                                 block: { (time: Timer) in
                                timer.invalidate()
                                item.isTalking = false
                            })
                            
                            break
                        }
                    }
                }
            }
        }
    }

```

- view側
 animationモディファイでisTalkingプロパティの値が変わったらアニメーションを発生させます。Animation.easeInOutアニメーションの開始と終了で速度が変わるので、発話している感じがそれっぽく表現できます。

```swift:SpeakerVideoView.swift(抜粋)
struct SpeakerVideoItemView: View {
    @ObservedObject var user: User
    @State var animation = false
    
    var body: some View {
    ...
            if let canvas = user.zoomUser.getVideoCanvas(), let on = canvas.videoStatus()?.on, on == true {
                ZoomVideoView(videoCanvas: user.zoomUser.getVideoCanvas()!)
                    .frame(width: videoWidth, height: videoHeight)
                    .border(Color.green, width: user.isTalking ? 8.0 : 0)
                    .animation(Animation.easeInOut, value: user.isTalking) // アニメーション効果
            }
    ...
    }
}
```

## 相手のミュート解除でビューを切り替える

- モデル側
onUserAudioStatusChangedイベントで参加メンバーのマイクミュートの変化を捕捉します。ミュートの変化でビューを切り替えるためにobjectWillChange.send()で強制的にViewへ更新通知を送ります。SwiftUIのデフォルト動作としてPublishで宣言したプロパティの変化でViewが自動更新されますが、今回は描画対象のビューそのものを切り替えたいので明示的に更新を発生させています。

```swift: ZoomModel.swift(抜粋)
    func onUserAudioStatusChanged(_ helper: ZoomVideoSDKAudioHelper?, user userArray: [ZoomVideoSDKUser]?) {
        if let userArray = userArray {
            for user in userArray {
                if let isMuted = user.audioStatus()?.isMuted  {
                    for item in users {
                        if item.zoomUser == user {
                            item.isMuted = isMuted
                            self.objectWillChange.send() // 再描画
                            break
                        }
                    }
                }
            }
        }
    }

```

- View側
ユーザリストに対してfilter()を使ってisMutedプロパティーがtrueかfalseかを見て対象のユーザーを絞り込みます。前者がミュート状態のユーザーの一覧、後者がミュート解除しているユーザーの一覧です。

```swift:MutedVideoView.swift(抜粋)
struct MutedVideoView: View {
    @EnvironmentObject var zoom: ZoomModel
    
    var body: some View {
        ...
            ScrollView(.horizontal) {
                HStack {
                    ForEach(zoom.users.filter({$0.isMuted == true})) { user in
                       MutedVideoItemView(user: user, videoWidth: videoWidth, videoHeight: videoHeight)
                }
        ...
        }
    }
}
```

```swift:SpeakerVideoView.swift(抜粋)
struct SpeakerVideoView: View {
    @EnvironmentObject var zoom: ZoomModel
    
    var body: some View {
        ...
            LazyVGrid(columns: columns, spacing: yPadding) {
                if zoom.isJoined {
                    ForEach(zoom.users.filter({$0.isMuted == false})) { user in
                        SpeakerVideoItemView(user: user, videoWidth: videoWidth, videoHeight: videoHeight)
                    }
                }
            }
        ...
    }
}
```

## テキストチャットを実装する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/89745e97-e344-7c98-f703-48f57cbb4740.png)

- モデル側 
参加メンバーからのテキストメッセージはonChatNewMessageNotifyイベントで上がってきます。自分が送信したメッセージもここに通知されます。通知されたメッセージをモデル内の配列に格納し、Publishedします。View側で最新のメッセージがスクロール表示されるように、lastMessageIdもPublishedしています。

```swift:ZoomModel.swift(抜粋)
class ZoomModel: NSObject, ZoomVideoSDKDelegate, ObservableObject {
    @Published var messages: [Message] = []
    @Published var lastMessageId = UUID()

    func addMessage(userName: String, text: String) {
        let msg = Message(userName: userName, text: text)
        messages.append(msg)
        lastMessageId = msg.id
    }

    func onChatNewMessageNotify(_ helper: ZoomVideoSDKChatHelper?, message: ZoomVideoSDKChatMessage?) {
        if let content = message?.content, let senderName = message?.senderUser?.getName() {
            addMessage(userName: senderName, text: content)
        }
    }
```

- View側 
モデル側でメッセージが可能されたらScrollViewReaderでメッセージを表示します。また、onChangeモディファイでlastMessageIdを監視し末尾へスクロールさせています。

```swift:ChatView.swift(抜粋)
struct ChatView: View {
    @EnvironmentObject var zoom: ZoomModel
    
    var body: some View {
    ...
            ScrollViewReader { reader in
                List {
                    ForEach(zoom.messages) { message in
                        MessageItemView(message: message, myUserName: zoom.userName)
                            .font(.footnote)
                            .background(.bar)
                    }
                }
                .listStyle(PlainListStyle())
                .onChange(of: zoom.lastMessageId) { id in
                    // 末尾へスクロール
                    withAnimation(.linear(duration: 2)) {
                        reader.scrollTo(id)
                    }
                }
            }
    ...
    }
}
```

:::note info
MessageItemViewの内部では吹き出しテキストViewを使用しています。この吹き出しテキストの実装方法は次の関連記事を参考にしてください。
:::

https://qiita.com/yuppejp/items/92429a0fc8440f9da487

# サンプルコード全文
今回のサンプルコードを掲載します。レイアウト確認用のデバッグコードが残っていて読みづらいかもしれません...

## ZoomModel.swift
```swift:ZoomModel.swift
import SwiftUI
import ZoomVideoSDK

class User: Identifiable, ObservableObject {
    @Published var isMuted = true
    @Published var isVideoOn = false
    @Published var isTalking  = false
    let id = UUID()
    var zoomUser: ZoomVideoSDKUser
    
    init(zoomUser: ZoomVideoSDKUser) {
        self.zoomUser = zoomUser
    }
    
    func getName() -> String {
        if let name = zoomUser.getName() {
            return name
        } else {
            return ""
        }
    }

    func mute() {
        if let audioHelper = ZoomVideoSDK.shareInstance()?.getAudioHelper() {
            audioHelper.muteAudio(zoomUser)
        }
    }

    func unmute() {
        if let session = ZoomVideoSDK.shareInstance()?.getSession(), let myself = session.getMySelf() {
            // 自分自身の場合、オーディオが開始していない場合は開始する
            if myself == zoomUser {
                if let audioStatus = myself.audioStatus() {
                    if audioStatus.audioType == .none {
                        if let audioHelper = ZoomVideoSDK.shareInstance()?.getAudioHelper() {
                            audioHelper.startAudio()
                        }
                    }
                }
            }
        }

        if let audioHelper = ZoomVideoSDK.shareInstance()?.getAudioHelper() {
            audioHelper.unmuteAudio(zoomUser)
        }
    }
}

struct Message: Identifiable {
    var id = UUID()
    var userName: String
    var date = Date()
    var text: String
}

class ZoomModel: NSObject, ZoomVideoSDKDelegate, ObservableObject {
    @Published var sessionName = "(未参加)"
    @Published var isJoined = false
    @Published var users: [User] = []
    @Published var mySelf = User(zoomUser: ZoomVideoSDKUser())
    @Published var messages: [Message] = []
    @Published var lastMessageId = UUID()
    var initialized = false
    var userName = generator(6) // ランダムな名前を生成
    
    func addMessage(userName: String, text: String) {
        let msg = Message(userName: userName, text: text)
        messages.append(msg)
        lastMessageId = msg.id
    }

    func sendMessage(text: String) {
        sendChatToAll(text)
    }
    
    func zoomInit() {
        ZoomVideoSDK.shareInstance()?.delegate = self
        
        let initParams = ZoomVideoSDKInitParams()
        initParams.domain = "zoom.us"
        initParams.enableLog = true
        
        let sdkInitReturnStatus = ZoomVideoSDK.shareInstance()?.initialize(initParams)
        switch sdkInitReturnStatus {
        case .Errors_Success:
            print("SDK initialized successfully")
            initialized = true
        default:
            if let error = sdkInitReturnStatus {
                print("SDK failed to initialize: \(error)")
            }
        }
    }
    
    func join() {
        if (!initialized) {
            zoomInit()
        }
        
        let sessionContext = ZoomVideoSDKSessionContext()
        sessionContext.token = "Your jwt"
        sessionContext.sessionName = "Session1" // "Your session name"
        sessionContext.sessionPassword = "123"  // "Your session password"
        sessionContext.userName = userName
        
        let videoOption = ZoomVideoSDKVideoOptions()
        videoOption.localVideoOn = true
        sessionContext.videoOption = videoOption
        
        let audioOption = ZoomVideoSDKAudioOptions()
        audioOption.connect = true
        audioOption.mute = true
        sessionContext.audioOption = audioOption
        
        if let session = ZoomVideoSDK.shareInstance()?.joinSession(sessionContext) {
            print("joinSession: successfully")
            print("  name: \(String(describing: session.getName()))")
            sessionName = session.getName()!
        } else {
            print("joinSession: failed")
        }
    }
    
    func leave() {
        // end: if end the session for host. YES if the host should end the entire session, or NO if the host should just leave the session.
        var shouldEndSession = false
        if mySelf.zoomUser.isHost() {
            // 自分がホストの場合はセッションを強制終了（とりあえず）
            shouldEndSession = true
        }
        ZoomVideoSDK.shareInstance()?.leaveSession(shouldEndSession)
    }
    
    func startVideo() {
        if let videoHelper = ZoomVideoSDK.shareInstance()?.getVideoHelper() {
            videoHelper.startVideo()
        }
    }

    func stopVideo() {
        if let videoHelper = ZoomVideoSDK.shareInstance()?.getVideoHelper() {
            videoHelper.stopVideo()
        }
    }
    
    func sendChatToAll(_ text: String) {
        if let chatHelper = ZoomVideoSDK.shareInstance()?.getChatHelper() {
            chatHelper.sendChat(toAll: text)
        }
    }

    // see: https://qiita.com/Kosuke-214/items/649ed2454aee695c2e00
    static func generator(_ length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        var randomString = ""
        for _ in 0 ..< length {
            randomString += String(letters.randomElement()!)
        }
        return randomString
    }

    func onError(_ ErrorType: ZoomVideoSDKError, detail details: Int) {
        switch ErrorType {
        case .Errors_Success:
            print("Success")
        case .Errors_Session_Service_Invaild:
            print("Errors_Session_Service_Invaild \(ErrorType) \(details)")
        case .Errors_Session_Join_Failed:
            print("Errors_Session_Join_Failed \(ErrorType) \(details)")
        case .Errors_Session_Disconncting:
            print("Errors_Session_Disconncting \(ErrorType) \(details)")
        case .Errors_Session_Invalid_Param:
            print("Errors_Session_Invalid_Param \(ErrorType) \(details)")
        default:
            print("Error \(ErrorType) \(details)")
        }
    }

    func onSessionJoin() {
        print("onSessionJoin")

        if let session = ZoomVideoSDK.shareInstance()?.getSession() {
            if let user = session.getMySelf() {
                print("  id: \(String(describing: user.getID()))")
                print("  name: \(String(describing: user.getName()))")

                mySelf.zoomUser = user
            }
        }
        isJoined = true
        messages.removeAll()
    }
    
    func onSessionLeave() {
        print("onSessionLeave")

        isJoined = false
        users.removeAll()
    }
    
    func onUserJoin(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        print("onUserJoin")
        
        if let userArray = userArray {
            for user in userArray {
                print("  id: \(user.getID())")
                print("  name: \(String(describing: user.getName()))")
                
                // 一覧に追加
                let user = User(zoomUser: user)
                users.append(user)
            }
        }
    }
    
    func onUserLeave(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        print("onUserLeave")
        
        if let userArray = userArray {
            for user in userArray {
                print(user)
                
                // 一覧から削除
                for (i, item) in users.enumerated() {
                    if item.zoomUser == user {
                        users.remove(at: i)
                        break
                    }
                }
            }
        }
    }
    
    func onUserVideoStatusChanged(_ helper: ZoomVideoSDKVideoHelper?, user userArray: [ZoomVideoSDKUser]?) {
        print("onUserVideoStatusChanged")

        if let userArray = userArray {
            for user in userArray {
                print("  id: \(user.getID())")
                print("  name: \(String(describing: user.getName()))")

                var isVideoOn = false
                if let canvas = user.getVideoCanvas(), let on = canvas.videoStatus()?.on {
                    isVideoOn = on
                }
                print("  on: \(isVideoOn)")

                for item in users {
                    if item.zoomUser == user {
                        item.isVideoOn = isVideoOn
                        break
                    }
                }
                if user == mySelf.zoomUser {
                    mySelf.isVideoOn = isVideoOn
                }
            }
        }
    }

    func onUserAudioStatusChanged(_ helper: ZoomVideoSDKAudioHelper?, user userArray: [ZoomVideoSDKUser]?) {
        print("onUserAudioStatusChanged")
        
        if let userArray = userArray {
            for user in userArray {
                if let audioType = user.audioStatus()?.audioType {
                    print("  audioType: \(audioType)")
                }

                if let isMuted = user.audioStatus()?.isMuted  {
                    print("  isMuted: \(isMuted)")
                    
                    for item in users {
                        if item.zoomUser == user {
                            item.isMuted = isMuted
                            self.objectWillChange.send() // 再描画
                            break
                        }
                    }
                    if user == mySelf.zoomUser {
                        mySelf.isMuted = isMuted
                    }
                }
            }
        }
    }
    
    func onLiveStreamStatusChanged(_ helper: ZoomVideoSDKLiveStreamHelper?, status: ZoomVideoSDKLiveStreamStatus) {
        print("onLiveStreamStatusChanged")

        switch status {
        case .inProgress:
            print("Live stream now in progress.")
        case .ended:
            print("Live stream has ended.")
        default:
            print("Live stream status unknown.")
        }
    }
    
    func onChatNewMessageNotify(_ helper: ZoomVideoSDKChatHelper?, message: ZoomVideoSDKChatMessage?) {
        print("onChatNewMessageNotify")

        if let content = message?.content, let senderName = message?.senderUser?.getName() {
            print("\(senderName) sent a message: \(content)")
            
            addMessage(userName: senderName, text: content)
        }
    }
    
    func onUserHostChanged(_ helper: ZoomVideoSDKUserHelper?, users user: ZoomVideoSDKUser?) {
        print("onUserHostChanged")

        if let userName = user!.getName() {
            print("\(userName): is the new host.")
        }
    }
    
    func onUserActiveAudioChanged(_ helper: ZoomVideoSDKUserHelper?, users userArray: [ZoomVideoSDKUser]?) {
        print("onUserActiveAudioChanged")

        if let userArray = userArray {
            for user in userArray {
                if let audioStatus = user.audioStatus() {
                    let isTalking = audioStatus.talking
                    
                    if let userName = user.getName() {
                        print("[onUserActiveAudioChanged]\(userName) isTalking:\(isTalking)")
                    }
                    
                    for item in users {
                        if item.zoomUser == user {
                            item.isTalking = isTalking

                            let timer = Timer.scheduledTimer(withTimeInterval: 0.3,
                                                             repeats: true,
                                                             block: { (time: Timer) in
                                item.isTalking.toggle()
                            })
                            
                            Timer.scheduledTimer(withTimeInterval: 3,
                                                 repeats: false,
                                                 block: { (time: Timer) in
                                timer.invalidate()
                                item.isTalking = false
                            })
                            
                            break
                        }
                    }
                    if user == mySelf.zoomUser {
                        mySelf.isTalking = isTalking
                    }

                }
            }
        }
    }
    
    func onSessionNeedPassword(_ completion: ((String?, Bool) -> ZoomVideoSDKError)?) {
        print("onSessionNeedPassword")

        if let completion = completion {
            let userInput = "password"
            let cancelJoinSession = false
            let completionReturnValue = completion(userInput, cancelJoinSession)
            print("completionReturnValue: \(completionReturnValue)")
        }
    }
    
    func onSessionPasswordWrong(_ completion: ((String?, Bool) -> ZoomVideoSDKError)?) {
        print("onSessionPasswordWrong")

        if let completion = completion {
            let userInput = "password"
            let cancelJoinSession = false
            let completionReturnValue = completion(userInput, cancelJoinSession)
            print("completionReturnValue: \(completionReturnValue)")
        }
    }
}
```

## ContentView.swift
```swift:ContentView.swift
import SwiftUI
import ZoomVideoSDK

struct ContentView: View {
    var body: some View {
        let zoom = ZoomModel()
        MainView()
            .environmentObject(zoom)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        ContentView()
            .environmentObject(zoom)
    }
}

struct MainView: View {
    @EnvironmentObject var zoom: ZoomModel

    var body: some View {
        VStack(spacing: 0) {
            GeometryReader { geometry in
                let width = geometry.frame(in: .global).width
                let height = geometry.frame(in: .global).height

                if (width < height) {
                    // ポートレート
                    VStack(spacing: 0) {
                        HeaderView(mySelf: zoom.mySelf)

                        HStack(spacing: 0) {
                            LocalVideoView()
                                .aspectRatio(1/1, contentMode: .fit)
                                .padding(4)
                            MutedVideoView()
                        }
                        .frame(height: height * 0.1)

                        SpeakerVideoView()
                            .frame(maxHeight: .infinity)

                        ChatView()
                            .frame(height: height * 0.35)
                    }
                } else {
                    // ランドスケープ
                    VStack(spacing: 0) {
                        HeaderView(mySelf: zoom.mySelf)
                            //.frame(height: height * 0.15)

                        HStack(spacing: 0) {
                            VStack(spacing: 0) {
                                HStack(spacing: 0) {
                                    LocalVideoView()
                                        .aspectRatio(1/1, contentMode: .fit)
                                        .padding(2)
                                    
                                    MutedVideoView()
                                }
                                .frame(height: height * 0.2)

                                SpeakerVideoView()
                                    .frame(maxHeight: .infinity)
                            }
                            .frame(width: width * 0.7)
                            
                            ChatView()
                                .frame(maxHeight: .infinity)
                                .padding(.leading, 4)
                        }
                    }
                }
            }
        }
    }
}


```
## HeaderView.swift
```swift:HeaderView.swift
import SwiftUI

struct HeaderView: View {
    @EnvironmentObject var zoom: ZoomModel
    @ObservedObject var mySelf: User // View更新監視用
    
    var body: some View {
        HStack(spacing: 0) {
            Text(zoom.sessionName)
                .font(.title2)
            
            Spacer()
            
            Button(action: {
                if mySelf.isVideoOn {
                    zoom.stopVideo()
                } else {
                    zoom.startVideo()
                }
            }, label: {
                Image(systemName: mySelf.isVideoOn ? "video.fill" : "video.slash.fill")
            })
            .tint(.primary)
            
            Button(action: {
                if mySelf.isMuted {
                    mySelf.unmute()
                } else {
                    mySelf.mute()
                }
            }, label: {
                Image(systemName: mySelf.isMuted ? "mic.slash.fill" : "mic.fill")
            })
            .tint(.primary)
            .padding()
            
            Button(action: {
                if zoom.isJoined {
                    zoom.leave()
                } else {
                    zoom.join()
                }
            }) {
                HStack {
                    Image(systemName: "phone.fill")
                    Text(zoom.isJoined ? "退出" : "参加")
                }
            }
            .buttonStyle(.borderedProminent)
            .tint(zoom.isJoined ? .red : .green)
        }
        .padding(.leading, 8)
        .padding(.trailing, 8)
    }
}

struct SessionView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        VStack {
            HeaderView(mySelf: zoom.mySelf)
                .environmentObject(zoom)
                //.preferredColorScheme(.dark)
            Spacer()
        }
    }
}
```

## LocalVideoView.swift
```swift:LocalVideoView.swift
import SwiftUI

struct LocalVideoView: View {
    @EnvironmentObject var zoom: ZoomModel

    var body: some View {
        VStack {
            if zoom.isJoined, let canvas = zoom.mySelf.zoomUser.getVideoCanvas() {
                ZStack {
                    ZoomVideoView(videoCanvas: canvas)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                    
                    VStack(spacing: 0) {
                        Text(zoom.userName)
                            .font(.caption)
                            .padding(2)
                            .foregroundColor(.white)
                            .background(Color.black.opacity(0.3))
                            .padding(4)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
                }
            } else {
                // レイアウト確認用
                ZStack {
                    Text("My Video")
                        .font(.caption2)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(.gray)
                    
                    VStack(spacing: 0) {
                        Text("myself")
                            .font(.caption2)
                            .padding(2)
                            .foregroundColor(.white)
                            .background(Color.black.opacity(0.3))
                            .padding(4)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
                }
            }
        }
    }
}

struct LocalVideoView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        LocalVideoView()
            .environmentObject(zoom)
    }
}
```

## MutedVideoView.swift
```swift:MutedVideoView.swift
import SwiftUI

struct MutedVideoView: View {
    @EnvironmentObject var zoom: ZoomModel
    @State var users: [PreviewUser] = [] // レイアウト確認用
    
    var body: some View {
        GeometryReader { geometry in
            let viewHeight = geometry.frame(in: .global).height
            
            let videoHeight = viewHeight * 0.6
            let videoWidth = videoHeight
            
            ScrollView(.horizontal) {
                HStack {
                    if zoom.isJoined {
                        ForEach(zoom.users.filter({$0.isMuted == true})) { user in
                            MutedVideoItemView(user: user, videoWidth: videoWidth, videoHeight: videoHeight)
                        }
                    } else {
                        ForEach(users) { user in
                            VStack(spacing: 0) {
                                // レイアウト確認用
                                Text("\(user.id)")
                                    .font(.caption2)
                                    .frame(width: videoWidth, height: videoHeight)
                                    .background(.gray)
                                    .clipShape(Circle())

                                Text(user.name)
                                    .frame(maxHeight: .infinity)
                                    .font(.caption2)
                            }
                        }
                    }
                }
            }
            .padding(4)
        }
        .background(.bar)
        .onAppear {
            if !zoom.isJoined {
                // レイアウト確認用
                for i in 1..<6{
                    let user = PreviewUser(id: i, name: "user\(i)")
                    users.append(user)
                }
            }
        }
    }
}

struct MutedVideoItemView: View {
    @ObservedObject var user: User
    var videoWidth: CGFloat
    var videoHeight: CGFloat

    var body: some View {
        
        VStack(spacing: 0) {
            if let canvas = user.zoomUser.getVideoCanvas(), let on = canvas.videoStatus()?.on, on == true {
                ZoomVideoView(videoCanvas: user.zoomUser.getVideoCanvas()!)
                    .frame(width: videoWidth, height: videoHeight)
                    .clipShape(Circle())
            } else {
                Text(Image(systemName: "video.slash.fill"))
                    .frame(width: videoWidth, height: videoHeight)
                    .background(.gray)
                    .clipShape(Circle())
            }
            
            Text(user.zoomUser.getName()!)
                .frame(maxHeight: .infinity)
                .font(.caption2)
        }
    }
}

struct MutedVideoView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        VStack {
            MutedVideoView()
                .frame(height: 100)
                .environmentObject(zoom)
            Spacer()
        }
    }
}
```

## SpeakerVideoView.swift
```swift:SpeakerVideoView.swift
import SwiftUI

struct SpeakerVideoView: View {
    @EnvironmentObject var zoom: ZoomModel
    @State var talking = false
    @State var users: [PreviewUser] = [] // レイアウト確認用
    
    var body: some View {
        GeometryReader { geometry in
            let frameWidth = geometry.frame(in: .global).width
            let frameHeight = geometry.frame(in: .global).height
            
            let xPadding: CGFloat = 4.0
            let yPadding: CGFloat = 4.0
            let (gredRow, gredCol) = getGreadSize()
            let videoWidth = frameWidth / CGFloat(gredCol) - (xPadding * 1)
            let videoHeight = frameHeight / CGFloat(gredRow) - (yPadding * 1)
            
            let columns: [GridItem] = Array(
                repeating: GridItem(.fixed(videoWidth), spacing: xPadding, alignment: .top),
                count: gredCol)
            LazyVGrid(columns: columns, spacing: yPadding) {
                if zoom.isJoined {
                    ForEach(zoom.users.filter({$0.isMuted == false})) { user in
                        SpeakerVideoItemView(user: user, videoWidth: videoWidth, videoHeight: videoHeight)
                            .background(.bar)
                    }
                } else {
                    // レイアウト確認
                    ForEach(users) { user in
                        ZStack {
                            Text("\(user.id)")
                                .font(.caption)
                                .frame(width: videoWidth, height: videoHeight)
                                .border(Color.green, width: talking ? 8.0 : 0)
                                .animation(Animation.easeInOut, value: talking)

                            VStack {
                                Text("\(user.name)")
                                    .font(.caption)
                                    .padding(3)
                                    .foregroundColor(.white)
                                    .background(Color.black.opacity(0.3))
                                    .padding(8)
                            }
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
                        }
                        .background(.gray)
                    }
                }
            }
            .padding(2)
            .background(.bar)
        }
        .onAppear {
            // レイアウト確認用
            for i in 1..<10 {
                let user = PreviewUser(id: i, name: "speaker\(i)")
                users.append(user)
            }
            
            // アニメーションのテスト
            Timer.scheduledTimer(withTimeInterval: 0.3,
                                 repeats: true,
                                 block: { (time: Timer) in
                talking.toggle()
            })
        }
    }
    
    private func getGreadSize() -> (row: Int, col: Int) {
        var count = 0
        var row = 0
        var col = 0
        
        if zoom.isJoined {
            // ミュート解除しているユーザー数
            count = zoom.users.filter({$0.isMuted == false}).count
        } else {
            // レイアウト確認用
            count = users.count
        }
        
        // 必要な桁数と行数
        if count > 0 {
            col = Int(ceil(sqrt(Double(count)))) // 少数部切り上げ
            row = Int(ceil(Double(count) / Double(col))) // 少数部切り上げ
        }
    }
    
}

struct SpeakerVideoItemView: View {
    @ObservedObject var user: User
    @State var animation = false
    var videoWidth: CGFloat
    var videoHeight: CGFloat
    
    var body: some View {
        ZStack {
            if let canvas = user.zoomUser.getVideoCanvas(), let on = canvas.videoStatus()?.on, on == true {
                ZoomVideoView(videoCanvas: user.zoomUser.getVideoCanvas()!)
                    .frame(width: videoWidth, height: videoHeight)
                    .border(Color.green, width: user.isTalking ? 8.0 : 0)
                    .animation(Animation.easeInOut, value: user.isTalking)
            } else {
                Text(Image(systemName: "video.slash.fill"))
                    .font(.title)
                    .frame(width: videoWidth, height: videoHeight)
                    .border(Color.green, width: user.isTalking ? 8.0 : 0)
                    .animation(Animation.easeInOut, value: user.isTalking)
            }
            
            VStack {
                Text(user.getName())
                    .font(.caption)
                    .padding(3)
                    .foregroundColor(.white)
                    .background(Color.black.opacity(0.3))
                    .padding(8)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
        }
    }
}

// レイアウト確認用
struct PreviewUser: Identifiable {
    var id: Int
    var name: String
}

struct GridVideoView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        SpeakerVideoView()
            .environmentObject(zoom)
    }
}
```

## ChatView.swift
```swift:ChatView.swift
import SwiftUI

struct ChatView: View {
    @EnvironmentObject var zoom: ZoomModel
    @State var inputText = ""
    
    var body: some View {
        VStack(spacing: 0) {
            ScrollViewReader { reader in
                List {
                    ForEach(zoom.messages) { message in
                        MessageItemView(message: message, myUserName: zoom.userName)
                            .font(.footnote)
                            .background(.bar)
                    }
                    .listRowSeparator(.hidden) // 境界線を非表示
                    .listRowInsets(EdgeInsets(top:0, leading: 0, bottom:0, trailing: 0)) // 行余白を詰める
                }
                .listStyle(PlainListStyle())
                .onChange(of: zoom.lastMessageId) { id in
                    // 末尾へスクロール
                    withAnimation(.linear(duration: 2)) {
                        reader.scrollTo(id)
                    }
                }
            }
            .onTapGesture {
                // キーボードを閉じる
                UIApplication.shared.closeKeyboard()
            }

            HStack {
                TextField("メッセージを入力...", text: $inputText, onCommit: {
                    if !inputText.isEmpty {
                        zoom.sendMessage(text: inputText)
                        inputText = "" // クリア
                    }
                })
                .font(.footnote)
                .keyboardType(.default)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(4)
            }
            .background(.background)
        }
        .background(.bar)
        .onAppear {
            // レイアウト確認用
            if !zoom.isJoined {
                for i in 1..<4 {
                    zoom.addMessage(userName: "user" + String(i), text: "メッセージ" + String(i))
                }
                zoom.addMessage(userName: zoom.userName, text: "返信メッセージ1")
            }
        }
    }
}

struct MessageItemView: View {
    var message: Message
    var myUserName: String
    
    var body: some View {
        if message.userName == myUserName {
            // 自分のメッセージ
            MyMessageItemView(message: message)
        } else {
            // 相手のメッセージ
            YourMessageItemView(message: message)
        }
    }
}

struct YourMessageItemView: View {
    var message: Message
    
    var body: some View {
        HStack(spacing: 0) {
            VStack(alignment: .leading, spacing: 0) {
                Text(message.userName)
                    .font(.caption2)
                    .padding(.leading, 4)
                
                BalloonText(message.text, mirrored: true)
                    //.font(.body)
                    .padding(.leading, 8)
            }
            
            VStack() {
                Text(message.date.formatTime())
                    .font(.caption2)
                    .padding(.leading, 4)
                    .frame(maxHeight: .infinity, alignment: .bottom)
            }
            
            Spacer()
                .frame(maxWidth: .infinity)
        }
    }
}

struct MyMessageItemView: View {
    @State var message: Message
    
    var body: some View {
        HStack(spacing: 0) {
            Spacer()
                .frame(maxWidth: .infinity)
            
            VStack() {
                Text(message.date.formatTime())
                    .font(.caption2)
                    .padding(.leading, 4)
                    .frame(maxHeight: .infinity, alignment: .bottom)
            }
            
            VStack(alignment: .trailing, spacing: 0) {
                Text(message.userName)
                    .font(.caption2)
                    .padding(.trailing, 4)
                
                BalloonText(message.text, mirrored: false)
                    .padding(.trailing, 8)
            }
        }
    }
}

extension Date {
    func formatTime() -> String {
        let f = DateFormatter()
        f.timeStyle = .short
        f.dateStyle = .none
        f.locale = Locale(identifier: "ja_JP")
        let time = f.string(from: self)
        return time
    }
}

extension UIApplication {
    func closeKeyboard() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

struct ChatView_Previews: PreviewProvider {
    static var previews: some View {
        let zoom = ZoomModel()
        ChatView()
            .environmentObject(zoom)
            .previewInterfaceOrientation(.portraitUpsideDown)
    }
}
```

## ZoomVideoView.swift
```swift:ZoomVideoView.swift
import SwiftUI
import ZoomVideoSDK

struct ZoomVideoView: UIViewControllerRepresentable {
    let videoCanvas: ZoomVideoSDKVideoCanvas

    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = UIViewController()
        viewController.view.backgroundColor = .lightGray
        
        return viewController
    }
    
    func updateUIViewController(_ viewController: UIViewController, context: Context) {
        let videoAspect = ZoomVideoSDKVideoAspect.panAndScan
        videoCanvas.subscribe(with: viewController.view, andAspectMode: videoAspect)
    }
    
    final class Coordinator: NSObject {
        let parent: ZoomVideoView
        init(_ parent: ZoomVideoView) {
            self.parent = parent
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    static func dismantleUIView(_ viewController: UIViewController, coordinator: Coordinator) {
        coordinator.parent.videoCanvas.unSubscribe(with: viewController.view)
    }
}
```


## BalloonText.swift
```swift:BalloonText.swift
import SwiftUI

struct BalloonText: View {
    let text: String
    let color: Color
    let mirrored: Bool
    
    init(_ text: String,
         color: Color = Color(UIColor(red: 109/255, green: 230/255, blue: 123/255, alpha: 1.0)),
         mirrored: Bool = false
    ) {
        self.text = text
        self.color = color
        self.mirrored = mirrored
    }

    var body: some View {
        let cornerRadius = 8.0
        
        Text(text)
            .padding(.leading, 4 + (mirrored ? cornerRadius * 0.6 : 0))
            .padding(.trailing, 4 + (!mirrored ? cornerRadius * 0.6 : 0))
            .padding(.vertical, 2)
            .background(BalloonShape(
                cornerRadius: cornerRadius,
                color: color,
                mirrored: mirrored)
            )
    }
}

struct BalloonShape: View {
    var cornerRadius: Double
    var color: Color
    var mirrored = false
    
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let tailSize = CGSize(
                    width: cornerRadius * 0.6,
                    height: cornerRadius * 0.2)
                let shapeRect = CGRect(
                    x: 0,
                    y: 0,
                    width: geometry.size.width,
                    height: geometry.size.height)
                
                // 時計まわりに描いていく

                // 左上角丸
                path.addArc(
                    center: CGPoint(
                        x: shapeRect.minX + cornerRadius,
                        y: shapeRect.minY + cornerRadius),
                    radius: cornerRadius,
                    startAngle: Angle(degrees: 180),
                    endAngle: Angle(degrees: 279), clockwise: false)
                
                // 右上角丸
                path.addArc(
                    center: CGPoint(
                        x: shapeRect.maxX - cornerRadius - tailSize.width,
                        y: shapeRect.minY + cornerRadius),
                    radius: cornerRadius,
                    startAngle: Angle(degrees: 270),
                    endAngle: Angle(degrees: 270 + 45), clockwise: false)

                // しっぽ上部
                path.addQuadCurve(
                    to: CGPoint(
                        x: shapeRect.maxX,
                        y: shapeRect.minY),
                    control: CGPoint(
                        x: shapeRect.maxX - (tailSize.width / 2),
                        y: shapeRect.minY))

                // しっぽ下部
                path.addQuadCurve(
                    to: CGPoint(
                        x: shapeRect.maxX - tailSize.width,
                        y: shapeRect.minY + (cornerRadius / 2) + tailSize.height),
                    control: CGPoint(
                        x: shapeRect.maxX - (tailSize.width / 2),
                        y: shapeRect.minY))

                // 右下角丸
                path.addArc(
                    center: CGPoint(
                        x: shapeRect.maxX - cornerRadius - tailSize.width,
                        y: shapeRect.maxY - cornerRadius),
                    radius: cornerRadius,
                    startAngle: Angle(degrees: 0),
                    endAngle: Angle(degrees: 90), clockwise: false)

                // 左下角丸
                path.addArc(
                    center: CGPoint(
                        x: shapeRect.minX + cornerRadius,
                        y: shapeRect.maxY - cornerRadius),
                    radius: cornerRadius,
                    startAngle: Angle(degrees: 90),
                    endAngle: Angle(degrees: 180), clockwise: false)
            }
            .fill(self.color)
            .rotation3DEffect(.degrees(mirrored ? 180 : 0), axis: (x: 0, y: 1, z: 0))
        }
    }
}

struct BalloonText_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            BalloonText("メッセージ1")
                .font(.footnote)
            BalloonText("メッセージ2", mirrored: true)
                .font(.footnote)
        }
    }
}
```

# まとめ
Zoom Video SDKとSwiftUIの相性はバッチリでした。Zoom Video SDKの設計の良さもありZoomの処理部をモデル側に完全に分離できたので、SwiftUIの宣言的UIの恩恵を最大限に得ることができました。

こうなると、Android Jetpack Composeとの相性も気になるところですね。今後試してみたいと思います。

# 参考サイト
- [Zoom Video SDK 公式ドキュメント　](https://marketplace.zoom.us/docs/sdk/video/introduction/)
- [はじめての Zoom Video SDK - 準備編 - Qiita](https://qiita.com/yosuke-sawamura/items/72df3bd91a6319ccc35c)
- [Swiftで指定した桁数のランダムな文字列を生成 - Qiita](https://qiita.com/Kosuke-214/items/649ed2454aee695c2e00)