增加换肤功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user