增加换肤功能

This commit is contained in:
启星
2025-08-14 10:07:49 +08:00
parent f6964c1e89
commit 4f9318d98e
8789 changed files with 978530 additions and 2 deletions

View File

@@ -0,0 +1,13 @@
//
// FloatchatResolverRegister.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/16.
//
import Factory
extension Container {
var floatChatService: Factory<FloatChatStoreProvider> {
self { FloatChatStore() }.shared
}
}

View File

@@ -0,0 +1,125 @@
//
// FloatChatButton.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/9.
// Copyright © 2024 Tencent. All rights reserved.
//
import UIKit
import Foundation
#if USE_OPENCOMBINE
import OpenCombine
import OpenCombineDispatch
#else
import Combine
#endif
import Factory
class FloatChatButton: UIView {
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
private lazy var floatInputViewShowState = self.store.select(FloatChatSelectors.getShowFloatInputView)
var cancellableSet = Set<AnyCancellable>()
weak var inputController: UIViewController?
var roomId = ""
private let emojiLabelSpacing: CGFloat = 6
private let horizonSpacing: CGFloat = 10
func updateRoomId(roomId: String) {
self.roomId = roomId
store.dispatch(action: FloatChatActions.setRoomId(payload: roomId))
}
private let emojiView: UIImageView = {
let emojiImage = UIImage(named: "room_emoji_icon", in: tuiRoomKitBundle(), compatibleWith: nil)
let imageView = UIImageView(image: emojiImage)
return imageView
}()
private let label: UILabel = {
let view = UILabel()
view.backgroundColor = .clear
view.textColor = UIColor.tui_color(withHex: "D5E0F2")
view.font = UIFont(name: "PingFangSC-Regular", size: 12)
view.text = .placeHolderText
view.sizeToFit()
return view
}()
private let clickView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isUserInteractionEnabled = true
return view
}()
private var isViewReady = false
override func didMoveToWindow() {
super.didMoveToWindow()
guard !isViewReady else { return }
constructViewHierarchy()
activateConstraints()
bindInteraction()
isViewReady = true
}
private func constructViewHierarchy() {
backgroundColor = UIColor.tui_color(withHex: "22262E80", alpha: 0.5)
layer.cornerRadius = 15
addSubview(emojiView)
addSubview(label)
addSubview(clickView)
}
private func activateConstraints() {
emojiView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(horizonSpacing)
make.centerY.equalToSuperview()
make.width.equalTo(24)
make.height.equalTo(24)
}
label.snp.makeConstraints { make in
make.leading.equalTo(emojiView.snp.trailing).offset(emojiLabelSpacing)
make.centerY.equalToSuperview()
make.trailing.equalToSuperview().offset(-horizonSpacing)
make.height.equalTo(24)
}
clickView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func bindInteraction() {
let tap = UITapGestureRecognizer(target: self, action: #selector(showInputView))
clickView.addGestureRecognizer(tap)
floatInputViewShowState
.receive(on: DispatchQueue.mainQueue)
.sink { [weak self] showFloatChatInput in
guard let self = self else { return }
if showFloatChatInput {
let inputController = FloatChatInputController()
inputController.view.backgroundColor = .clear
let navController = UINavigationController(rootViewController: inputController)
navController.isNavigationBarHidden = true
navController.navigationBar.prefersLargeTitles = true
navController.modalPresentationStyle = .overFullScreen
RoomCommon.getCurrentWindowViewController()?.present(navController, animated: true, completion: nil)
self.inputController = inputController
} else {
self.inputController?.dismiss(animated: true)
}
}
.store(in: &cancellableSet)
}
@objc private func showInputView() {
store.dispatch(action: FloatViewActions.showFloatInputView(payload: true))
}
}
private extension String {
static var placeHolderText: String {
localized("Say something")
}
}

View File

@@ -0,0 +1,118 @@
//
// FloatChatDisplayView.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/9.
// Copyright © 2024 Tencent. All rights reserved.
//
import UIKit
#if USE_OPENCOMBINE
import OpenCombine
import OpenCombineDispatch
#else
import Combine
#endif
import Factory
class FloatChatDisplayView: UIView {
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
private lazy var messagePublisher = self.store.select(FloatChatSelectors.getLatestMessage)
private var messages: [FloatChatMessageView] = []
var cancellableSet = Set<AnyCancellable>()
private let messageSpacing: CGFloat = 8
private lazy var blurLayer: CALayer = {
let layer = CAGradientLayer()
layer.colors = [
UIColor.black.withAlphaComponent(0).cgColor,
UIColor.black.withAlphaComponent(1).cgColor
]
layer.locations = [0, 0.2]
layer.startPoint = CGPoint(x: 0.5, y: 0)
layer.endPoint = CGPoint(x: 0.5, y: 1)
return layer
}()
override func layoutSubviews() {
super.layoutSubviews()
blurLayer.frame = self.bounds
}
private var isViewReady = false
override func didMoveToWindow() {
super.didMoveToWindow()
guard !isViewReady else { return }
constructViewHierarchy()
bindInteraction()
isViewReady = true
}
private func constructViewHierarchy() {
self.layer.mask = blurLayer
}
func bindInteraction() {
messagePublisher
.filter{ !$0.content.isEmpty }
.receive(on: DispatchQueue.mainQueue)
.sink { [weak self] floatMessage in
guard let self = self else { return }
self.addMessage(floatMessage)
}
.store(in: &cancellableSet)
}
private func addMessage(_ message: FloatChatMessage) {
let messageView = FloatChatMessageView(floatMessage: message)
if currentMessageHeight() + messageView.height + messageSpacing > bounds.height {
removeOldestMessage()
}
addSubview(messageView)
messageView.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.width.lessThanOrEqualToSuperview()
make.height.lessThanOrEqualToSuperview()
if let lastMessage = messages.last {
make.top.equalTo(lastMessage.snp.bottom).offset(messageSpacing).priority(.high)
}
make.bottom.lessThanOrEqualToSuperview()
}
messages.append(messageView)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.removeMessageWithAnimation(message: messageView)
}
}
private func currentMessageHeight() -> CGFloat {
return messages.reduce(0) { $0 + $1.height + messageSpacing}
}
private func removeOldestMessage() {
guard let oldest = messages.first else { return }
removeMessage(message: oldest)
}
private func removeMessageWithAnimation(message: FloatChatMessageView) {
UIView.animate(withDuration: 0.3) {
message.alpha = 0
} completion: { _ in
self.removeMessage(message: message)
}
}
private func removeMessage(message: FloatChatMessageView) {
if let index = messages.firstIndex(of: message) {
message.removeFromSuperview()
messages.remove(at: index)
}
}
}
extension FloatChatDisplayView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return nil
}
}

