| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- //
- // LNIMChatTextInputView.swift
- // Lanu
- //
- // Created by OneeChan on 2025/12/4.
- //
- import Foundation
- import UIKit
- import SnapKit
- protocol LNIMChatTextInputViewDelegate: NSObject {
- func onVoiceInputClick()
- }
- private enum LNIMChatTextInputType {
- case none
- case keyboard
- case emoji
- }
- class LNIMChatTextInputView: UIView {
- private var hideEmojiConstraint: Constraint?
-
- private let emojiButton = UIButton()
- private let emojiPanel = LNIMChatEmojiPanel()
- private var emojiHeight: Constraint?
-
- private let placeholderLabel = UILabel()
- private let inputField = LNAutoSizeTextView()
-
- private let cameraButton = UIButton()
- private var hideCameraConstraint: Constraint?
-
- private let sendButton = UIButton()
-
- weak var delegate: LNIMChatTextInputViewDelegate?
- weak var viewModel: LNIMChatViewModel?
-
- override init(frame: CGRect) {
- super.init(frame: frame)
-
- setupViews()
- adjustViewsForInputTypeChanged(type: .none)
-
- LNEventDeliver.addObserver(self)
- }
-
- func hideInput() {
- adjustViewsForInputTypeChanged(type: .none)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- }
- extension LNIMChatTextInputView {
- private func showImageSelectPanel() {
- let handler: (UIImage?, URL?) -> Void = { [weak self] image, _ in
- guard let self else { return }
- guard let image else { return }
- viewModel?.sendImageMessage(image: image)
- }
-
- LNBottomSheetMenu.showImageSelectMenu(view: self, handler: handler)
- }
- }
- extension LNIMChatTextInputView: UITextViewDelegate {
- func textViewDidBeginEditing(_ textView: UITextView) {
- if inputField.inputView == nil {
- adjustViewsForInputTypeChanged(type: .keyboard)
- }
- }
-
- func textViewDidChange(_ textView: UITextView) {
- placeholderLabel.isHidden = textView.text.isEmpty != true
-
- let inputEmpty = textView.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false
- if inputEmpty {
- hideCameraConstraint?.update(priority: .low)
- } else {
- hideCameraConstraint?.update(priority: .high)
- }
- sendButton.setImage(inputEmpty ? .icImChatVoice : .icImChatSend, for: .normal)
- cameraButton.isEnabled = inputEmpty
- UIView.animate(withDuration: 0.25) { [weak self] in
- guard let self else { return }
- cameraButton.alpha = inputEmpty ? 1.0 : 0.0
- layoutIfNeeded()
- }
- }
-
- func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
- let currentText = textField.text ?? ""
-
- guard let range = Range(range, in: currentText) else { return false }
- let newText = currentText.replacingCharacters(in: range, with: string)
- if newText.count < currentText.count {
- return true
- }
-
- return newText.count <= LNIMManager.maxMessageInput
- }
- }
- extension LNIMChatTextInputView: LNKeyboardNotify {
- func onKeyboardWillShow(curInput: UIView?, keyboardHeight: CGFloat) {
- guard curInput == inputField else { return }
-
- emojiHeight?.update(offset: keyboardHeight)
- }
-
- func onKeyboardShow(curInput: UIView?, keyboardHeight: CGFloat) {
- guard curInput == inputField else { return }
- layoutIfNeeded()
- }
- }
- extension LNIMChatTextInputView: LNIMChatEmojiPanelDelegate {
- func onIMChatEmojiPanelDidClickDelete(view: LNIMChatEmojiPanel) {
- guard inputField.textStorage.length > 0 else { return }
- inputField.textStorage.deleteCharacters(in: .init(location: inputField.textStorage.length - 1, length: 1))
- textViewDidChange(inputField)
- }
-
- func onIMChatEmojiPanel(view: LNIMChatEmojiPanel, didSelectEmoji emoji: LNEmojiData) {
- let attachment = LNIMChatEmojiAttachment()
- attachment.font = inputField.font
- attachment.name = emoji.name
- attachment.image = LNIMEmojiManager.shared.getFaceFromCache(path: emoji.path)
-
- let emojiStr = NSAttributedString(attachment: attachment)
- let curRange = inputField.selectedRange
- if curRange.length > 0 {
- inputField.textStorage.deleteCharacters(in: curRange)
- }
- inputField.textStorage.insert(emojiStr, at: inputField.selectedRange.location)
- inputField.selectedRange = .init(location: inputField.selectedRange.location + 1, length: 0)
-
- textViewDidChange(inputField)
- }
- }
- extension LNIMChatTextInputView {
- private func adjustViewsForInputTypeChanged(type: LNIMChatTextInputType) {
- switch type {
- case .none:
- inputField.inputView = nil
- inputField.resignFirstResponder()
-
- emojiButton.setImage(.icImChatEmoji, for: .normal)
-
- hideEmojiConstraint?.update(priority: .high)
- emojiPanel.isHidden = true
- emojiPanel.reloadData() // 隐藏输入框便触发刷新
- case .keyboard:
- inputField.becomeFirstResponder()
- inputField.inputView = nil
- inputField.reloadInputViews()
-
- emojiButton.setImage(.icImChatEmoji, for: .normal)
-
- hideEmojiConstraint?.update(priority: .low)
- emojiPanel.isHidden = true
- case .emoji:
- inputField.becomeFirstResponder()
- inputField.inputView = UIView()
- inputField.reloadInputViews()
-
- emojiButton.setImage(.icImChatKeyboard, for: .normal)
-
- hideEmojiConstraint?.update(priority: .low)
- emojiPanel.isHidden = false
- }
- }
-
- private func setupViews() {
- backgroundColor = .fill
-
- let inputMenu = buildInputMenu()
- addSubview(inputMenu)
- inputMenu.snp.makeConstraints { make in
- make.top.equalToSuperview().inset(10)
- make.leading.equalToSuperview().offset(12)
- hideEmojiConstraint = make.bottom.equalToSuperview().offset(-safeBottomInset).priority(.high).constraint
- }
-
- sendButton.setImage(.icImChatVoice, for: .normal)
- sendButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- sendButton.addAction(UIAction(handler: { [weak self] _ in
- guard let self else { return }
- let text = inputField.textStorage
- if text.length == 0 {
- delegate?.onVoiceInputClick()
- adjustViewsForInputTypeChanged(type: .none)
- } else {
- viewModel?.sendTextMessage(text: text.toEmojiContent)
- inputField.text = nil
- textViewDidChange(inputField)
- }
- }), for: .touchUpInside)
- addSubview(sendButton)
- sendButton.snp.makeConstraints { make in
- make.centerY.equalTo(inputMenu)
- make.trailing.equalToSuperview().offset(-12)
- make.leading.equalTo(inputMenu.snp.trailing).offset(10)
- }
-
- let emoji = buildEmojiView()
- addSubview(emoji)
- emoji.snp.makeConstraints { make in
- make.horizontalEdges.equalToSuperview()
- make.top.equalTo(inputMenu.snp.bottom).offset(10)
- make.bottom.equalToSuperview().priority(.medium)
- emojiHeight = make.height.equalTo(336).constraint
- }
- }
-
- private func buildInputMenu() -> UIView {
- let container = UIView()
- container.backgroundColor = .fill_2
- container.layer.cornerRadius = 19
- container.clipsToBounds = true
-
- emojiButton.setImage(.icImChatEmoji, for: .normal)
- emojiButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- emojiButton.addAction(UIAction(handler: { [weak self] _ in
- guard let self else { return }
- if inputField.inputView != nil {
- adjustViewsForInputTypeChanged(type: .keyboard)
- } else {
- adjustViewsForInputTypeChanged(type: .emoji)
- }
- inputField.reloadInputViews()
- }), for: .touchUpInside)
- container.addSubview(emojiButton)
- emojiButton.snp.makeConstraints { make in
- make.bottom.equalToSuperview().offset(-8)
- make.leading.equalToSuperview().offset(10)
- }
-
- inputField.font = .body_m
- inputField.textColor = .text_5
- inputField.backgroundColor = .clear
- inputField.delegate = self
- container.addSubview(inputField)
- inputField.snp.makeConstraints { make in
- make.verticalEdges.equalToSuperview().inset(3.5)
- make.leading.equalTo(emojiButton.snp.trailing).offset(4)
- hideCameraConstraint = make.trailing.equalToSuperview().offset(-10).priority(.low).constraint
- }
-
- placeholderLabel.text = .init(key: "A00084")
- placeholderLabel.font = .body_m
- placeholderLabel.textColor = .text_2
- container.insertSubview(placeholderLabel, belowSubview: inputField)
- placeholderLabel.snp.makeConstraints { make in
- make.centerY.equalTo(inputField)
- make.leading.equalTo(inputField).offset(4)
- }
-
- cameraButton.setImage(.icImChatCamera, for: .normal)
- cameraButton.addAction(UIAction(handler: { [weak self] _ in
- guard let self else { return }
- adjustViewsForInputTypeChanged(type: .none)
- showImageSelectPanel()
- }), for: .touchUpInside)
- container.addSubview(cameraButton)
- cameraButton.snp.makeConstraints { make in
- make.bottom.equalToSuperview().offset(-8)
- make.leading.equalTo(inputField.snp.trailing).offset(4)
- make.trailing.equalToSuperview().offset(-10).priority(.medium)
- }
- return container
- }
-
- private func buildEmojiView() -> UIView {
- emojiPanel.isHidden = true
- emojiPanel.delegate = self
-
- return emojiPanel
- }
- }
- #if DEBUG
- import SwiftUI
- struct LNIMChatTextInputViewPreview: UIViewRepresentable {
- func makeUIView(context: Context) -> some UIView {
- let container = UIView()
- container.backgroundColor = .lightGray
-
- let view = LNIMChatTextInputView()
- container.addSubview(view)
- view.snp.makeConstraints { make in
- make.leading.trailing.bottom.equalToSuperview()
- }
-
- return container
- }
-
- func updateUIView(_ uiView: UIViewType, context: Context) { }
- }
- #Preview(body: {
- LNIMChatTextInputViewPreview()
- })
- #endif
|