提交
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
//
|
||||
// GroupCallVideoCell.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by vincepzhang on 2023/3/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TUICallEngine
|
||||
import SnapKit
|
||||
|
||||
class GroupCallVideoCell: UICollectionViewCell {
|
||||
|
||||
let selfUserStatusObserver = Observer()
|
||||
let remoteUserStatusObserver = Observer()
|
||||
let selfUserVideoAvailableObserver = Observer()
|
||||
let remoteUserVideoAvailableObserver = Observer()
|
||||
let isCameraOpenObserver = Observer()
|
||||
let isMicMuteObserver = Observer()
|
||||
let remotePlayoutVolumeObserver = Observer()
|
||||
let selfPlayoutVolumeObserver = Observer()
|
||||
let isShowLargeViewUserIdObserver = Observer()
|
||||
let enableBlurBackgroundObserver = Observer()
|
||||
let selfNetworkQualityObserver = Observer()
|
||||
let remoteNetworkQualityObserver = Observer()
|
||||
|
||||
private var viewModel = GroupCallVideoCellViewModel(remote: User())
|
||||
private var user: User = User()
|
||||
private var isSelf: Bool = false
|
||||
|
||||
private var renderView = VideoView()
|
||||
|
||||
private let titleLabel = {
|
||||
let titleLabel = UILabel(frame: CGRect.zero)
|
||||
titleLabel.textColor = UIColor.t_colorWithHexString(color: "FFFFFF")
|
||||
titleLabel.font = UIFont.systemFont(ofSize: 12)
|
||||
titleLabel.textAlignment = TUICoreDefineConvert.getIsRTL() ? .right : .left
|
||||
return titleLabel
|
||||
}()
|
||||
|
||||
private let loadingView = {
|
||||
let view = GroupLoadingView()
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}()
|
||||
|
||||
private let avatarImageView = {
|
||||
let imageView = UIImageView(frame: CGRect.zero)
|
||||
imageView.clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private let micImageView = {
|
||||
let imageView = UIImageView(frame: CGRect.zero)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
if let image = TUICallKitCommon.getBundleImage(name: "icon_mic_off") {
|
||||
imageView.image = image
|
||||
}
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private let volumeImageView = {
|
||||
let imageView = UIImageView(frame: CGRect.zero)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
if let image = TUICallKitCommon.getBundleImage(name: "icon_volume") {
|
||||
imageView.image = image
|
||||
}
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private let networkQualityView = {
|
||||
let imageView = UIImageView(frame: CGRect.zero)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
if let image = TUICallKitCommon.getBundleImage(name: "group_network_low_quality") {
|
||||
imageView.image = image
|
||||
}
|
||||
return imageView
|
||||
}()
|
||||
|
||||
lazy var switchCameraBtn: GroupCallVideoCustomButton = {
|
||||
let btn = GroupCallVideoCustomButton(type: .system)
|
||||
btn.contentMode = .scaleAspectFit
|
||||
if let image = TUICallKitCommon.getBundleImage(name: "group_switch_camera") {
|
||||
btn.setBackgroundImage(image, for: .normal)
|
||||
}
|
||||
btn.addTarget(self, action: #selector(switchCameraTouchEvent(sender: )), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
lazy var virtualBackgroundBtn: GroupCallVideoCustomButton = {
|
||||
let btn = GroupCallVideoCustomButton(type: .system)
|
||||
btn.contentMode = .scaleAspectFit
|
||||
let imageName = TUICallState.instance.enableBlurBackground.value ? "group_virtual_background_on" : "group_virtual_background_off"
|
||||
if let image = TUICallKitCommon.getBundleImage(name: imageName) {
|
||||
btn.setBackgroundImage(image, for: .normal)
|
||||
}
|
||||
btn.addTarget(self, action: #selector(virtualBackgroundTouchEvent(sender: )), for: .touchUpInside)
|
||||
return btn
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
if !loadingView.isHidden {
|
||||
loadingView.startAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isSelf {
|
||||
TUICallState.instance.selfUser.value.callStatus.removeObserver(selfUserStatusObserver)
|
||||
TUICallState.instance.selfUser.value.videoAvailable.removeObserver(selfUserVideoAvailableObserver)
|
||||
TUICallState.instance.selfUser.value.playoutVolume.removeObserver(selfPlayoutVolumeObserver)
|
||||
TUICallState.instance.enableBlurBackground.removeObserver(enableBlurBackgroundObserver)
|
||||
TUICallState.instance.selfUser.value.networkQualityReminder.removeObserver(selfNetworkQualityObserver)
|
||||
} else {
|
||||
viewModel.remoteUserStatus.removeObserver(remoteUserStatusObserver)
|
||||
viewModel.remoteUserVideoAvailable.removeObserver(remoteUserVideoAvailableObserver)
|
||||
viewModel.remoteIsShowLowNetworkQuality.removeObserver(remoteNetworkQualityObserver)
|
||||
if TUICallState.instance.showVirtualBackgroundButton {
|
||||
viewModel.remoteUserVolume.removeObserver(remotePlayoutVolumeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
TUICallState.instance.showLargeViewUserId.removeObserver(isMicMuteObserver)
|
||||
|
||||
for view in contentView.subviews {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UI Specification Processing
|
||||
private var isViewReady: Bool = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if isViewReady { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
isViewReady = true
|
||||
if !loadingView.isHidden {
|
||||
loadingView.startAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
func constructViewHierarchy() {
|
||||
contentView.addSubview(renderView)
|
||||
contentView.addSubview(avatarImageView)
|
||||
contentView.addSubview(loadingView)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(micImageView)
|
||||
contentView.addSubview(volumeImageView)
|
||||
contentView.addSubview(networkQualityView)
|
||||
contentView.addSubview(switchCameraBtn)
|
||||
if TUICallState.instance.showVirtualBackgroundButton {
|
||||
contentView.addSubview(virtualBackgroundBtn)
|
||||
}
|
||||
}
|
||||
|
||||
func activateConstraints() {
|
||||
renderView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.contentView)
|
||||
}
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.contentView)
|
||||
}
|
||||
loadingView.snp.makeConstraints { make in
|
||||
make.center.equalTo(self)
|
||||
make.width.height.equalTo(40.scaleWidth())
|
||||
}
|
||||
|
||||
if viewModel.isShowLargeViewUserId.value {
|
||||
activateLargeViewConstraints()
|
||||
} else {
|
||||
activateSmallViewConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
func activateLargeViewConstraints() {
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.leading.equalTo(self.contentView).offset(8.scaleWidth())
|
||||
make.height.equalTo(24.scaleWidth())
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
micImageView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8.scaleWidth())
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
volumeImageView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8.scaleWidth())
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
if TUICallState.instance.showVirtualBackgroundButton {
|
||||
virtualBackgroundBtn.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.bottom.trailing.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
switchCameraBtn.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.centerY.equalTo(virtualBackgroundBtn)
|
||||
make.trailing.equalTo(virtualBackgroundBtn.snp.leading).offset(-10.scaleWidth())
|
||||
}
|
||||
} else {
|
||||
switchCameraBtn.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.bottom.trailing.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
}
|
||||
networkQualityView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.centerY.equalTo(switchCameraBtn)
|
||||
make.trailing.equalTo(switchCameraBtn.snp.leading).offset(-10.scaleWidth())
|
||||
}
|
||||
}
|
||||
|
||||
func activateSmallViewConstraints() {
|
||||
volumeImageView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.leading.equalTo(self.contentView).offset(8.scaleWidth())
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
networkQualityView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
if self.micImageView.isHidden {
|
||||
make.trailing.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
} else {
|
||||
make.trailing.equalTo(self.micImageView.snp.leading).offset(-8.scaleWidth())
|
||||
}
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
micImageView.snp.remakeConstraints { make in
|
||||
make.width.height.equalTo(24.scaleWidth())
|
||||
make.trailing.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
make.bottom.equalTo(self.contentView).offset(-8.scaleWidth())
|
||||
}
|
||||
}
|
||||
|
||||
func initCell(user: User) {
|
||||
self.user = user
|
||||
viewModel = GroupCallVideoCellViewModel(remote: user)
|
||||
isSelf = TUICallState.instance.selfUser.value.id.value == user.id.value ? true : false
|
||||
initWaitingUI()
|
||||
registerObserveState()
|
||||
}
|
||||
|
||||
// MARK: Action Event
|
||||
@objc func switchCameraTouchEvent(sender: UIButton) {
|
||||
CallEngineManager.instance.switchCamera()
|
||||
}
|
||||
|
||||
@objc func virtualBackgroundTouchEvent(sender: UIButton ) {
|
||||
CallEngineManager.instance.setBlurBackground()
|
||||
}
|
||||
|
||||
// MARK: Register TUICallState Observer && Update UI
|
||||
func registerObserveState() {
|
||||
callStatusChanged()
|
||||
videoAvailableChanged()
|
||||
isCameraOpenChanged()
|
||||
volumeChanged()
|
||||
isShowLargeViewUserIdChanged()
|
||||
isMicMuteChanged()
|
||||
enableBlurBackgroundChanged()
|
||||
networkQualityChanged()
|
||||
}
|
||||
|
||||
func callStatusChanged() {
|
||||
if isSelf {
|
||||
TUICallState.instance.selfUser.value.callStatus.addObserver(selfUserStatusObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateSelfUserUI()
|
||||
})
|
||||
} else {
|
||||
viewModel.remoteUserStatus.addObserver(remoteUserStatusObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateRemoteUserCellUI()
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func videoAvailableChanged() {
|
||||
if isSelf {
|
||||
TUICallState.instance.selfUser.value.videoAvailable.addObserver(selfUserVideoAvailableObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateSelfUserUI()
|
||||
})
|
||||
} else {
|
||||
viewModel.remoteUserVideoAvailable.addObserver(remoteUserVideoAvailableObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateRemoteUserCellUI()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func volumeChanged() {
|
||||
if isSelf {
|
||||
TUICallState.instance.selfUser.value.playoutVolume.addObserver(selfPlayoutVolumeObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.volumeImageView.isHidden = newValue == 0 ? true : false
|
||||
})
|
||||
} else {
|
||||
viewModel.remoteUserVolume.addObserver(remotePlayoutVolumeObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.volumeImageView.isHidden = newValue == 0 ? true : false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isCameraOpenChanged() {
|
||||
if isSelf {
|
||||
TUICallState.instance.isCameraOpen.addObserver(isCameraOpenObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateSelfUserUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isShowLargeViewUserIdChanged() {
|
||||
viewModel.isShowLargeViewUserId.addObserver(isShowLargeViewUserIdObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if newValue {
|
||||
self.updateLargeViewUI()
|
||||
} else {
|
||||
self.updateSmallViewUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isMicMuteChanged() {
|
||||
if isSelf {
|
||||
TUICallState.instance.isMicMute.addObserver(isMicMuteObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.micImageView.isHidden = !newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enableBlurBackgroundChanged () {
|
||||
if isSelf && TUICallState.instance.showVirtualBackgroundButton {
|
||||
TUICallState.instance.enableBlurBackground.addObserver(enableBlurBackgroundObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
let imageName = TUICallState.instance.enableBlurBackground.value ? "group_virtual_background_on" : "group_virtual_background_off"
|
||||
if let image = TUICallKitCommon.getBundleImage(name: imageName) {
|
||||
self.virtualBackgroundBtn.setBackgroundImage(image, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func networkQualityChanged () {
|
||||
if isSelf {
|
||||
TUICallState.instance.selfUser.value.networkQualityReminder.addObserver(selfNetworkQualityObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.networkQualityView.isHidden = !newValue
|
||||
})
|
||||
} else {
|
||||
viewModel.remoteIsShowLowNetworkQuality.addObserver(remoteNetworkQualityObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.networkQualityView.isHidden = !newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func updateLargeViewUI() {
|
||||
if isSelf {
|
||||
switchCameraBtn.isHidden = !TUICallState.instance.isCameraOpen.value
|
||||
virtualBackgroundBtn.isHidden = !TUICallState.instance.isCameraOpen.value || !TUICallState.instance.showVirtualBackgroundButton
|
||||
titleLabel.isHidden = false
|
||||
micImageView.isHidden = !TUICallState.instance.isMicMute.value
|
||||
} else {
|
||||
switchCameraBtn.isHidden = true
|
||||
virtualBackgroundBtn.isHidden = true
|
||||
titleLabel.isHidden = false
|
||||
micImageView.isHidden = true
|
||||
}
|
||||
activateLargeViewConstraints()
|
||||
}
|
||||
|
||||
func updateSmallViewUI() {
|
||||
switchCameraBtn.isHidden = true
|
||||
virtualBackgroundBtn.isHidden = true
|
||||
titleLabel.isHidden = true
|
||||
|
||||
if isSelf {
|
||||
micImageView.isHidden = !TUICallState.instance.isMicMute.value
|
||||
} else {
|
||||
micImageView.isHidden = true
|
||||
}
|
||||
activateSmallViewConstraints()
|
||||
}
|
||||
|
||||
|
||||
// MARK: Update UI
|
||||
func initWaitingUI() {
|
||||
titleLabel.text = User.getUserDisplayName(user: viewModel.remoteUser)
|
||||
setUserAvatar()
|
||||
|
||||
if isSelf {
|
||||
initSelfUserUI()
|
||||
} else {
|
||||
updateRemoteUserCellUI()
|
||||
}
|
||||
}
|
||||
|
||||
func initSelfUserUI() {
|
||||
hiddenAllSubView()
|
||||
micImageView.isHidden = !TUICallState.instance.isMicMute.value
|
||||
|
||||
if !VideoFactory.instance.isExistVideoView(videoView: renderView) {
|
||||
renderView = VideoFactory.instance.createVideoView(userId: TUICallState.instance.selfUser.value.id.value, frame: CGRect.zero)
|
||||
renderView.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
if TUICallState.instance.isCameraOpen.value == true {
|
||||
renderView.isHidden = false
|
||||
switchCameraBtn.isHidden = false
|
||||
CallEngineManager.instance.openCamera(videoView: renderView)
|
||||
} else {
|
||||
renderView.isHidden = true
|
||||
avatarImageView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelfUserUI() {
|
||||
hiddenAllSubView()
|
||||
micImageView.isHidden = !TUICallState.instance.isMicMute.value
|
||||
|
||||
if TUICallState.instance.isCameraOpen.value == true {
|
||||
renderView.isHidden = false
|
||||
switchCameraBtn.isHidden = false
|
||||
virtualBackgroundBtn.isHidden = !TUICallState.instance.showVirtualBackgroundButton
|
||||
} else {
|
||||
switchCameraBtn.isHidden = true
|
||||
virtualBackgroundBtn.isHidden = true
|
||||
avatarImageView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
func updateRemoteUserCellUI() {
|
||||
hiddenAllSubView()
|
||||
|
||||
if viewModel.remoteUserStatus.value == .waiting {
|
||||
loadingView.isHidden = false
|
||||
loadingView.startAnimating()
|
||||
avatarImageView.isHidden = false
|
||||
} else if viewModel.remoteUserStatus.value == .accept {
|
||||
CallEngineManager.instance.startRemoteView(user: viewModel.remoteUser, videoView: renderView)
|
||||
|
||||
if viewModel.remoteUserVideoAvailable.value == true {
|
||||
renderView.isUserInteractionEnabled = false
|
||||
renderView.isHidden = false
|
||||
} else {
|
||||
avatarImageView.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hiddenAllSubView() {
|
||||
renderView.isHidden = true
|
||||
loadingView.isHidden = true
|
||||
loadingView.stopAnimating()
|
||||
avatarImageView.isHidden = true
|
||||
micImageView.isHidden = true
|
||||
volumeImageView.isHidden = true
|
||||
switchCameraBtn.isHidden = true
|
||||
virtualBackgroundBtn.isHidden = true
|
||||
networkQualityView.isHidden = true
|
||||
}
|
||||
|
||||
// MARK: Private Method
|
||||
func setUserAvatar() {
|
||||
let userIcon: UIImage? = TUICallKitCommon.getBundleImage(name: "default_user_icon")
|
||||
|
||||
if viewModel.remoteUser.avatar.value == "" {
|
||||
guard let image = userIcon else { return }
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.sd_setImage(with: URL(string: viewModel.remoteUser.avatar.value), placeholderImage: userIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GroupCallVideoCustomButton: UIButton {
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let expandedBounds = bounds.insetBy(dx: -4.scaleWidth(), dy: -4.scaleWidth())
|
||||
return expandedBounds.contains(point) ? self : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//
|
||||
// GroupCallVideoFlowLayout.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by noah on 2023/11/1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public var showLargeViewIndex: Int = -1
|
||||
|
||||
enum MosaicSegmentStyle {
|
||||
case fullWidth
|
||||
case fiftyFifty
|
||||
case oneThird
|
||||
case threeOneThirds
|
||||
case twoThirdsOneThirdRight
|
||||
case twoThirdsOneThirdCenter
|
||||
case oneThirdTwoThirds
|
||||
}
|
||||
|
||||
class GroupCallVideoFlowLayout: UICollectionViewFlowLayout {
|
||||
private var deletingIndexPaths = [IndexPath]()
|
||||
private var insertingIndexPaths = [IndexPath]()
|
||||
|
||||
private var contentBounds = CGRect.zero
|
||||
private var cachedAttributes = [UICollectionViewLayoutAttributes]()
|
||||
|
||||
override func prepare() {
|
||||
super.prepare()
|
||||
|
||||
guard let collectionView = collectionView else { return }
|
||||
|
||||
cachedAttributes.removeAll()
|
||||
contentBounds = CGRect(origin: .zero, size: collectionView.bounds.size)
|
||||
|
||||
let count = collectionView.numberOfItems(inSection: 0)
|
||||
var currentIndex = 0
|
||||
var segment: MosaicSegmentStyle = getSegment(count: count, currentIndex: currentIndex)
|
||||
let cvWidth = collectionView.bounds.size.width
|
||||
var lastFrame: CGRect = (count != 2 || showLargeViewIndex >= 0) ? .zero : CGRect(x: 0, y: cvWidth / 5, width: 0, height: 0)
|
||||
|
||||
while currentIndex < count {
|
||||
var segmentRects = [CGRect]()
|
||||
switch segment {
|
||||
case .fullWidth:
|
||||
segmentRects = [CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth)]
|
||||
|
||||
case .fiftyFifty:
|
||||
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth / 2)
|
||||
let horizontalSlices = segmentFrame.dividedIntegral(fraction: 0.5, from: .minXEdge)
|
||||
segmentRects = [horizontalSlices.first, horizontalSlices.second]
|
||||
|
||||
case .oneThird:
|
||||
segmentRects = [CGRect(x: cvWidth / 4.0, y: lastFrame.maxY + 1.0, width: cvWidth / 2.0, height: cvWidth / 2.0)]
|
||||
|
||||
case .threeOneThirds:
|
||||
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth / 3)
|
||||
let horizontalSlicesFirst = segmentFrame.dividedIntegral(fraction: 1.0 / 3, from: .minXEdge)
|
||||
let horizontalSlices = horizontalSlicesFirst.second.dividedIntegral(fraction: 0.5, from: .minXEdge)
|
||||
segmentRects = [horizontalSlicesFirst.first, horizontalSlices.first, horizontalSlices.second]
|
||||
|
||||
case .twoThirdsOneThirdRight:
|
||||
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth * 2 / 3)
|
||||
let horizontalSlices = segmentFrame.dividedIntegral(fraction: (1.0 / 3), from: .minXEdge)
|
||||
let verticalSlices = horizontalSlices.first.dividedIntegral(fraction: 0.5, from: .minYEdge)
|
||||
segmentRects = [verticalSlices.first, verticalSlices.second, horizontalSlices.second]
|
||||
|
||||
case .twoThirdsOneThirdCenter:
|
||||
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth * 2 / 3)
|
||||
let horizontalSlices = segmentFrame.dividedIntegral(fraction: (1.0 / 3), from: .minXEdge)
|
||||
let verticalSlices = horizontalSlices.first.dividedIntegral(fraction: 0.5, from: .minYEdge)
|
||||
segmentRects = [verticalSlices.first, horizontalSlices.second, verticalSlices.second]
|
||||
|
||||
case .oneThirdTwoThirds:
|
||||
let segmentFrame = CGRect(x: 0, y: lastFrame.maxY + 1.0, width: cvWidth, height: cvWidth * 2 / 3)
|
||||
let horizontalSlices = segmentFrame.dividedIntegral(fraction: (2.0 / 3), from: .minXEdge)
|
||||
let verticalSlices = horizontalSlices.second.dividedIntegral(fraction: 0.5, from: .minYEdge)
|
||||
segmentRects = [horizontalSlices.first, verticalSlices.first, verticalSlices.second]
|
||||
}
|
||||
|
||||
// Create and cache layout attributes for calculated frames.
|
||||
for rect in segmentRects {
|
||||
let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: currentIndex, section: 0))
|
||||
attributes.frame = rect
|
||||
|
||||
cachedAttributes.append(attributes)
|
||||
contentBounds = contentBounds.union(lastFrame)
|
||||
|
||||
currentIndex += 1
|
||||
lastFrame = rect
|
||||
}
|
||||
|
||||
segment = getSegment(count: count, currentIndex: currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func getSegment(count: Int, currentIndex: Int) -> MosaicSegmentStyle {
|
||||
var segment: MosaicSegmentStyle
|
||||
|
||||
// Determine first segment style.
|
||||
if currentIndex == 0 {
|
||||
var segment: MosaicSegmentStyle = .threeOneThirds
|
||||
|
||||
if count == 1 {
|
||||
segment = .fullWidth
|
||||
} else if count >= 2 && count <= 4 {
|
||||
if showLargeViewIndex >= 0 {
|
||||
segment = .fullWidth
|
||||
} else {
|
||||
segment = .fiftyFifty
|
||||
}
|
||||
} else if showLargeViewIndex == 0 {
|
||||
segment = .oneThirdTwoThirds
|
||||
} else if showLargeViewIndex == 1 {
|
||||
segment = .twoThirdsOneThirdCenter
|
||||
} else if showLargeViewIndex == 2 {
|
||||
segment = .twoThirdsOneThirdRight
|
||||
}
|
||||
|
||||
return segment
|
||||
}
|
||||
|
||||
// Determine the next segment style.
|
||||
switch count - currentIndex {
|
||||
case 1:
|
||||
if count == 3 {
|
||||
segment = .oneThird
|
||||
} else if count > 4 && showLargeViewIndex == (count - 1) {
|
||||
segment = .oneThirdTwoThirds
|
||||
} else {
|
||||
segment = .threeOneThirds
|
||||
}
|
||||
case 2:
|
||||
if count == 4 {
|
||||
segment = .fiftyFifty
|
||||
} else if count > 4 && showLargeViewIndex == currentIndex {
|
||||
segment = .oneThirdTwoThirds
|
||||
} else if count > 4 && (showLargeViewIndex == currentIndex + 1) {
|
||||
segment = .twoThirdsOneThirdCenter
|
||||
} else if count > 4 && (showLargeViewIndex == currentIndex + 2) {
|
||||
segment = .twoThirdsOneThirdRight
|
||||
} else {
|
||||
segment = .threeOneThirds
|
||||
}
|
||||
default:
|
||||
if count > 4 && showLargeViewIndex == currentIndex {
|
||||
segment = .oneThirdTwoThirds
|
||||
} else if count > 4 && (showLargeViewIndex == currentIndex + 1) {
|
||||
segment = .twoThirdsOneThirdCenter
|
||||
} else if count > 4 && (showLargeViewIndex == currentIndex + 2) {
|
||||
segment = .twoThirdsOneThirdRight
|
||||
} else {
|
||||
segment = .threeOneThirds
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return segment
|
||||
}
|
||||
|
||||
override var collectionViewContentSize: CGSize {
|
||||
return contentBounds.size
|
||||
}
|
||||
|
||||
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
guard let collectionView = collectionView else { return false }
|
||||
return !newBounds.size.equalTo(collectionView.bounds.size)
|
||||
}
|
||||
|
||||
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
return cachedAttributes[indexPath.item]
|
||||
}
|
||||
|
||||
override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
guard let attributes = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) else { return nil }
|
||||
|
||||
if !deletingIndexPaths.isEmpty {
|
||||
if deletingIndexPaths.contains(itemIndexPath) {
|
||||
attributes.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
|
||||
attributes.alpha = 0.0
|
||||
attributes.zIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
guard let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) else { return nil }
|
||||
|
||||
if insertingIndexPaths.contains(itemIndexPath) {
|
||||
attributes.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
|
||||
attributes.alpha = 0.0
|
||||
attributes.zIndex = 0
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
|
||||
super.prepare(forCollectionViewUpdates: updateItems)
|
||||
|
||||
for update in updateItems {
|
||||
switch update.updateAction {
|
||||
case .delete:
|
||||
guard let indexPath = update.indexPathBeforeUpdate else { return }
|
||||
deletingIndexPaths.append(indexPath)
|
||||
case .insert:
|
||||
guard let indexPath = update.indexPathAfterUpdate else { return }
|
||||
insertingIndexPaths.append(indexPath)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func finalizeCollectionViewUpdates() {
|
||||
super.finalizeCollectionViewUpdates()
|
||||
|
||||
deletingIndexPaths.removeAll()
|
||||
insertingIndexPaths.removeAll()
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
var attributesArray = [UICollectionViewLayoutAttributes]()
|
||||
|
||||
// Find any cell that sits within the query rect.
|
||||
guard let lastIndex = cachedAttributes.indices.last,
|
||||
let firstMatchIndex = binSearch(rect, start: 0, end: lastIndex) else { return attributesArray }
|
||||
|
||||
// Starting from the match, loop up and down through the array until all the attributes
|
||||
// have been added within the query rect.
|
||||
for attributes in cachedAttributes[..<firstMatchIndex].reversed() {
|
||||
guard attributes.frame.maxY >= rect.minY else { break }
|
||||
attributesArray.append(attributes)
|
||||
}
|
||||
|
||||
for attributes in cachedAttributes[firstMatchIndex...] {
|
||||
guard attributes.frame.minY <= rect.maxY else { break }
|
||||
attributesArray.append(attributes)
|
||||
}
|
||||
|
||||
return attributesArray
|
||||
}
|
||||
|
||||
// Perform a binary search on the cached attributes array.
|
||||
func binSearch(_ rect: CGRect, start: Int, end: Int) -> Int? {
|
||||
if end < start { return nil }
|
||||
|
||||
let mid = (start + end) / 2
|
||||
let attributes = cachedAttributes[mid]
|
||||
|
||||
if attributes.frame.intersects(rect) {
|
||||
return mid
|
||||
} else {
|
||||
if attributes.frame.maxY < rect.minY {
|
||||
return binSearch(rect, start: (mid + 1), end: end)
|
||||
} else {
|
||||
return binSearch(rect, start: start, end: (mid - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//
|
||||
// GroupCallVideoLayout.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by vincepzhang on 2023/2/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class GroupCallVideoLayout: UIView, UICollectionViewDelegate, UICollectionViewDataSource {
|
||||
|
||||
let selfCallStatusObserver = Observer()
|
||||
let isCameraOpenObserver = Observer()
|
||||
let selfUser = TUICallState.instance.selfUser.value
|
||||
|
||||
var allUserList = [User]()
|
||||
|
||||
lazy var calleeCollectionView = {
|
||||
let flowLayout = GroupCallVideoFlowLayout()
|
||||
let calleeCollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flowLayout)
|
||||
calleeCollectionView.delegate = self
|
||||
calleeCollectionView.dataSource = self
|
||||
calleeCollectionView.showsVerticalScrollIndicator = false
|
||||
calleeCollectionView.showsHorizontalScrollIndicator = false
|
||||
calleeCollectionView.backgroundColor = UIColor.clear
|
||||
return calleeCollectionView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.clear
|
||||
processUserList(remoteUserList: TUICallState.instance.remoteUserList.value)
|
||||
registerObserveState()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
TUICallState.instance.isCameraOpen.removeObserver(isCameraOpenObserver)
|
||||
TUICallState.instance.remoteUserList.removeObserver(selfCallStatusObserver)
|
||||
|
||||
showLargeViewIndex = -1
|
||||
|
||||
for view in subviews {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UI Specification Processing
|
||||
private var isViewReady: Bool = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if isViewReady { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
showHistoryLargeView()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
func constructViewHierarchy() {
|
||||
addSubview(calleeCollectionView)
|
||||
}
|
||||
|
||||
func activateConstraints() {
|
||||
calleeCollectionView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self)
|
||||
}
|
||||
}
|
||||
|
||||
func bindInteraction() {
|
||||
for i in 0..<9 {
|
||||
calleeCollectionView.register(GroupCallVideoCell.self, forCellWithReuseIdentifier: "GroupCallVideoCell_\(i)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Set TUICallState showLargeViewUserId
|
||||
func setShowLargeViewUserId(userId: String) {
|
||||
TUICallState.instance.showLargeViewUserId.value = userId
|
||||
}
|
||||
|
||||
// MARK: Register TUICallState Observer && Update UI
|
||||
func registerObserveState() {
|
||||
remoteUserChanged()
|
||||
isCameraOpenChanged()
|
||||
}
|
||||
|
||||
func remoteUserChanged() {
|
||||
TUICallState.instance.remoteUserList.addObserver(selfCallStatusObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
self.processUserList(remoteUserList: newValue)
|
||||
self.calleeCollectionView.reloadData()
|
||||
})
|
||||
}
|
||||
|
||||
func processUserList(remoteUserList: [User]) {
|
||||
allUserList.removeAll()
|
||||
selfUser.index = 0
|
||||
allUserList.append(selfUser)
|
||||
|
||||
for (index, value) in remoteUserList.enumerated() {
|
||||
value.index = index + 1
|
||||
allUserList.append(value)
|
||||
}
|
||||
}
|
||||
|
||||
func isCameraOpenChanged() {
|
||||
TUICallState.instance.isCameraOpen.addObserver(isCameraOpenObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if newValue {
|
||||
self.showMySelfAsLargeView()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func showMySelfAsLargeView() {
|
||||
var row = -1
|
||||
for (index, element) in allUserList.enumerated() where element.id.value == selfUser.id.value {
|
||||
row = index
|
||||
}
|
||||
if row >= 0 && selfUser.id.value != TUICallState.instance.showLargeViewUserId.value {
|
||||
let indexPath = IndexPath(row: row, section: 0)
|
||||
performUpdates(indexPath: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func showHistoryLargeView() {
|
||||
var row = -1
|
||||
for (index, element) in allUserList.enumerated() where element.id.value == TUICallState.instance.showLargeViewUserId.value {
|
||||
row = index
|
||||
}
|
||||
if row >= 0 && row < allUserList.count {
|
||||
let indexPath = IndexPath(row: row, section: 0)
|
||||
performUpdates(indexPath: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UICollectionViewDelegate, UICollectionViewDataSource
|
||||
extension GroupCallVideoLayout {
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return allUserList.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "GroupCallVideoCell_\(indexPath.row)",
|
||||
for: indexPath) as! GroupCallVideoCell
|
||||
cell.initCell(user: allUserList[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
performUpdates(indexPath: indexPath)
|
||||
}
|
||||
|
||||
func performUpdates(indexPath: IndexPath) {
|
||||
let count = allUserList.count
|
||||
let remoteUpdates = getRemoteUpdates(indexPath: indexPath)
|
||||
|
||||
var firstBigFlag = false
|
||||
if count >= 2 && count <= 4 && indexPath.row != showLargeViewIndex {
|
||||
firstBigFlag = true
|
||||
}
|
||||
|
||||
showLargeViewIndex = (showLargeViewIndex == indexPath.row) ? -1 : indexPath.row
|
||||
if firstBigFlag {
|
||||
showLargeViewIndex = 0
|
||||
}
|
||||
|
||||
setShowLargeViewUserId(userId: (showLargeViewIndex >= 0) ? allUserList[indexPath.row].id.value : " ")
|
||||
|
||||
// Animate all other update types together.
|
||||
calleeCollectionView.cancelInteractiveMovement()
|
||||
calleeCollectionView.performBatchUpdates({
|
||||
var deletes = [Int]()
|
||||
var inserts = [(user:User, index:Int)]()
|
||||
|
||||
for update in remoteUpdates {
|
||||
switch update {
|
||||
case let .delete(index):
|
||||
calleeCollectionView.deleteItems(at: [IndexPath(item: index, section: 0)])
|
||||
deletes.append(index)
|
||||
|
||||
case let .insert(user, index):
|
||||
calleeCollectionView.insertItems(at: [IndexPath(item: index, section: 0)])
|
||||
inserts.append((user, index))
|
||||
|
||||
case let .move(fromIndex, toIndex):
|
||||
calleeCollectionView.moveItem(at: IndexPath(item: fromIndex, section: 0),
|
||||
to: IndexPath(item: toIndex, section: 0))
|
||||
deletes.append(fromIndex)
|
||||
inserts.append((allUserList[fromIndex], toIndex))
|
||||
}
|
||||
}
|
||||
|
||||
for deletedIndex in deletes.sorted().reversed() {
|
||||
allUserList.remove(at: deletedIndex)
|
||||
}
|
||||
|
||||
let sortedInserts = inserts.sorted(by: { (userA, userB) -> Bool in
|
||||
return userA.index <= userB.index
|
||||
})
|
||||
|
||||
for insertion in sortedInserts {
|
||||
if insertion.index >= allUserList.startIndex && insertion.index <= allUserList.endIndex {
|
||||
allUserList.insert(insertion.user, at: insertion.index)
|
||||
}
|
||||
}
|
||||
}) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.calleeCollectionView.endInteractiveMovement()
|
||||
}
|
||||
}
|
||||
|
||||
func getRemoteUpdates(indexPath: IndexPath) -> [UserUpdate] {
|
||||
let count = allUserList.count
|
||||
|
||||
if count < 2 || count > 4 || indexPath.row >= count {
|
||||
return [UserUpdate]()
|
||||
}
|
||||
|
||||
if indexPath.row == showLargeViewIndex {
|
||||
return [
|
||||
UserUpdate.move(0, allUserList[indexPath.row].index)
|
||||
]
|
||||
}
|
||||
|
||||
if count == 2 || allUserList[0].index == 0 {
|
||||
return [
|
||||
UserUpdate.move(indexPath.row, 0)
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
UserUpdate.move(0, allUserList[0].index),
|
||||
UserUpdate.move(indexPath.row, 0)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// GroupLoadingView.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by noah on 2023/11/28.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
let kGroupLoadingViewDotSize: CGFloat = 6.scaleWidth()
|
||||
|
||||
class GroupLoadingView: UIView {
|
||||
|
||||
let dotSpacing: CGFloat = 8.scaleWidth()
|
||||
let animationDuration: TimeInterval = 0.5
|
||||
|
||||
private let dotLeft: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.white
|
||||
view.layer.cornerRadius = kGroupLoadingViewDotSize / 2
|
||||
view.alpha = 0.8
|
||||
return view
|
||||
}()
|
||||
|
||||
private let dotCenter: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.white
|
||||
view.layer.cornerRadius = kGroupLoadingViewDotSize / 2
|
||||
view.alpha = 0.5
|
||||
return view
|
||||
}()
|
||||
|
||||
private let dotRight: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.white
|
||||
view.layer.cornerRadius = kGroupLoadingViewDotSize / 2
|
||||
view.alpha = 0.2
|
||||
return view
|
||||
}()
|
||||
|
||||
// MARK: UI Specification Processing
|
||||
private var isViewReady: Bool = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if isViewReady { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
private func constructViewHierarchy() {
|
||||
addSubview(dotLeft)
|
||||
addSubview(dotCenter)
|
||||
addSubview(dotRight)
|
||||
}
|
||||
|
||||
private func activateConstraints() {
|
||||
dotLeft.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.centerX.equalTo(dotCenter).offset(-(dotSpacing + kGroupLoadingViewDotSize))
|
||||
make.width.height.equalTo(kGroupLoadingViewDotSize)
|
||||
}
|
||||
dotCenter.snp.makeConstraints { make in
|
||||
make.center.equalTo(self)
|
||||
make.width.height.equalTo(kGroupLoadingViewDotSize)
|
||||
}
|
||||
dotRight.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.centerX.equalTo(dotCenter).offset(dotSpacing + kGroupLoadingViewDotSize)
|
||||
make.width.height.equalTo(kGroupLoadingViewDotSize)
|
||||
}
|
||||
}
|
||||
|
||||
func resetDotsAlpha() {
|
||||
dotLeft.alpha = 0.8
|
||||
dotCenter.alpha = 0.5
|
||||
dotRight.alpha = 0.2
|
||||
}
|
||||
|
||||
// MARK: UI Animation
|
||||
private var isAnimation: Bool = false
|
||||
func startAnimating() {
|
||||
if isAnimation {
|
||||
return
|
||||
} else {
|
||||
isAnimation = true
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: animationDuration) {
|
||||
self.dotLeft.alpha = 0.2
|
||||
self.dotCenter.alpha = 0.8
|
||||
self.dotRight.alpha = 0.5
|
||||
} completion: { finished in
|
||||
if finished {
|
||||
self.startSecondAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startSecondAnimation() {
|
||||
UIView.animate(withDuration: animationDuration, delay: 0, options: [.curveEaseInOut ], animations: {
|
||||
self.dotLeft.alpha = 0.2
|
||||
self.dotCenter.alpha = 0.5
|
||||
self.dotRight.alpha = 0.8
|
||||
}, completion: { finished in
|
||||
if finished {
|
||||
self.startThirdAnimation()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func startThirdAnimation() {
|
||||
UIView.animate(withDuration: animationDuration, delay: 0, options: [.curveEaseInOut ], animations: {
|
||||
self.dotRight.alpha = 0.5
|
||||
}, completion: { finished in
|
||||
if finished {
|
||||
self.startFourthAnimation()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func startFourthAnimation() {
|
||||
UIView.animate(withDuration: animationDuration, delay: 0, options: [.curveEaseInOut ], animations: {
|
||||
self.dotLeft.alpha = 0.8
|
||||
self.dotCenter.alpha = 0.5
|
||||
self.dotRight.alpha = 0.2
|
||||
}, completion: { finished in
|
||||
if finished {
|
||||
self.isAnimation = false
|
||||
self.startAnimating()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func stopAnimating() {
|
||||
dotLeft.layer.removeAllAnimations()
|
||||
dotCenter.layer.removeAllAnimations()
|
||||
dotRight.layer.removeAllAnimations()
|
||||
resetDotsAlpha()
|
||||
isAnimation = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// SingleLayoutView.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by vincepzhang on 2023/2/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private let kCallKitSingleSmallVideoViewWidth = 100.0
|
||||
private let kCallKitSingleSmallVideoViewFrame = CGRect(x: ScreenSize.width - kCallKitSingleSmallVideoViewWidth,
|
||||
y: StatusBar_Height + 40,
|
||||
width: kCallKitSingleSmallVideoViewWidth,
|
||||
height: kCallKitSingleSmallVideoViewWidth / 9.0 * 16.0)
|
||||
private let kCallKitSingleLargeVideoViewFrame = CGRect(x: 0, y: 0, width: ScreenSize.width, height: ScreenSize.height)
|
||||
|
||||
class SingleCallVideoLayout: UIView {
|
||||
|
||||
let selfCallStatusObserver = Observer()
|
||||
let isCameraOpenObserver = Observer()
|
||||
let enableBlurBackgroundObserver = Observer()
|
||||
let remoteUserListObserver = Observer()
|
||||
|
||||
var remoteHadInit = false
|
||||
var isLocalPreViewLarge: Bool = true
|
||||
|
||||
var localPreView: VideoView {
|
||||
if VideoFactory.instance.viewMap[TUICallState.instance.selfUser.value.id.value] == nil {
|
||||
let _ = VideoFactory.instance.createVideoView(userId: TUICallState.instance.selfUser.value.id.value, frame: CGRect.zero)
|
||||
}
|
||||
return VideoFactory.instance.viewMap[TUICallState.instance.selfUser.value.id.value]?.videoView ?? VideoView(frame: CGRect.zero)
|
||||
}
|
||||
|
||||
var remotePreView: VideoView {
|
||||
guard let remoteUser = TUICallState.instance.remoteUserList.value.first else { return VideoView(frame: CGRect.zero) }
|
||||
if VideoFactory.instance.viewMap[remoteUser.id.value] == nil {
|
||||
let _ = VideoFactory.instance.createVideoView(userId: remoteUser.id.value, frame: CGRect.zero)
|
||||
}
|
||||
return VideoFactory.instance.viewMap[remoteUser.id.value]?.videoView ?? VideoView(frame: CGRect.zero)
|
||||
}
|
||||
var remoteUser: User?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.t_colorWithHexString(color: "#242424")
|
||||
|
||||
if TUICallState.instance.mediaType.value != .video {
|
||||
return
|
||||
}
|
||||
|
||||
initPreView()
|
||||
registerObserveState()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
TUICallState.instance.selfUser.value.callStatus.removeObserver(selfCallStatusObserver)
|
||||
TUICallState.instance.isCameraOpen.removeObserver(isCameraOpenObserver)
|
||||
TUICallState.instance.enableBlurBackground.removeObserver(enableBlurBackgroundObserver)
|
||||
TUICallState.instance.remoteUserList.removeObserver(remoteUserListObserver)
|
||||
}
|
||||
|
||||
// MARK: Register TUICallState Observer && Update UI
|
||||
func registerObserveState() {
|
||||
callStatusChanged()
|
||||
cameraStateChanged()
|
||||
enableBlurBackgroundStateChange()
|
||||
remoteUserListChange()
|
||||
}
|
||||
|
||||
func callStatusChanged() {
|
||||
TUICallState.instance.selfUser.value.callStatus.addObserver(selfCallStatusObserver, closure: { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if TUICallState.instance.mediaType.value == .video &&
|
||||
TUICallState.instance.selfUser.value.callStatus.value == .accept {
|
||||
self.initRemotePreView()
|
||||
self.setBeginAcceptPreview()
|
||||
}
|
||||
|
||||
if TUICallState.instance.mediaType.value == .unknown &&
|
||||
TUICallState.instance.selfUser.value.callStatus.value == .none {
|
||||
self.setEndPreview()
|
||||
self.deinitPreView()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func cameraStateChanged() {
|
||||
TUICallState.instance.isCameraOpen.addObserver(isCameraOpenObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if newValue == false && self.isLocalPreViewLarge == false {
|
||||
self.localPreView.isHidden = true
|
||||
} else {
|
||||
self.localPreView.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func enableBlurBackgroundStateChange() {
|
||||
TUICallState.instance.enableBlurBackground.addObserver(enableBlurBackgroundObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if newValue == true && self.isLocalPreViewLarge == false {
|
||||
switchPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remoteUserListChange() {
|
||||
TUICallState.instance.remoteUserList.addObserver(remoteUserListObserver) { [weak self] newValue, _ in
|
||||
guard let self = self else { return }
|
||||
if !remoteHadInit && TUICallState.instance.selfUser.value.callStatus.value == .accept {
|
||||
self.initRemotePreView()
|
||||
self.setBeginAcceptPreview()}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: update UI
|
||||
func switchPreview() {
|
||||
if isLocalPreViewLarge {
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.localPreView.frame = kCallKitSingleSmallVideoViewFrame
|
||||
self.remotePreView.frame = kCallKitSingleLargeVideoViewFrame
|
||||
} completion: { finished in
|
||||
self.sendSubviewToBack(self.remotePreView)
|
||||
}
|
||||
isLocalPreViewLarge = false
|
||||
} else {
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.localPreView.frame = kCallKitSingleLargeVideoViewFrame
|
||||
self.remotePreView.frame = kCallKitSingleSmallVideoViewFrame
|
||||
} completion: { finished in
|
||||
self.sendSubviewToBack(self.localPreView)
|
||||
}
|
||||
isLocalPreViewLarge = true
|
||||
}
|
||||
}
|
||||
|
||||
func setBeginAcceptPreview() {
|
||||
remotePreView.isHidden = false
|
||||
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.localPreView.frame = kCallKitSingleSmallVideoViewFrame
|
||||
self.remotePreView.frame = kCallKitSingleLargeVideoViewFrame
|
||||
self.localPreView.isHidden = TUICallState.instance.isCameraOpen.value ? false : true
|
||||
} completion: { finished in
|
||||
self.sendSubviewToBack(self.remotePreView)
|
||||
}
|
||||
isLocalPreViewLarge = false
|
||||
}
|
||||
|
||||
func setEndPreview() {
|
||||
CallEngineManager.instance.closeCamera()
|
||||
CallEngineManager.instance.stopRemoteView(user: self.remoteUser ?? User())
|
||||
|
||||
self.remoteUser = nil
|
||||
isLocalPreViewLarge = true
|
||||
}
|
||||
|
||||
func initPreView() {
|
||||
if TUICallState.instance.selfUser.value.callStatus.value == .waiting {
|
||||
initLocalPreView()
|
||||
} else if TUICallState.instance.selfUser.value.callStatus.value == .accept {
|
||||
initLocalPreView()
|
||||
initRemotePreView()
|
||||
setBeginAcceptPreview()
|
||||
}
|
||||
}
|
||||
|
||||
func initLocalPreView() {
|
||||
localPreView.frame = kCallKitSingleLargeVideoViewFrame
|
||||
localPreView.delegate = self
|
||||
localPreView.isUserInteractionEnabled = true
|
||||
localPreView.isHidden = false
|
||||
addSubview(localPreView)
|
||||
|
||||
if TUICallState.instance.selfUser.value.callStatus.value == .waiting {
|
||||
CallEngineManager.instance.openCamera(videoView: localPreView)
|
||||
} else if TUICallState.instance.selfUser.value.callStatus.value == .accept && TUICallState.instance.isCameraOpen.value == true {
|
||||
CallEngineManager.instance.openCamera(videoView: localPreView)
|
||||
}
|
||||
|
||||
if TUICallState.instance.isCameraOpen.value == false {
|
||||
localPreView.isHidden = true
|
||||
} else {
|
||||
localPreView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
func initRemotePreView() {
|
||||
guard let remoteUser = TUICallState.instance.remoteUserList.value.first else { return }
|
||||
self.remoteUser = remoteUser
|
||||
remotePreView.frame = kCallKitSingleSmallVideoViewFrame
|
||||
remotePreView.isUserInteractionEnabled = true
|
||||
remotePreView.delegate = self
|
||||
remotePreView.isHidden = true
|
||||
addSubview(self.remotePreView)
|
||||
remoteHadInit = true
|
||||
|
||||
CallEngineManager.instance.startRemoteView(user: remoteUser, videoView: remotePreView)
|
||||
}
|
||||
|
||||
func deinitPreView() {
|
||||
localPreView.removeFromSuperview()
|
||||
remotePreView.removeFromSuperview()
|
||||
VideoFactory.instance.viewMap.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
extension SingleCallVideoLayout: VideoViewDelegate {
|
||||
func tapGestureAction(tapGesture: UITapGestureRecognizer) {
|
||||
if tapGesture.view?.frame.size.width == CGFloat(kCallKitSingleSmallVideoViewWidth) {
|
||||
switchPreview()
|
||||
} else {
|
||||
self.clickFullScreen()
|
||||
}
|
||||
|
||||
if TUICallState.instance.isCameraOpen.value == false && self.isLocalPreViewLarge == false {
|
||||
self.localPreView.isHidden = true
|
||||
} else {
|
||||
self.localPreView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
func clickFullScreen() {
|
||||
if (TUICallState.instance.selfUser.value.callStatus.value == .accept) {
|
||||
TUICallState.instance.isShowFullScreen.value = !TUICallState.instance.isShowFullScreen.value
|
||||
}
|
||||
}
|
||||
|
||||
func panGestureAction(panGesture: UIPanGestureRecognizer) {
|
||||
if panGesture.view?.frame.size.width != CGFloat(kCallKitSingleSmallVideoViewWidth) { return }
|
||||
|
||||
let smallView = panGesture.view?.superview
|
||||
if panGesture.state == .changed {
|
||||
let translation = panGesture.translation(in: self)
|
||||
let newCenterX = translation.x + (smallView?.center.x ?? 0.0)
|
||||
let newCenterY = translation.y + (smallView?.center.y ?? 0.0)
|
||||
|
||||
if newCenterX < ((smallView?.bounds.size.width ?? 0.0) / 2.0) ||
|
||||
newCenterX > self.bounds.size.width - (smallView?.bounds.size.width ?? 0.0) / 2.0 {
|
||||
return
|
||||
}
|
||||
|
||||
if newCenterY < ((smallView?.bounds.size.height ?? 0.0) / 2.0) ||
|
||||
newCenterY > self.bounds.size.height - (smallView?.bounds.size.height ?? 0.0) / 2.0 {
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
smallView?.center = CGPoint(x: newCenterX, y: newCenterY)
|
||||
}
|
||||
panGesture.setTranslation(CGPointZero, in: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// VideoFactory.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by vincepzhang on 2023/2/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VideoFactory {
|
||||
|
||||
static let instance = VideoFactory()
|
||||
var viewMap: [String: UserVideoEntity] = Dictionary()
|
||||
|
||||
func createVideoView(userId: String,frame: CGRect) -> VideoView {
|
||||
let videoView = VideoView(frame: frame)
|
||||
let userVideoEntity = UserVideoEntity()
|
||||
userVideoEntity.userId = userId
|
||||
userVideoEntity.videoView = videoView
|
||||
if TUICallState.instance.selfUser.value.id.value == userId {
|
||||
userVideoEntity.user = TUICallState.instance.selfUser.value
|
||||
} else {
|
||||
for user in TUICallState.instance.remoteUserList.value where user.id.value == userId {
|
||||
userVideoEntity.user = user
|
||||
}
|
||||
}
|
||||
viewMap[userId] = userVideoEntity
|
||||
return videoView
|
||||
}
|
||||
|
||||
func isExistVideoView(videoView: VideoView) -> Bool {
|
||||
let isExist = false
|
||||
for view in viewMap where view.value.videoView == videoView {
|
||||
return true
|
||||
}
|
||||
return isExist
|
||||
}
|
||||
}
|
||||
|
||||
class UserVideoEntity {
|
||||
var userId: String = ""
|
||||
var user: User = User()
|
||||
var videoView: VideoView = VideoView(frame: CGRect.zero)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// VideoView.swift
|
||||
// TUICallKit
|
||||
//
|
||||
// Created by : on 2023/2/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TUICallEngine
|
||||
|
||||
@objc protocol VideoViewDelegate: NSObjectProtocol {
|
||||
@objc optional func tapGestureAction(tapGesture: UITapGestureRecognizer)
|
||||
@objc optional func panGestureAction(panGesture: UIPanGestureRecognizer)
|
||||
}
|
||||
|
||||
class VideoView: TUIVideoView {
|
||||
weak var delegate: VideoViewDelegate?
|
||||
|
||||
let volumeProgress: UIProgressView = {
|
||||
let volumeProgress = UIProgressView(progressViewStyle: .default)
|
||||
volumeProgress.backgroundColor = UIColor.clear
|
||||
return volumeProgress
|
||||
}()
|
||||
|
||||
let gestureView = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = UIColor.t_colorWithHexString(color: "#55534F")
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UI Specification Processing
|
||||
private var isViewReady: Bool = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if isViewReady { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
func constructViewHierarchy() {
|
||||
addSubview(gestureView)
|
||||
addSubview(volumeProgress)
|
||||
}
|
||||
|
||||
func activateConstraints() {
|
||||
|
||||
gestureView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self)
|
||||
}
|
||||
|
||||
volumeProgress.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalTo(self)
|
||||
make.height.equalTo(4)
|
||||
}
|
||||
}
|
||||
|
||||
func bindInteraction() {
|
||||
gestureView.backgroundColor = UIColor.clear
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapGesture(tapGesture: )))
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(panGesture(panGesture: )))
|
||||
gestureView.addGestureRecognizer(tap)
|
||||
pan.require(toFail: tap)
|
||||
gestureView.addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
// MARK: Gesture Action
|
||||
@objc func tapGesture(tapGesture: UITapGestureRecognizer) {
|
||||
if self.delegate != nil && ((self.delegate?.responds(to: Selector(("tapGestureAction")))) != nil) {
|
||||
self.delegate?.tapGestureAction?(tapGesture: tapGesture)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func panGesture(panGesture: UIPanGestureRecognizer) {
|
||||
if self.delegate != nil && ((self.delegate?.responds(to: Selector(("panGestureAction")))) != nil) {
|
||||
self.delegate?.panGestureAction?(panGesture: panGesture)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user