この記事は【Zoom Video SDK】初心者による導入メモ - iOS/Swift編に続く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
画面レイアウト
画面のサイズに依存しないレスポンシブデザインで、縦レイアウトと横レイアウトにも対応してみました。システムのダークモードにも連動します。
円形部分がマイクミュートで参加しているメンバーで、四角のマス部分がミュートを解除し会話に参加しいてるメンバーです。画面の下部はテキストチャットです。
実装のポイント
Xcodeプロジェクトの設定
前回のXcodeプロジェクト設定を参考にプロジェクトを作成してください。AppleシリコンベースのMacでシミュレーターを使う方法も記載しています。
アプリの構造
機能ごとにViewを分割し、HStack、VStackを使ってそれぞれを配置しています。ObservableObjectから派生させたZoomModelクラスに、ZoomVideoSDKのデリゲートを実装しています。このクラスインスタンスを環境変数としてVieに渡すことで、Zoomセッションの状態の変化(メンバーの参加/退出、ビデオon/offなど)に連動してViewを自動的に更新することができます。
ObservableObjectを入れ子構造にすると思ったようにViewが更新されないため、実際にはもう少し細いかい単位でViewに分割しています。
ZoomSDKのデリゲートの実装
ObservableObjectに干渉しないようにZoomVideoSDKDelegateを手前で宣言します。DelegateはNSObjectが必要なためさらに手前にNSObjectを置きます。
このコード例ではメンバーの参加/退出でメンバーリストを更新しています。View側ではこのリストをObservedObjectで宣言しておくことで更新監視が働きメンバーリストの更新に連動してViewが自動的に更新されます。
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として扱うことができます。
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イベントで発話中のユーザを検知してアニメーション効果を付けます。試したところ、このイベントは周囲の環境音や音楽にはあまり反応せず人間の肉声に反応するようですのでかなり精度の良い検知ができました。この発話イベントを使ってユーザの映像に緑の枠線のアニメーション効果を付けてみました。
- モデル側
view側でアニメーション効果を発動させるために一つめのタイマーで0.3秒間隔でトグルさせます。そして、二つ目のタイマーで3秒経過したらfalseにしてアニメーションの停止をViewに伝えます。
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アニメーションの開始と終了で速度が変わるので、発話している感じがそれっぽく表現できます。
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が自動更新されますが、今回は描画対象のビューそのものを切り替えたいので明示的に更新を発生させています。
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かを見て対象のユーザーを絞り込みます。前者がミュート状態のユーザーの一覧、後者がミュート解除しているユーザーの一覧です。
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)
}
...
}
}
}
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)
}
}
}
...
}
}
テキストチャットを実装する
- モデル側
参加メンバーからのテキストメッセージはonChatNewMessageNotifyイベントで上がってきます。自分が送信したメッセージもここに通知されます。通知されたメッセージをモデル内の配列に格納し、Publishedします。View側で最新のメッセージがスクロール表示されるように、lastMessageIdもPublishedしています。
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を監視し末尾へスクロールさせています。
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)
}
}
}
...
}
}
MessageItemViewの内部では吹き出しテキストViewを使用しています。この吹き出しテキストの実装方法は次の関連記事を参考にしてください。
サンプルコード全文
今回のサンプルコードを掲載します。レイアウト確認用のデバッグコードが残っていて読みづらいかもしれません...
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
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
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
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
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
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
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
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
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との相性も気になるところですね。今後試してみたいと思います。