// // 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!") }