View File

@@ -0,0 +1,280 @@
//
// FloatChatInputController.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/11.
// Copyright © 2024 Tencent. All rights reserved.
//
import UIKit
import SnapKit
import Foundation
import TUICore
import Factory
class FloatChatInputController: UIViewController {
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
@Injected(\.conferenceStore) private var operation
private var textViewBottomConstraint: Constraint?
private var textViewHeightConstraint: Constraint?
private var emojiPanelTopConstraint: Constraint?
private let maxNumberOfLines = 3
private let emojiPanelHeight = 274.0
private let inputBarView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
return view
}()
private let emojiButton: LargeTapAreaButton = {
let button = LargeTapAreaButton()
let img = UIImage(named: "room_emoji_icon", in: tuiRoomKitBundle(), compatibleWith: nil)
button.setImage(img, for: .normal)
return button
}()
private let inputTextView: UITextView = {
let view = UITextView(frame: .zero)
view.font = UIFont.systemFont(ofSize: 17.5)
view.returnKeyType = UIReturnKeyType.send
view.enablesReturnKeyAutomatically = true
view.textContainer.lineBreakMode = .byCharWrapping
view.textContainerInset = UIEdgeInsets(top: view.textContainerInset.top, left: 10, bottom: view.textContainerInset.bottom, right: 10)
view.textContainer.lineFragmentPadding = 0
view.layer.cornerRadius = view.sizeThatFits(.zero).height / 2
view.layer.masksToBounds = true
view.isHidden = true
view.textColor = UIColor.tui_color(withHex: "#D5F4F2", alpha: 0.6)
view.backgroundColor = UIColor.tui_color(withHex: "#4F586B", alpha: 0.3)
return view
}()
private let sendButton: UIButton = {
let button = UIButton()
button.setTitle(.sendText, for: .normal)
button.layer.cornerRadius = 18
button.backgroundColor = UIColor.tui_color(withHex: "#006CFF")
return button
}()
private let backgroundView: UIView = {
let view = UITextView(frame: .zero)
view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
return view
}()
private lazy var emojiPanel: EmotionBoardView = {
let emotionBoardView = EmotionBoardView()
let emotionHelper = EmotionHelper.shared
emotionBoardView.emotions = emotionHelper.emotions
emotionBoardView.delegate = self
emotionBoardView.backgroundColor = UIColor.tui_color(withHex: "#22262E")
emotionBoardView.isHidden = true
return emotionBoardView
}()
private lazy var maxHeightOfTextView: CGFloat = {
let lineHeight = inputTextView.font?.lineHeight ?? 0
return ceil(lineHeight * CGFloat(maxNumberOfLines) + inputTextView.textContainerInset.top + inputTextView.textContainerInset.bottom)
}()
override func viewDidLoad() {
super.viewDidLoad()
constructViewHierarchy()
activateConstraints()
bindInteraction()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
showInputView()
}
private func constructViewHierarchy() {
inputBarView.addSubview(emojiButton)
inputBarView.addSubview(inputTextView)
inputBarView.addSubview(sendButton)
view.addSubview(backgroundView)
view.addSubview(inputBarView)
view.addSubview(emojiPanel)
}
private func activateConstraints() {
backgroundView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview()
make.top.equalTo(inputBarView.snp.top)
}
inputBarView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.height.equalTo(inputTextView).offset(2 * 12)
textViewBottomConstraint = make.bottom.equalTo(view).constraint
}
emojiButton.snp.makeConstraints { make in
make.width.height.equalTo(24)
make.centerY.equalToSuperview()
make.leading.equalTo(view.safeAreaLayoutGuide.snp.leading).offset(10)
}
sendButton.snp.makeConstraints { make in
make.width.equalTo(64)
make.height.equalTo(36)
make.centerY.equalToSuperview()
make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing).offset(-10)
}
inputTextView.snp.makeConstraints { make in
make.leading.equalTo(emojiButton.snp.trailing).offset(10)
make.trailing.equalTo(sendButton.snp.leading).offset(-10)
let size = inputTextView.sizeThatFits(.zero)
textViewHeightConstraint = make.height.equalTo(size.height).constraint
make.centerY.equalToSuperview()
}
emojiPanel.snp.makeConstraints { make in
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(emojiPanelHeight)
emojiPanelTopConstraint = make.top.equalTo(view.snp.bottom).constraint
}
}
private func bindInteraction() {
inputTextView.delegate = self
emojiButton.addTarget(self, action: #selector(onEmojiButtonTapped), for: .touchUpInside)
sendButton.addTarget(self, action: #selector(onSendButtonTapped), for: .touchUpInside)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(hideInputView))
view.addGestureRecognizer(tapGesture)
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil)
}
@objc private func keyboardWillShow(notification: NSNotification) {
guard let keyboardRect: CGRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let curve: UInt = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else {
return
}
let intersection = CGRectIntersection(keyboardRect, self.view.frame)
UIView.animate(withDuration: duration, delay: 0.0, options: UIView.AnimationOptions(rawValue: curve)) { [weak self] in
guard let self = self else { return }
self.textViewBottomConstraint?.update(offset: -CGRectGetHeight(intersection))
}
}
@objc private func onSendButtonTapped(sender: UIButton) {
if inputTextView.normalText.isEmpty {
operation.dispatch(action: ViewActions.showToast(payload: ToastInfo(message: .inputCannotBeEmpty, position: .center)))
} else {
store.dispatch(action: FloatChatActions.sendMessage(payload: inputTextView.normalText))
}
hideInputView()
}
private func showInputView() {
inputTextView.isHidden = false
inputTextView.becomeFirstResponder()
}
@objc private func hideInputView() {
inputBarView.isHidden = true
view.endEditing(true)
store.dispatch(action: FloatViewActions.showFloatInputView(payload: false))
}
@objc private func onEmojiButtonTapped(sender: UIButton) {
sender.isSelected = !sender.isSelected
if sender.isSelected {
showEmojiPanel()
} else {
hideEmojiPanel()
}
}
private func showEmojiPanel() {
inputTextView.resignFirstResponder()
emojiPanel.isHidden = false
UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
guard let self = self else { return }
self.emojiPanelTopConstraint?.update(offset: -self.emojiPanelHeight)
self.textViewBottomConstraint?.update(offset: -self.emojiPanelHeight)
}
}
private func hideEmojiPanel() {
UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
guard let self = self else { return }
self.emojiPanelTopConstraint?.update(offset: self.emojiPanelHeight)
} completion: {[weak self] _ in
guard let self = self else { return }
self.emojiPanel.isHidden = true
self.inputTextView.becomeFirstResponder()
}
}
private func updateTextViewHeight() {
let currentHeight = ceil(inputTextView.sizeThatFits(CGSize(width: inputTextView.bounds.size.width, height: CGFloat.greatestFiniteMagnitude)).height)
inputTextView.isScrollEnabled = currentHeight > maxHeightOfTextView
if currentHeight <= maxHeightOfTextView {
textViewHeightConstraint?.update(offset: currentHeight)
}
}
}
extension FloatChatInputController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
inputTextView.becomeFirstResponder()
}
func textViewDidChange(_ textView: UITextView) {
updateTextViewHeight()
}
func textViewDidEndEditing(_ textView: UITextView) {
inputTextView.resignFirstResponder()
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
store.dispatch(action: FloatChatActions.sendMessage(payload: textView.normalText))
hideInputView()
return false
}
return true
}
}
extension FloatChatInputController: EmotionBoardViewDelegate {
func emotionView(emotionBoardView: EmotionBoardView, didSelectEmotion emotion: Emotion, atIndex index: Int) {
let attributedString = EmotionHelper.shared.obtainImageAttributedString(byImageKey: emotion.displayName,
font: inputTextView.font ?? UIFont(), useCache: false)
inputTextView.insertEmotionAttributedString(emotionAttributedString: attributedString)
}
func emotionViewDidSelectDeleteButton(emotionBoardView: EmotionBoardView) {
if !inputTextView.deleteEmotion() {
inputTextView.deleteBackward()
}
}
}
class LargeTapAreaButton: UIButton {
var tapAreaPadding: CGFloat = 20
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let largerBounds = bounds.insetBy(dx: -tapAreaPadding, dy: -tapAreaPadding)
return largerBounds.contains(point)
}
}
private extension String {
static var sendText: String {
localized("Send")
}
static let inputCannotBeEmpty = localized("Input can't be empty!")
}

