LNIMChatTextInputView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. //
  2. // LNIMChatTextInputView.swift
  3. // Lanu
  4. //
  5. // Created by OneeChan on 2025/12/4.
  6. //
  7. import Foundation
  8. import UIKit
  9. import SnapKit
  10. protocol LNIMChatTextInputViewDelegate: NSObject {
  11. func onVoiceInputClick()
  12. }
  13. private enum LNIMChatTextInputType {
  14. case none
  15. case keyboard
  16. case emoji
  17. }
  18. class LNIMChatTextInputView: UIView {
  19. private var hideEmojiConstraint: Constraint?
  20. private let emojiButton = UIButton()
  21. private let emojiPanel = LNIMChatEmojiPanel()
  22. private var emojiHeight: Constraint?
  23. private let placeholderLabel = UILabel()
  24. private let inputField = LNAutoSizeTextView()
  25. private let cameraButton = UIButton()
  26. private var hideCameraConstraint: Constraint?
  27. private let sendButton = UIButton()
  28. weak var delegate: LNIMChatTextInputViewDelegate?
  29. weak var viewModel: LNIMChatViewModel?
  30. override init(frame: CGRect) {
  31. super.init(frame: frame)
  32. setupViews()
  33. adjustViewsForInputTypeChanged(type: .none)
  34. LNEventDeliver.addObserver(self)
  35. }
  36. func hideInput() {
  37. adjustViewsForInputTypeChanged(type: .none)
  38. }
  39. required init?(coder: NSCoder) {
  40. fatalError("init(coder:) has not been implemented")
  41. }
  42. }
  43. extension LNIMChatTextInputView {
  44. private func showImageSelectPanel() {
  45. let handler: (UIImage?, URL?) -> Void = { [weak self] image, _ in
  46. guard let self else { return }
  47. guard let image else { return }
  48. viewModel?.sendImageMessage(image: image)
  49. }
  50. LNBottomSheetMenu.showImageSelectMenu(view: self, handler: handler)
  51. }
  52. }
  53. extension LNIMChatTextInputView: UITextViewDelegate {
  54. func textViewDidBeginEditing(_ textView: UITextView) {
  55. if inputField.inputView == nil {
  56. adjustViewsForInputTypeChanged(type: .keyboard)
  57. }
  58. }
  59. func textViewDidChange(_ textView: UITextView) {
  60. placeholderLabel.isHidden = textView.text.isEmpty != true
  61. let inputEmpty = textView.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false
  62. if inputEmpty {
  63. hideCameraConstraint?.update(priority: .low)
  64. } else {
  65. hideCameraConstraint?.update(priority: .high)
  66. }
  67. sendButton.setImage(inputEmpty ? .icImChatVoice : .icImChatSend, for: .normal)
  68. cameraButton.isEnabled = inputEmpty
  69. UIView.animate(withDuration: 0.25) { [weak self] in
  70. guard let self else { return }
  71. cameraButton.alpha = inputEmpty ? 1.0 : 0.0
  72. layoutIfNeeded()
  73. }
  74. }
  75. func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
  76. let currentText = textField.text ?? ""
  77. guard let range = Range(range, in: currentText) else { return false }
  78. let newText = currentText.replacingCharacters(in: range, with: string)
  79. if newText.count < currentText.count {
  80. return true
  81. }
  82. return newText.count <= LNIMManager.maxMessageInput
  83. }
  84. }
  85. extension LNIMChatTextInputView: LNKeyboardNotify {
  86. func onKeyboardWillShow(curInput: UIView?, keyboardHeight: CGFloat) {
  87. guard curInput == inputField else { return }
  88. emojiHeight?.update(offset: keyboardHeight)
  89. }
  90. func onKeyboardShow(curInput: UIView?, keyboardHeight: CGFloat) {
  91. guard curInput == inputField else { return }
  92. layoutIfNeeded()
  93. }
  94. }
  95. extension LNIMChatTextInputView: LNIMChatEmojiPanelDelegate {
  96. func onIMChatEmojiPanelDidClickDelete(view: LNIMChatEmojiPanel) {
  97. guard inputField.textStorage.length > 0 else { return }
  98. inputField.textStorage.deleteCharacters(in: .init(location: inputField.textStorage.length - 1, length: 1))
  99. textViewDidChange(inputField)
  100. }
  101. func onIMChatEmojiPanel(view: LNIMChatEmojiPanel, didSelectEmoji emoji: LNEmojiData) {
  102. let attachment = LNIMChatEmojiAttachment()
  103. attachment.font = inputField.font
  104. attachment.name = emoji.name
  105. attachment.image = LNIMEmojiManager.shared.getFaceFromCache(path: emoji.path)
  106. let emojiStr = NSAttributedString(attachment: attachment)
  107. let curRange = inputField.selectedRange
  108. if curRange.length > 0 {
  109. inputField.textStorage.deleteCharacters(in: curRange)
  110. }
  111. inputField.textStorage.insert(emojiStr, at: inputField.selectedRange.location)
  112. inputField.selectedRange = .init(location: inputField.selectedRange.location + 1, length: 0)
  113. textViewDidChange(inputField)
  114. }
  115. }
  116. extension LNIMChatTextInputView {
  117. private func adjustViewsForInputTypeChanged(type: LNIMChatTextInputType) {
  118. switch type {
  119. case .none:
  120. inputField.inputView = nil
  121. inputField.resignFirstResponder()
  122. emojiButton.setImage(.icImChatEmoji, for: .normal)
  123. hideEmojiConstraint?.update(priority: .high)
  124. emojiPanel.isHidden = true
  125. emojiPanel.reloadData() // 隐藏输入框便触发刷新
  126. case .keyboard:
  127. inputField.becomeFirstResponder()
  128. inputField.inputView = nil
  129. inputField.reloadInputViews()
  130. emojiButton.setImage(.icImChatEmoji, for: .normal)
  131. hideEmojiConstraint?.update(priority: .low)
  132. emojiPanel.isHidden = true
  133. case .emoji:
  134. inputField.becomeFirstResponder()
  135. inputField.inputView = UIView()
  136. inputField.reloadInputViews()
  137. emojiButton.setImage(.icImChatKeyboard, for: .normal)
  138. hideEmojiConstraint?.update(priority: .low)
  139. emojiPanel.isHidden = false
  140. }
  141. }
  142. private func setupViews() {
  143. backgroundColor = .fill
  144. let inputMenu = buildInputMenu()
  145. addSubview(inputMenu)
  146. inputMenu.snp.makeConstraints { make in
  147. make.top.equalToSuperview().inset(10)
  148. make.leading.equalToSuperview().offset(12)
  149. hideEmojiConstraint = make.bottom.equalToSuperview().offset(-safeBottomInset).priority(.high).constraint
  150. }
  151. sendButton.setImage(.icImChatVoice, for: .normal)
  152. sendButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  153. sendButton.addAction(UIAction(handler: { [weak self] _ in
  154. guard let self else { return }
  155. let text = inputField.textStorage
  156. if text.length == 0 {
  157. delegate?.onVoiceInputClick()
  158. adjustViewsForInputTypeChanged(type: .none)
  159. } else {
  160. viewModel?.sendTextMessage(text: text.toEmojiContent)
  161. inputField.text = nil
  162. textViewDidChange(inputField)
  163. }
  164. }), for: .touchUpInside)
  165. addSubview(sendButton)
  166. sendButton.snp.makeConstraints { make in
  167. make.centerY.equalTo(inputMenu)
  168. make.trailing.equalToSuperview().offset(-12)
  169. make.leading.equalTo(inputMenu.snp.trailing).offset(10)
  170. }
  171. let emoji = buildEmojiView()
  172. addSubview(emoji)
  173. emoji.snp.makeConstraints { make in
  174. make.horizontalEdges.equalToSuperview()
  175. make.top.equalTo(inputMenu.snp.bottom).offset(10)
  176. make.bottom.equalToSuperview().priority(.medium)
  177. emojiHeight = make.height.equalTo(336).constraint
  178. }
  179. }
  180. private func buildInputMenu() -> UIView {
  181. let container = UIView()
  182. container.backgroundColor = .fill_2
  183. container.layer.cornerRadius = 19
  184. container.clipsToBounds = true
  185. emojiButton.setImage(.icImChatEmoji, for: .normal)
  186. emojiButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  187. emojiButton.addAction(UIAction(handler: { [weak self] _ in
  188. guard let self else { return }
  189. if inputField.inputView != nil {
  190. adjustViewsForInputTypeChanged(type: .keyboard)
  191. } else {
  192. adjustViewsForInputTypeChanged(type: .emoji)
  193. }
  194. inputField.reloadInputViews()
  195. }), for: .touchUpInside)
  196. container.addSubview(emojiButton)
  197. emojiButton.snp.makeConstraints { make in
  198. make.bottom.equalToSuperview().offset(-8)
  199. make.leading.equalToSuperview().offset(10)
  200. }
  201. inputField.font = .body_m
  202. inputField.textColor = .text_5
  203. inputField.backgroundColor = .clear
  204. inputField.delegate = self
  205. container.addSubview(inputField)
  206. inputField.snp.makeConstraints { make in
  207. make.verticalEdges.equalToSuperview().inset(3.5)
  208. make.leading.equalTo(emojiButton.snp.trailing).offset(4)
  209. hideCameraConstraint = make.trailing.equalToSuperview().offset(-10).priority(.low).constraint
  210. }
  211. placeholderLabel.text = .init(key: "A00084")
  212. placeholderLabel.font = .body_m
  213. placeholderLabel.textColor = .text_2
  214. container.insertSubview(placeholderLabel, belowSubview: inputField)
  215. placeholderLabel.snp.makeConstraints { make in
  216. make.centerY.equalTo(inputField)
  217. make.leading.equalTo(inputField).offset(4)
  218. }
  219. cameraButton.setImage(.icImChatCamera, for: .normal)
  220. cameraButton.addAction(UIAction(handler: { [weak self] _ in
  221. guard let self else { return }
  222. adjustViewsForInputTypeChanged(type: .none)
  223. showImageSelectPanel()
  224. }), for: .touchUpInside)
  225. container.addSubview(cameraButton)
  226. cameraButton.snp.makeConstraints { make in
  227. make.bottom.equalToSuperview().offset(-8)
  228. make.leading.equalTo(inputField.snp.trailing).offset(4)
  229. make.trailing.equalToSuperview().offset(-10).priority(.medium)
  230. }
  231. return container
  232. }
  233. private func buildEmojiView() -> UIView {
  234. emojiPanel.isHidden = true
  235. emojiPanel.delegate = self
  236. return emojiPanel
  237. }
  238. }
  239. #if DEBUG
  240. import SwiftUI
  241. struct LNIMChatTextInputViewPreview: UIViewRepresentable {
  242. func makeUIView(context: Context) -> some UIView {
  243. let container = UIView()
  244. container.backgroundColor = .lightGray
  245. let view = LNIMChatTextInputView()
  246. container.addSubview(view)
  247. view.snp.makeConstraints { make in
  248. make.leading.trailing.bottom.equalToSuperview()
  249. }
  250. return container
  251. }
  252. func updateUIView(_ uiView: UIViewType, context: Context) { }
  253. }
  254. #Preview(body: {
  255. LNIMChatTextInputViewPreview()
  256. })
  257. #endif