FloatChatInputController.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. //
  2. // FloatChatInputController.swift
  3. // TUIRoomKit
  4. //
  5. // Created by CY zhao on 2024/5/11.
  6. // Copyright © 2024 Tencent. All rights reserved.
  7. //
  8. import UIKit
  9. import SnapKit
  10. import Foundation
  11. import TUICore
  12. import Factory
  13. class FloatChatInputController: UIViewController {
  14. @Injected(\.floatChatService) private var store: FloatChatStoreProvider
  15. @Injected(\.conferenceStore) private var operation
  16. private var textViewBottomConstraint: Constraint?
  17. private var textViewHeightConstraint: Constraint?
  18. private var emojiPanelTopConstraint: Constraint?
  19. private let maxNumberOfLines = 3
  20. private let emojiPanelHeight = 274.0
  21. private let inputBarView: UIView = {
  22. let view = UIView()
  23. view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
  24. return view
  25. }()
  26. private let emojiButton: LargeTapAreaButton = {
  27. let button = LargeTapAreaButton()
  28. let img = UIImage(named: "room_emoji_icon", in: tuiRoomKitBundle(), compatibleWith: nil)
  29. button.setImage(img, for: .normal)
  30. return button
  31. }()
  32. private let inputTextView: UITextView = {
  33. let view = UITextView(frame: .zero)
  34. view.font = UIFont.systemFont(ofSize: 17.5)
  35. view.returnKeyType = UIReturnKeyType.send
  36. view.enablesReturnKeyAutomatically = true
  37. view.textContainer.lineBreakMode = .byCharWrapping
  38. view.textContainerInset = UIEdgeInsets(top: view.textContainerInset.top, left: 10, bottom: view.textContainerInset.bottom, right: 10)
  39. view.textContainer.lineFragmentPadding = 0
  40. view.layer.cornerRadius = view.sizeThatFits(.zero).height / 2
  41. view.layer.masksToBounds = true
  42. view.isHidden = true
  43. view.textColor = UIColor.tui_color(withHex: "#D5F4F2", alpha: 0.6)
  44. view.backgroundColor = UIColor.tui_color(withHex: "#4F586B", alpha: 0.3)
  45. return view
  46. }()
  47. private let sendButton: UIButton = {
  48. let button = UIButton()
  49. button.setTitle(.sendText, for: .normal)
  50. button.layer.cornerRadius = 18
  51. button.backgroundColor = UIColor.tui_color(withHex: "#006CFF")
  52. return button
  53. }()
  54. private let backgroundView: UIView = {
  55. let view = UITextView(frame: .zero)
  56. view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
  57. return view
  58. }()
  59. private lazy var emojiPanel: EmotionBoardView = {
  60. let emotionBoardView = EmotionBoardView()
  61. let emotionHelper = EmotionHelper.shared
  62. emotionBoardView.emotions = emotionHelper.emotions
  63. emotionBoardView.delegate = self
  64. emotionBoardView.backgroundColor = UIColor.tui_color(withHex: "#22262E")
  65. emotionBoardView.isHidden = true
  66. return emotionBoardView
  67. }()
  68. private lazy var maxHeightOfTextView: CGFloat = {
  69. let lineHeight = inputTextView.font?.lineHeight ?? 0
  70. return ceil(lineHeight * CGFloat(maxNumberOfLines) + inputTextView.textContainerInset.top + inputTextView.textContainerInset.bottom)
  71. }()
  72. override func viewDidLoad() {
  73. super.viewDidLoad()
  74. constructViewHierarchy()
  75. activateConstraints()
  76. bindInteraction()
  77. }
  78. override func viewWillAppear(_ animated: Bool) {
  79. super.viewWillAppear(animated)
  80. showInputView()
  81. }
  82. private func constructViewHierarchy() {
  83. inputBarView.addSubview(emojiButton)
  84. inputBarView.addSubview(inputTextView)
  85. inputBarView.addSubview(sendButton)
  86. view.addSubview(backgroundView)
  87. view.addSubview(inputBarView)
  88. view.addSubview(emojiPanel)
  89. }
  90. private func activateConstraints() {
  91. backgroundView.snp.makeConstraints { make in
  92. make.leading.trailing.equalToSuperview()
  93. make.bottom.equalToSuperview()
  94. make.top.equalTo(inputBarView.snp.top)
  95. }
  96. inputBarView.snp.makeConstraints { make in
  97. make.leading.trailing.equalToSuperview()
  98. make.height.equalTo(inputTextView).offset(2 * 12)
  99. textViewBottomConstraint = make.bottom.equalTo(view).constraint
  100. }
  101. emojiButton.snp.makeConstraints { make in
  102. make.width.height.equalTo(24)
  103. make.centerY.equalToSuperview()
  104. make.leading.equalTo(view.safeAreaLayoutGuide.snp.leading).offset(10)
  105. }
  106. sendButton.snp.makeConstraints { make in
  107. make.width.equalTo(64)
  108. make.height.equalTo(36)
  109. make.centerY.equalToSuperview()
  110. make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing).offset(-10)
  111. }
  112. inputTextView.snp.makeConstraints { make in
  113. make.leading.equalTo(emojiButton.snp.trailing).offset(10)
  114. make.trailing.equalTo(sendButton.snp.leading).offset(-10)
  115. let size = inputTextView.sizeThatFits(.zero)
  116. textViewHeightConstraint = make.height.equalTo(size.height).constraint
  117. make.centerY.equalToSuperview()
  118. }
  119. emojiPanel.snp.makeConstraints { make in
  120. make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
  121. make.height.equalTo(emojiPanelHeight)
  122. emojiPanelTopConstraint = make.top.equalTo(view.snp.bottom).constraint
  123. }
  124. }
  125. private func bindInteraction() {
  126. inputTextView.delegate = self
  127. emojiButton.addTarget(self, action: #selector(onEmojiButtonTapped), for: .touchUpInside)
  128. sendButton.addTarget(self, action: #selector(onSendButtonTapped), for: .touchUpInside)
  129. let tapGesture = UITapGestureRecognizer(target: self, action: #selector(hideInputView))
  130. view.addGestureRecognizer(tapGesture)
  131. NotificationCenter.default.addObserver(self,
  132. selector: #selector(keyboardWillShow),
  133. name: UIResponder.keyboardWillShowNotification,
  134. object: nil)
  135. }
  136. @objc private func keyboardWillShow(notification: NSNotification) {
  137. guard let keyboardRect: CGRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
  138. let curve: UInt = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt,
  139. let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
  140. else {
  141. return
  142. }
  143. let intersection = CGRectIntersection(keyboardRect, self.view.frame)
  144. UIView.animate(withDuration: duration, delay: 0.0, options: UIView.AnimationOptions(rawValue: curve)) { [weak self] in
  145. guard let self = self else { return }
  146. self.textViewBottomConstraint?.update(offset: -CGRectGetHeight(intersection))
  147. }
  148. }
  149. @objc private func onSendButtonTapped(sender: UIButton) {
  150. if inputTextView.normalText.isEmpty {
  151. operation.dispatch(action: ViewActions.showToast(payload: ToastInfo(message: .inputCannotBeEmpty, position: .center)))
  152. } else {
  153. store.dispatch(action: FloatChatActions.sendMessage(payload: inputTextView.normalText))
  154. store.dispatch(action: FloatChatActions.reportData(payload: .metricsBarrageSendMessage))
  155. }
  156. hideInputView()
  157. }
  158. private func showInputView() {
  159. inputTextView.isHidden = false
  160. inputTextView.becomeFirstResponder()
  161. }
  162. @objc private func hideInputView() {
  163. inputBarView.isHidden = true
  164. view.endEditing(true)
  165. store.dispatch(action: FloatViewActions.showFloatInputView(payload: false))
  166. }
  167. @objc private func onEmojiButtonTapped(sender: UIButton) {
  168. sender.isSelected = !sender.isSelected
  169. if sender.isSelected {
  170. showEmojiPanel()
  171. } else {
  172. hideEmojiPanel()
  173. }
  174. }
  175. private func showEmojiPanel() {
  176. inputTextView.resignFirstResponder()
  177. emojiPanel.isHidden = false
  178. UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
  179. guard let self = self else { return }
  180. self.emojiPanelTopConstraint?.update(offset: -self.emojiPanelHeight)
  181. self.textViewBottomConstraint?.update(offset: -self.emojiPanelHeight)
  182. }
  183. }
  184. private func hideEmojiPanel() {
  185. UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
  186. guard let self = self else { return }
  187. self.emojiPanelTopConstraint?.update(offset: self.emojiPanelHeight)
  188. } completion: {[weak self] _ in
  189. guard let self = self else { return }
  190. self.emojiPanel.isHidden = true
  191. self.inputTextView.becomeFirstResponder()
  192. }
  193. }
  194. private func updateTextViewHeight() {
  195. let currentHeight = ceil(inputTextView.sizeThatFits(CGSize(width: inputTextView.bounds.size.width, height: CGFloat.greatestFiniteMagnitude)).height)
  196. inputTextView.isScrollEnabled = currentHeight > maxHeightOfTextView
  197. if currentHeight <= maxHeightOfTextView {
  198. textViewHeightConstraint?.update(offset: currentHeight)
  199. }
  200. }
  201. }
  202. extension FloatChatInputController: UITextViewDelegate {
  203. func textViewDidBeginEditing(_ textView: UITextView) {
  204. inputTextView.becomeFirstResponder()
  205. }
  206. func textViewDidChange(_ textView: UITextView) {
  207. updateTextViewHeight()
  208. }
  209. func textViewDidEndEditing(_ textView: UITextView) {
  210. inputTextView.resignFirstResponder()
  211. }
  212. func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
  213. if text == "\n" {
  214. store.dispatch(action: FloatChatActions.sendMessage(payload: textView.normalText))
  215. hideInputView()
  216. return false
  217. }
  218. return true
  219. }
  220. }
  221. extension FloatChatInputController: EmotionBoardViewDelegate {
  222. func emotionView(emotionBoardView: EmotionBoardView, didSelectEmotion emotion: Emotion, atIndex index: Int) {
  223. let attributedString = EmotionHelper.shared.obtainImageAttributedString(byImageKey: emotion.displayName,
  224. font: inputTextView.font ?? UIFont(), useCache: false)
  225. inputTextView.insertEmotionAttributedString(emotionAttributedString: attributedString)
  226. }
  227. func emotionViewDidSelectDeleteButton(emotionBoardView: EmotionBoardView) {
  228. if !inputTextView.deleteEmotion() {
  229. inputTextView.deleteBackward()
  230. }
  231. }
  232. }
  233. class LargeTapAreaButton: UIButton {
  234. var tapAreaPadding: CGFloat = 20
  235. override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
  236. let largerBounds = bounds.insetBy(dx: -tapAreaPadding, dy: -tapAreaPadding)
  237. return largerBounds.contains(point)
  238. }
  239. }
  240. private extension String {
  241. static var sendText: String {
  242. localized("Send")
  243. }
  244. static let inputCannotBeEmpty = localized("Input can't be empty!")
  245. }