View File

@@ -0,0 +1,32 @@
//
// Emotion.swift
// TUILiveKit
//
// Created by krabyu on 2024/4/3.
//
import UIKit
class Emotion: Equatable {
let identifier: String
let displayName: String
var image: UIImage = UIImage()
init(identifier: String, displayName: String) {
self.identifier = identifier
self.displayName = displayName
}
static func == (left: Emotion, right: Emotion) -> Bool {
return left.identifier == right.identifier
}
var description: String {
return "identifier:\(identifier), displayName:\(displayName)"
}
}
class EmotionAttachment: NSTextAttachment {
var displayText: String = ""
}

View File

@@ -0,0 +1,177 @@
//
// EmotionHelper.swift
// TUILiveKit
//
// Created by krabyu on 2024/4/3.
//
import UIKit
class EmotionHelper {
static let shared = {
EmotionHelper()
}()
private init() {
useDefaultEmotions()
}
var emotions: [Emotion] = []
private var cacheTotalImageDictionary: [String: UIImage] = [:]
private var cacheAttributedDictionary: [String: NSAttributedString] = [:]
private var regularExpression: NSRegularExpression = try! NSRegularExpression(pattern: "\\[[a-zA-Z0-9_\\u4e00-\\u9fa5]+\\]", options: [])
func useDefaultEmotions() {
createTotalEmotions()
cacheTotalImage()
}
func setEmotions(emotions: [Emotion]) {
self.emotions = emotions
cacheTotalImageDictionary = [:]
cacheTotalImage()
}
func cacheTotalImage() {
if cacheTotalImageDictionary.count == 0 {
var emotionImageDictionary: [String: UIImage] = [:]
for emotion in emotions {
if emotion.image.size.width != 0 {
emotion.image = UIImage(named: emotion.identifier, in: tuiRoomKitBundle(), compatibleWith: nil) ?? UIImage()
}
emotionImageDictionary[emotion.displayName] = emotion.image
}
cacheTotalImageDictionary = emotionImageDictionary
}
}
func obtainImagesAttributedString(byText text: String, font: UIFont) -> NSMutableAttributedString {
let matches = regularExpression.matches(in: text, range: NSRange(location: 0, length: text.count))
let intactAttributedString = NSMutableAttributedString(string: text)
for match in matches.reversed() {
guard let emojiRange = Range(match.range, in: text) else { return NSMutableAttributedString(string: "") }
let emojiKey = String(text[emojiRange])
var useCache = true
if #available(iOS 15.0, *) {
// Cached NSAttributedString cannot be used on ios15, only one expression will appear, but it can be used on ios14 and before.
useCache = false
}
let imageAttributedString = obtainImageAttributedString(byImageKey: emojiKey, font: font, useCache: useCache)
intactAttributedString.replaceCharacters(in: match.range, with: imageAttributedString)
}
// Fixed an issue where font changed due to inserting AttributeString;
// Prevents the textView font from getting smaller after inserting an expression
intactAttributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: intactAttributedString.length))
intactAttributedString.addAttribute(.foregroundColor, value: UIColor.tui_color(withHex: "D5E0F2"), range: NSRange(location: 0, length: intactAttributedString.length))
return intactAttributedString
}
func obtainImageAttributedString(byImageKey imageKey: String, font: UIFont, useCache: Bool) -> NSAttributedString {
if !useCache {
let image = cacheTotalImageDictionary[imageKey]
if image == nil {
return NSAttributedString(string: "")
}
let emotionAttachment = EmotionAttachment()
emotionAttachment.displayText = imageKey
emotionAttachment.image = image
emotionAttachment.bounds = CGRect(x: 0, y: font.descender, width: font.lineHeight, height: font.lineHeight)
let imageAttributedString = NSAttributedString(attachment: emotionAttachment)
return imageAttributedString
}
let keyFont = String(format: "%@%.1f", imageKey, font.pointSize)
if let result = cacheAttributedDictionary[keyFont] {
return result
}
guard let image = cacheTotalImageDictionary[imageKey] else {
return NSAttributedString(string: "")
}
let emotionAttachment = EmotionAttachment()
emotionAttachment.displayText = imageKey
emotionAttachment.image = image
emotionAttachment.bounds = CGRect(x: 0, y: font.descender, width: font.lineHeight, height: font.lineHeight)
let result = NSAttributedString(attachment: emotionAttachment)
cacheAttributedDictionary[keyFont] = result
return result
}
private func createTotalEmotions() {
emotions = []
emotions.append(Emotion(identifier: "room_floatChat_emoji_0", displayName: "[TUIEmoji_Smile]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_1", displayName: "[TUIEmoji_Expect]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_2", displayName: "[TUIEmoji_Blink]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_3", displayName: "[TUIEmoji_Guffaw]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_4", displayName: "[TUIEmoji_KindSmile]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_5", displayName: "[TUIEmoji_Haha]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_6", displayName: "[TUIEmoji_Cheerful]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_7", displayName: "[TUIEmoji_Speechless]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_8", displayName: "[TUIEmoji_Amazed]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_9", displayName: "[TUIEmoji_Sorrow]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_10", displayName: "[TUIEmoji_Complacent]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_11", displayName: "[TUIEmoji_Silly]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_12", displayName: "[TUIEmoji_Lustful]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_13", displayName: "[TUIEmoji_Giggle]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_14", displayName: "[TUIEmoji_Kiss]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_15", displayName: "[TUIEmoji_Wail]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_16", displayName: "[TUIEmoji_TearsLaugh]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_17", displayName: "[TUIEmoji_Trapped]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_18", displayName: "[TUIEmoji_Mask]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_19", displayName: "[TUIEmoji_Fear]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_20", displayName: "[TUIEmoji_BareTeeth]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_21", displayName: "[TUIEmoji_FlareUp]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_22", displayName: "[TUIEmoji_Yawn]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_23", displayName: "[TUIEmoji_Tact]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_24", displayName: "[TUIEmoji_Stareyes]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_25", displayName: "[TUIEmoji_ShutUp]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_26", displayName: "[TUIEmoji_Sigh]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_27", displayName: "[TUIEmoji_Hehe]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_28", displayName: "[TUIEmoji_Silent]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_29", displayName: "[TUIEmoji_Surprised]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_30", displayName: "[TUIEmoji_Askance]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_31", displayName: "[TUIEmoji_Ok]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_32", displayName: "[TUIEmoji_Shit]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_33", displayName: "[TUIEmoji_Monster]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_34", displayName: "[TUIEmoji_Daemon]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_35", displayName: "[TUIEmoji_Rage]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_36", displayName: "[TUIEmoji_Fool]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_37", displayName: "[TUIEmoji_Pig]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_38", displayName: "[TUIEmoji_Cow]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_39", displayName: "[TUIEmoji_Ai]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_40", displayName: "[TUIEmoji_Skull]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_41", displayName: "[TUIEmoji_Bombs]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_42", displayName: "[TUIEmoji_Coffee]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_43", displayName: "[TUIEmoji_Cake]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_44", displayName: "[TUIEmoji_Beer]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_45", displayName: "[TUIEmoji_Flower]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_46", displayName: "[TUIEmoji_Watermelon]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_47", displayName: "[TUIEmoji_Rich]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_48", displayName: "[TUIEmoji_Heart]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_49", displayName: "[TUIEmoji_Moon]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_50", displayName: "[TUIEmoji_Sun]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_51", displayName: "[TUIEmoji_Star]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_52", displayName: "[TUIEmoji_RedPacket]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_53", displayName: "[TUIEmoji_Celebrate]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_54", displayName: "[TUIEmoji_Bless]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_55", displayName: "[TUIEmoji_Fortune]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_56", displayName: "[TUIEmoji_Convinced]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_57", displayName: "[TUIEmoji_Prohibit]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_58", displayName: "[TUIEmoji_666]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_59", displayName: "[TUIEmoji_857]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_60", displayName: "[TUIEmoji_Knife]"))
emotions.append(Emotion(identifier: "room_floatChat_emoji_61", displayName: "[TUIEmoji_Like]"))
for emotion in emotions {
emotion.image = UIImage(named: emotion.identifier, in: tuiRoomKitBundle(), compatibleWith: nil) ?? UIImage()
}
}
}

View File

@@ -0,0 +1,57 @@
//
// FloatChatService.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/14.
//
import Foundation
import RTCRoomEngine
import ImSDK_Plus
import TUICore
#if USE_OPENCOMBINE
import OpenCombine
#else
import Combine
#endif
import Factory
class FloatChatService: NSObject {
@WeakLazyInjected(\.floatChatService) private var store: FloatChatStoreProvider?
private let imManager = {
V2TIMManager.sharedInstance()
}()
private var roomId: String? {
self.store?.selectCurrent(FloatChatSelectors.getRoomId)
}
override init() {
super.init()
imManager?.addSimpleMsgListener(listener: self)
}
func sendGroupMessage(_ message: String) -> AnyPublisher<String, Never> {
return Future<String, Never> { [weak self] promise in
guard let self = self else { return }
self.imManager?.sendGroupTextMessage(message, to: self.roomId, priority: .PRIORITY_NORMAL, succ: {
promise(.success((message)))
}, fail: { code, message in
let errorMsg = TUITool.convertIMError(Int(code), msg: message)
//TODO: show toast from store.dispatch
RoomRouter.makeToastInWindow(toast:errorMsg ?? "send message fail", duration: 2)
})
}
.eraseToAnyPublisher()
}
}
extension FloatChatService: V2TIMSimpleMsgListener {
func onRecvGroupTextMessage(_ msgID: String!, groupID: String!, sender info: V2TIMGroupMemberInfo!, text: String!) {
guard groupID == roomId else {
return
}
let user = FloatChatUser(memberInfo: info)
let floatMessage = FloatChatMessage(user: user, content: text)
store?.dispatch(action: FloatChatActions.onMessageReceived(payload: floatMessage))
}
}

View File

@@ -0,0 +1,56 @@
//
// FloatChatState.swift
// TUIRoomKit
//
// Created by aby on 2024/5/17.
//
import Foundation
import RTCRoomEngine
import ImSDK_Plus
struct FloatChatState: Codable {
var isFloatInputViewShow = false
var roomId: String = ""
var latestMessage = FloatChatMessage()
}
struct FloatChatMessage: Codable, Equatable {
var id = UUID()
var user = FloatChatUser()
var content: String = ""
var extInfo: [String: AnyCodable] = [:]
init() {}
init(user: FloatChatUser, content: String) {
self.user = user
self.content = content
}
}
struct FloatChatUser: Codable, Equatable {
var userId: String = ""
var userName: String = ""
var avatarUrl: String?
init() {}
init(loginInfo: TUILoginUserInfo) {
self.userId = loginInfo.userId
self.userName = loginInfo.userName
self.avatarUrl = loginInfo.avatarUrl
}
init(userId: String, userName: String, avatarUrl: String?) {
self.userId = userId
self.userName = userName
self.avatarUrl = avatarUrl
}
init(memberInfo: V2TIMGroupMemberInfo) {
self.userId = memberInfo.userID ?? ""
self.userName = memberInfo.nickName ?? userId
self.avatarUrl = memberInfo.faceURL
}
}

View File

@@ -0,0 +1,22 @@
//
// FloatChatAction.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
// Copyright © 2024 Tencent. All rights reserved.
//
import Foundation
enum FloatChatActions {
static let key = "FloatChat.chat"
static let sendMessage = ActionTemplate(id: key.appending(".sendMessage"), payloadType: String.self)
static let onMessageSended = ActionTemplate(id: key.appending(".messageSended"), payloadType: String.self)
static let onMessageReceived = ActionTemplate(id: key.appending(".messageReceived"), payloadType: FloatChatMessage.self)
static let setRoomId = ActionTemplate(id: key.appending(".setRoomId"), payloadType: String.self)
}
enum FloatViewActions {
static let key = "FloatChat.view"
static let showFloatInputView = ActionTemplate(id: key.appending(".show"), payloadType: Bool.self)
}

View File

@@ -0,0 +1,21 @@
//
// FloatChatEffect.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/14.
//
import Foundation
class FloatChatEffect: Effects {
typealias Environment = FloatChatService
let sendMessage = Effect<Environment>.dispatchingOne { actions, environment in
actions.wasCreated(from: FloatChatActions.sendMessage)
.flatMap { action in
environment.sendGroupMessage(action.payload)
.map { FloatChatActions.onMessageSended(payload: $0) }
}
.eraseToAnyPublisher()
}
}

View File

@@ -0,0 +1,28 @@
//
// FloatChatReducer.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
// Copyright © 2024 Tencent. All rights reserved.
//
import Foundation
import RTCRoomEngine
let floatChatReducer = Reducer<FloatChatState>(
ReduceOn(FloatChatActions.onMessageSended) { state, action in
let selfInfo = TUIRoomEngine.getSelfInfo()
let user = FloatChatUser(loginInfo: selfInfo)
let floatMessage = FloatChatMessage(user: user, content: action.payload)
state.latestMessage = floatMessage
},
ReduceOn(FloatChatActions.onMessageReceived) { state, action in
state.latestMessage = action.payload
},
ReduceOn(FloatViewActions.showFloatInputView) { state, action in
state.isFloatInputViewShow = action.payload
},
ReduceOn(FloatChatActions.setRoomId) { state, action in
state.roomId = action.payload
}
)

View File

@@ -0,0 +1,15 @@
//
// FloatChatSelectors.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
// Copyright © 2024 Tencent. All rights reserved.
//
import Foundation
enum FloatChatSelectors {
static let getLatestMessage = Selector(keyPath: \FloatChatState.latestMessage)
static let getShowFloatInputView = Selector(keyPath: \FloatChatState.isFloatInputViewShow)
static let getRoomId = Selector(keyPath: \FloatChatState.roomId)
}

View File

@@ -0,0 +1,23 @@
//
// FloatChatState.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
// Copyright © 2024 Tencent. All rights reserved.
//
import Foundation
import RTCRoomEngine
#if USE_OPENCOMBINE
import OpenCombine
#else
import Combine
#endif
protocol FloatChatStoreProvider {
func dispatch(action: Action)
func select<Value: Equatable>(_ selector: Selector<FloatChatState, Value>) -> AnyPublisher<Value, Never>
func selectCurrent<Value>(_ selector: Selector<FloatChatState, Value>) -> Value
}

View File

@@ -0,0 +1,53 @@
//
// ConferenceRoomStore.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
//
import Foundation
#if USE_OPENCOMBINE
import OpenCombine
#else
import Combine
#endif
class FloatChatStore {
private(set) lazy var store: Store<FloatChatState, FloatChatService> = Store(initialState: FloatChatState(), environment: FloatChatService())
init() {
initStore()
}
deinit {
store.unregister(reducer: floatChatReducer)
store.unregisterEffects(withId: FloatChatEffect.id)
}
private func initStore() {
store.register(reducer: floatChatReducer)
store.register(effects: FloatChatEffect())
}
}
extension FloatChatStore: FloatChatStoreProvider {
func dispatch(action: Action) {
store.dispatch(action: action)
}
func select<Value>(_ selector: Selector<FloatChatState, Value>) -> AnyPublisher<Value, Never> where Value : Equatable {
return store.select(selector)
.removeDuplicates()
.eraseToAnyPublisher()
}
func selectCurrent<Value>(_ selector: Selector<FloatChatState, Value>) -> Value {
return store.selectCurrent(selector)
}
}

View File

@@ -0,0 +1,384 @@
//
// EmotionBoardView.swift
// TUILiveKit
//
// Created by krabyu on 2024/4/7.
//
import Foundation
import UIKit
protocol EmotionBoardViewDelegate: AnyObject {
func emotionView(emotionBoardView: EmotionBoardView, didSelectEmotion emotion: Emotion, atIndex index: Int)
func emotionViewDidSelectDeleteButton(emotionBoardView: EmotionBoardView)
}
protocol EmotionPageViewDelegate: AnyObject {
func emotionPageView(emotionPageView: EmotionPageView, didSelectEmotion emotion: Emotion, atIndex index: Int)
func emotionPageViewDidLayoutEmotions(emotionPageView: EmotionPageView)
}
class EmotionPageView: UIView {
private let buttonWidth = 35
private let buttonHeight = 30
weak var delegate: EmotionPageViewDelegate?
let emotionSelectedBackgroundView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = false
view.backgroundColor = UIColor(red: 0 / 255.0, green: 0 / 255.0, blue: 0 / 255.0, alpha: 0.16)
view.layer.cornerRadius = 3
view.alpha = 0
return view
}()
var deleteButton: UIButton = UIButton()
var deleteButtonOffset: CGPoint = CGPointZero
var emotionLayers: [CALayer] = []
var emotions: [Emotion] = []
// Record the rect of the clickable area of all emoticons in the current pageView, updated in drawRect: and used in tap events
var emotionHittingRects: [NSValue] = []
var padding = UIEdgeInsets()
var numberOfRows: Int = 4
// The size of the drawing area for each expression
var emotionSize = CGSize(width: 30, height: 30)
var emotionSelectedBackgroundExtension = UIEdgeInsets()
var minimumEmotionHorizontalSpacing: CGFloat = 16
var needsLayoutEmotions: Bool = true
var previousLayoutFrame = CGRect()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 34 / 255.0, green: 38 / 255.0, blue: 46 / 255.0, alpha: 1)
addSubview(emotionSelectedBackgroundView)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGestureRecognizer))
addGestureRecognizer(tap)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func frameForDeleteButton(deleteButton: UIView) -> CGRect {
var rect = deleteButton.frame
let x = CGRectGetWidth(bounds) - padding.right - CGRectGetWidth(deleteButton.frame) -
(emotionSize.width - CGRectGetWidth(deleteButton.frame)) / 2.0 + deleteButtonOffset.x
let y = CGRectGetHeight(bounds) - padding.bottom - CGRectGetHeight(deleteButton.frame) -
(emotionSize.height - CGRectGetHeight(deleteButton.frame)) / 2.0 + deleteButtonOffset.y
rect.origin = CGPoint(x: x, y: y)
return rect
}
override func layoutSubviews() {
super.layoutSubviews()
if deleteButton.superview == self {
// The Delete button must be placed in the position of the last expression,
// and is centered left and right above and below the expression
deleteButton.frame = frameForDeleteButton(deleteButton: deleteButton)
}
let isSizeChanged = !CGSizeEqualToSize(previousLayoutFrame.size, frame.size)
previousLayoutFrame = frame
if isSizeChanged {
setNeedsLayoutEmotions()
}
layoutEmotionsIfNeeded()
}
func setNeedsLayoutEmotions() {
needsLayoutEmotions = true
}
func setEmotions(emotions: [Emotion]) {
if self.emotions == emotions { return }
self.emotions = emotions
setNeedsLayoutEmotions()
setNeedsLayout()
}
func layoutEmotionsIfNeeded() {
if !needsLayoutEmotions { return }
needsLayoutEmotions = false
emotionHittingRects.removeAll()
let contentSize = bounds.inset(by: padding).size
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
let emotionHorizontalSpacing = (contentSize.width - emotionCountPerRow * emotionSize.width) / (emotionCountPerRow - 1)
let emotionVerticalSpacing = Int(contentSize.height - CGFloat(numberOfRows) * emotionSize.height) / Int(numberOfRows - 1)
emotionSelectedBackgroundExtension = UIEdgeInsets(top: CGFloat(-emotionVerticalSpacing) / 2,
left: -emotionHorizontalSpacing / 2,
bottom: CGFloat(-emotionVerticalSpacing) / 2,
right: -emotionHorizontalSpacing / 2)
var emotionOrigin = CGPointZero
let emotionCount = emotions.count
for i in stride(from: 0, to: emotionCount, by: 1) {
var emotionLayer: CALayer
if i < emotionLayers.count {
emotionLayer = emotionLayers[i]
} else {
emotionLayer = CALayer()
emotionLayer.contentsScale = UIScreen.main.scale
emotionLayers.append(emotionLayer)
layer.addSublayer(emotionLayer)
}
emotionLayer.contents = emotions[i].image.cgImage
let row = i / Int(emotionCountPerRow)
emotionOrigin.x = padding.left + (emotionSize.width + emotionHorizontalSpacing) * CGFloat(i % Int(emotionCountPerRow))
emotionOrigin.y = padding.top + (emotionSize.height + CGFloat(emotionVerticalSpacing)) * CGFloat(row)
let emotionRect = CGRect(x: emotionOrigin.x, y: emotionOrigin.y, width: emotionSize.width, height: emotionSize.height)
let emotionHittingRect = emotionRect.inset(by: emotionSelectedBackgroundExtension)
emotionHittingRects.append(NSValue(cgRect: emotionHittingRect))
emotionLayer.frame = emotionRect
emotionLayer.isHidden = false
}
if emotionLayers.count > emotionCount {
for i in emotionLayers.count - emotionCount ..< emotionLayers.count {
emotionLayers[i].isHidden = true
}
}
delegate?.emotionPageViewDidLayoutEmotions(emotionPageView: self)
}
@objc func handleTapGestureRecognizer(_ gestureRecognizer: UITapGestureRecognizer) {
let location = gestureRecognizer.location(in: self)
for i in 0 ..< emotionHittingRects.count {
let rect = emotionHittingRects[i].cgRectValue
if rect.contains(location) {
let layer = emotionLayers[i]
if layer.opacity < 0.2 { return }
let emotion = emotions[i]
emotionSelectedBackgroundView.frame = rect
UIView.animate(withDuration: 0.08, animations: { [weak self] in
guard let self = self else { return }
self.emotionSelectedBackgroundView.alpha = 1
}, completion: { [weak self] _ in
guard let self = self else { return }
UIView.animate(withDuration: 0.08, animations: {
self.emotionSelectedBackgroundView.alpha = 0
}, completion: nil)
})
delegate?.emotionPageView(emotionPageView: self, didSelectEmotion: emotion, atIndex: i)
return
}
}
}
func verticalSizeThatFits(size: CGSize, emotionVerticalSpacing: CGFloat) -> CGSize {
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
let contentSize = rect.inset(by: padding).size
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
let row = ceil(CGFloat(emotions.count) / (emotionCountPerRow * 1.0))
let height = (emotionSize.height + emotionVerticalSpacing) * row - emotionVerticalSpacing + (padding.top + padding.bottom)
return CGSize(width: size.width, height: height)
}
func updateDeleteButton(deleteButton: UIButton) {
self.deleteButton = deleteButton
addSubview(deleteButton)
}
func setDeleteButtonOffset(deleteButtonOffset: CGPoint) {
self.deleteButtonOffset = deleteButtonOffset
setNeedsLayout()
}
}
class EmotionVerticalScrollView: UIScrollView {
let pageView: EmotionPageView = {
let pageView = EmotionPageView()
pageView.deleteButton.isHidden = true
return pageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(pageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setEmotions(emotions: [Emotion],
emotionSize: CGSize,
minimumEmotionHorizontalSpacing: CGFloat,
emotionVerticalSpacing: CGFloat,
emotionSelectedBackgroundExtension: UIEdgeInsets,
paddingInPage: UIEdgeInsets) {
let pageView = self.pageView
pageView.emotions = emotions
pageView.padding = paddingInPage
let contentSize = CGSize(width: bounds.size.width - edgeInsetsGetHorizontalValue(insets: paddingInPage),
height: bounds.size.height - edgeInsetsGetVerticalValue(insets: paddingInPage))
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
pageView.numberOfRows = Int(ceil(CGFloat(emotions.count) / emotionCountPerRow))
pageView.emotionSize = emotionSize
pageView.emotionSelectedBackgroundExtension = emotionSelectedBackgroundExtension
pageView.minimumEmotionHorizontalSpacing = minimumEmotionHorizontalSpacing
pageView.setNeedsLayout()
let size = pageView.verticalSizeThatFits(size: bounds.size, emotionVerticalSpacing: emotionVerticalSpacing)
self.pageView.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
self.contentSize = size
}
func adjustEmotionsAlpha(withFloatingRect floatingRect: CGRect) {
let contentSize = CGSize(width: contentSize.width - edgeInsetsGetHorizontalValue(insets: pageView.padding),
height: contentSize.height - edgeInsetsGetVerticalValue(insets: pageView.padding))
let emotionCountPerRow = (contentSize.width + pageView.minimumEmotionHorizontalSpacing) /
((pageView.emotionSize.width) + (pageView.minimumEmotionHorizontalSpacing))
let emotionVerticalSpacing = Int(contentSize.height - CGFloat(pageView.numberOfRows) *
(pageView.emotionSize.height)) / Int(CGFloat(pageView.numberOfRows) - 1)
let emotionHorizontalSpacing = (contentSize.width - emotionCountPerRow * (pageView.emotionSize.width)) / (emotionCountPerRow - 1)
let columnIndexLeft = ceil((floatingRect.origin.x - (pageView.padding.left)) / ((pageView.emotionSize.width) +
emotionHorizontalSpacing)) - 1
let columnIndexRight = emotionCountPerRow - 1
let rowIndexTop = ((floatingRect.origin.y - (pageView.padding.top)) / ((pageView.emotionSize.height) +
CGFloat(emotionVerticalSpacing))) - 1
for i in 0 ..< pageView.emotionLayers.count {
let row = i / Int(emotionCountPerRow)
let column = i % Int(emotionCountPerRow)
CATransaction.begin()
CATransaction.setDisableActions(true)
if column >= Int(columnIndexLeft) && column <= Int(columnIndexRight) && row > Int(rowIndexTop) {
if row == Int(ceil(rowIndexTop)) {
let intersectAreaHeight = floatingRect.origin.y - pageView.emotionLayers[i].frame.origin.y
let percent = intersectAreaHeight / pageView.emotionSize.height
pageView.emotionLayers[i].opacity = Float(percent * percent)
} else {
pageView.emotionLayers[i].opacity = 0
}
} else {
pageView.emotionLayers[i].opacity = 1.0
}
CATransaction.commit()
}
}
func edgeInsetsGetVerticalValue(insets: UIEdgeInsets) -> CGFloat {
return insets.top + insets.bottom
}
func edgeInsetsGetHorizontalValue(insets: UIEdgeInsets) -> CGFloat {
return insets.left + insets.right
}
}
class EmotionBoardView: UIView {
private let buttonWidth = 35
private let buttonHeight = 30
var emotions: [Emotion] = []
weak var delegate: EmotionBoardViewDelegate?
var deleteButtonMargins = UIEdgeInsets(top: 0, left: 0, bottom: 18, right: 18)
var pagedEmotions: [Emotion] = []
let emotionVerticalSpacing = 16
let paddingInPage = UIEdgeInsets(top: 18, left: 18, bottom: 65, right: 18)
let numberOfRowsPerPage: Int = 4
let emotionSize = CGSize(width: 34, height: 34)
let emotionSelectedBackgroundExtension = UIEdgeInsets(top: -3, left: -3, bottom: -3, right: 03)
let minimumEmotionHorizontalSpacing: CGFloat = 16
let deleteButtonOffset: CGPoint = CGPointZero
let pageControlMarginBottom: CGFloat = 22
lazy var verticalScrollView: EmotionVerticalScrollView = {
let scrollView = EmotionVerticalScrollView()
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.delegate = self
return scrollView
}()
lazy var deleteButton: UIButton = {
let button = UIButton()
button.setImage(UIImage(named: "room_floatchat_delete", in: tuiRoomKitBundle(), compatibleWith: nil), for: .normal)
button.addTarget(self, action: #selector(didSelectDeleteButton), for: .touchUpInside)
button.layer.cornerRadius = 4
button.backgroundColor = .white
return button
}()
lazy var topLineView: UIView = {
let view = UIView()
view.frame = CGRect(x: 0, y: 0, width: CGRectGetWidth(bounds), height: 1 / UIScreen.main.scale)
view.backgroundColor = UIColor(red: 34 / 255.0, green: 38 / 255.0, blue: 46 / 255.0, alpha: 1)
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
didInitialized(withFrame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
didInitialized(withFrame: CGRectZero)
}
func didInitialized(withFrame frame: CGRect) {
addSubview(verticalScrollView)
addSubview(deleteButton)
addSubview(topLineView)
}
func setEmotions(emotions: [Emotion]) {
self.emotions = emotions
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
deleteButton.frame = CGRect(x: Int(Double(Int(bounds.width) - Int(deleteButtonMargins.right) - buttonWidth)),
y: Int(Double(bounds.height - safeAreaInsets.bottom) - deleteButtonMargins.bottom) - buttonHeight,
width: buttonWidth, height: buttonHeight)
var paddingInPage = paddingInPage
paddingInPage.bottom = paddingInPage.bottom + safeAreaInsets.bottom
let verticalScrollViewFrame = bounds.inset(by: .zero)
verticalScrollView.frame = verticalScrollViewFrame
verticalScrollView.setEmotions(emotions: emotions,
emotionSize: emotionSize,
minimumEmotionHorizontalSpacing: minimumEmotionHorizontalSpacing,
emotionVerticalSpacing: CGFloat(emotionVerticalSpacing),
emotionSelectedBackgroundExtension: emotionSelectedBackgroundExtension,
paddingInPage: paddingInPage)
verticalScrollView.pageView.delegate = self
topLineView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 1 / UIScreen.main.scale)
}
func adjustEmotionsAlpha() {
let x = deleteButton.frame.origin.x
let y = deleteButton.frame.origin.y
let width = deleteButton.frame.maxX
let height = deleteButton.frame.maxY - deleteButton.frame.minY
let buttonGroupRect = CGRect(x: x, y: y, width: width, height: height)
let floatingRect = verticalScrollView.convert(buttonGroupRect, from: self)
verticalScrollView.adjustEmotionsAlpha(withFloatingRect: floatingRect)
}
@objc func didSelectDeleteButton() {
delegate?.emotionViewDidSelectDeleteButton(emotionBoardView: self)
}
}
extension EmotionBoardView: EmotionPageViewDelegate {
func emotionPageView(emotionPageView: EmotionPageView, didSelectEmotion emotion: Emotion, atIndex index: Int) {
let index = emotions.firstIndex(of: emotion) ?? -1
delegate?.emotionView(emotionBoardView: self, didSelectEmotion: emotion, atIndex: index)
}
func emotionPageViewDidLayoutEmotions(emotionPageView: EmotionPageView) {
adjustEmotionsAlpha()
}
}
extension EmotionBoardView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == verticalScrollView {
adjustEmotionsAlpha()
}
}
}

View File

@@ -0,0 +1,98 @@
//
// FloatChatMessageView.swift
// TUIRoomKit
//
// Created by CY zhao on 2024/5/10.
//
import SnapKit
import UIKit
class FloatChatMessageView: UIView {
private let messageHorizonSpacing: CGFloat = 8
private let messageVerticalSpacing: CGFloat = 5
init(floatMessage: FloatChatMessage? = nil) {
self.floatMessage = floatMessage
super.init(frame: .zero)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
lazy var messageLabel: UILabel = {
let label = UILabel()
label.font = UIFont(name: "PingFangSC-Regular", size: 14.0)
label.numberOfLines = 0
label.text = " "
label.textAlignment = .left
label.lineBreakMode = .byWordWrapping
label.textColor = .white
return label
}()
var floatMessage: FloatChatMessage? {
didSet {
guard let floatMessage = floatMessage else {
return
}
updateMessage(with: floatMessage)
}
}
var height: CGFloat {
return messageLabel.frame.height + 2 * messageVerticalSpacing
}
func setupUI() {
backgroundColor = UIColor.tui_color(withHex: "#22262E", alpha: 0.4)
layer.cornerRadius = 13.0
constructViewHierarchy()
activateConstraints()
updateMessage(with: floatMessage)
}
private func constructViewHierarchy() {
addSubview(messageLabel)
}
private func activateConstraints() {
messageLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(messageHorizonSpacing)
make.top.equalToSuperview().offset(messageVerticalSpacing)
}
self.snp.makeConstraints { make in
make.width.equalTo(messageLabel).offset(2 * messageHorizonSpacing)
make.height.equalTo(messageLabel).offset(2 * messageVerticalSpacing)
}
}
private func updateMessage(with message: FloatChatMessage?) {
guard let message = message else {
messageLabel.attributedText = nil
return
}
messageLabel.attributedText = getAttributedText(from: message)
}
private func getAttributedText(from message: FloatChatMessage) -> NSMutableAttributedString {
var userName = message.user.userName.isEmpty ? message.user.userId : message.user.userName
userName = userName + ": "
let userNameAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.tui_color(withHex: "B2BBD1")]
let userNameAttributedText = NSMutableAttributedString(string: userName,
attributes: userNameAttributes)
let contentAttributedText: NSMutableAttributedString = getFullContentAttributedText(content: message.content)
userNameAttributedText.append(contentAttributedText)
return userNameAttributedText
}
private func getFullContentAttributedText(content: String) -> NSMutableAttributedString {
return EmotionHelper.shared.obtainImagesAttributedString(byText: content,
font: UIFont(name: "PingFangSC-Regular", size: 12) ??
UIFont.systemFont(ofSize: 12))
}
}

View File

@@ -0,0 +1,72 @@
//
// UITextView+Emotion.swift
// TUILiveKit
//
// Created by krabyu on 2024/4/3.
//
import UIKit
extension UITextView {
func insertEmotionAttributedString(emotionAttributedString: NSAttributedString) {
guard let content = attributedText.mutableCopy() as? NSMutableAttributedString
else {
return
}
let location = selectedRange.location
content.insert(emotionAttributedString, at: location)
content.addAttributes([.font: font ?? UIFont.systemFont(ofSize: 14),
.foregroundColor: textColor ?? .black,],
range: NSRange(location: location, length: emotionAttributedString.length))
attributedText = content
let newRange = NSRange(location: location + emotionAttributedString.length, length: 0)
selectedRange = newRange
}
func insertEmotionKey(emotionKey: String) {
guard let content: NSMutableAttributedString = attributedText.copy() as? NSMutableAttributedString else { return }
let location = selectedRange.location
content.insert(NSAttributedString(string: emotionKey, attributes: [.font: font ?? UIFont.systemFont(ofSize: 14),
.foregroundColor: textColor ?? .black,]), at: location)
attributedText = content
let newRange = NSRange(location: location + emotionKey.count, length: 0)
selectedRange = newRange
}
func deleteEmotion() -> Bool {
let location = selectedRange.location
if location == 0 { return false }
let headSubstring = text.prefix(location)
if headSubstring.hasSuffix("]") {
for i in stride(from: headSubstring.count, to: 0 - 1, by: -1) {
let index = headSubstring.index(headSubstring.startIndex, offsetBy: i - 1)
let tempString = headSubstring[index]
if tempString == "[" {
guard let content: NSMutableAttributedString = attributedText.copy() as? NSMutableAttributedString else { return false }
content.deleteCharacters(in: NSRange(location: i - 1, length: headSubstring.count - (i - 1)))
attributedText = content
let newRange = NSRange(location: headSubstring.count, length: 0)
selectedRange = newRange
return true
}
}
}
return false
}
var normalText: String {
guard let attributedText = attributedText else { return "" }
var normalText = ""
attributedText.enumerateAttributes(in: NSRange(location: 0, length: attributedText.length)) { attributes, range, _ in
if let attachment = attributes[.attachment] as? EmotionAttachment {
let emotionAttachment = attachment
normalText += emotionAttachment.displayText
} else {
let substring = (attributedText.string as NSString).substring(with: range)
normalText += substring
}
}
return normalText
}
}