// // TextInputViewController.swift // QuickLocation // // Created by 八条 on 2026/6/9. // import UIKit import RxSwift import RxCocoa /// 通用文本输入页面 /// 用法: /// let vc = TextInputViewController(title: "编辑昵称", maxLength: 20) { text in /// print("用户输入: \(text)") /// } /// present(vc, animated: true) final class TextInputViewController: UIViewController { private let titleText: String private let maxLength: Int private let confirmAction: ((String) -> Void)? private let disposeBag = DisposeBag() private let textRelay = BehaviorRelay(value: "") // MARK: - Init /// - Parameters: /// - title: 页面标题 /// - maxLength: 文字输入上限(0 表示不限制) /// - initialText: 初始文本,默认空 /// - confirmAction: 确定回调 init(title: String, maxLength: Int = 0, initialText: String = "", confirmAction: ((String) -> Void)? = nil) { self.titleText = title self.maxLength = maxLength self.confirmAction = confirmAction self.textRelay.accept(initialText) super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .coverVertical } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hexStr: "#F5FBFB") setupUI() setupBinding() setupKeyboard() textView.becomeFirstResponder() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if textViewHeightConstraint == nil { updateTextViewHeight() } } // MARK: - UI private func setupUI() { view.addSubview(topBar) topBar.addSubview(closeBtn) topBar.addSubview(titleLabel) view.addSubview(contentView) contentView.addSubview(textView) textView.addSubview(countLabel) contentView.addSubview(confirmBtn) // 顶栏 topBar.layoutChain .top() .edgesHorzontal() .height(kNaviHeight) closeBtn.layoutChain .bottom(12) .left(7) .width(24).height(24) titleLabel.layoutChain .centerY(closeBtn) .centerX() // 内容容器(textView + 按钮),键盘升起时整体上移 contentView.layoutChain .topToBottomOfView(topBar, offset: 16) .edgesHorzontal(15) .bottom() // 输入框 textView.layoutChain .top() .edgesHorzontal() .height(textViewMinHeight) // 字数统计(textView 右下角) countLabel.layoutChain .right(-10) .bottom(-8) // 确定按钮 confirmBtn.layoutChain .topToBottomOfView(textView, offset: 50) .edgesHorzontal() .height(44) .bottom() } // MARK: - Binding private func setupBinding() { // 输入流 textView.rx.text .compactMap { $0 } .subscribe(onNext: { [weak self] text in guard let self = self else { return } let realText = self.maxLength > 0 && text.count > self.maxLength ? String(text.prefix(self.maxLength)) : text if realText != text { self.textView.text = realText } self.textRelay.accept(realText) }) .disposed(by: disposeBag) // 字数统计 textRelay .map { [weak self] text in guard let self = self, self.maxLength > 0 else { return "" } return "\(text.count)/\(self.maxLength)" } .bind(to: countLabel.rx.text) .disposed(by: disposeBag) // 确定按钮状态 + 背景色 let confirmEnabled = textRelay .map { [weak self] text in guard let self = self else { return false } if self.maxLength > 0 { return !text.isEmpty && text.count <= self.maxLength } return !text.isEmpty } .share(replay: 1) confirmEnabled .bind(to: confirmBtn.rx.isEnabled) .disposed(by: disposeBag) confirmEnabled .subscribe(onNext: { [weak self] enabled in self?.confirmBtn.backgroundColor = enabled ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#CCCCCC") }) .disposed(by: disposeBag) // 动态高度 textView.rx.text .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self] _ in self?.updateTextViewHeight() }) .disposed(by: disposeBag) // 确定 confirmBtn.rx.tap .withLatestFrom(textRelay) .subscribe(onNext: { [weak self] text in guard let self = self else { return } self.confirmAction?(text) self.dismiss(animated: true) }) .disposed(by: disposeBag) // 关闭 closeBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.dismiss(animated: true) }) .disposed(by: disposeBag) } // MARK: - Keyboard private var originContentY: CGFloat = 0 private func setupKeyboard() { NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) .subscribe(onNext: { [weak self] noti in guard let self = self, let userInfo = noti.userInfo, let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardHeight = frame.height let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 // 计算 contentView 需要上移的距离 = 键盘遮住底部的高度 - 安全区域 let offset = keyboardHeight - kSafeBottomMargin UIView.animate(withDuration: duration) { self.contentView.transform = CGAffineTransform(translationX: 0, y: -offset) self.view.layoutIfNeeded() } }) .disposed(by: disposeBag) NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification) .subscribe(onNext: { [weak self] noti in guard let self = self else { return } let duration = (noti.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 UIView.animate(withDuration: duration) { self.contentView.transform = .identity self.view.layoutIfNeeded() } }) .disposed(by: disposeBag) } // MARK: - TextView Height private let textViewMinHeight: CGFloat = 150 private let textViewMaxHeight: CGFloat = 300 private var textViewHeightConstraint: NSLayoutConstraint? private func updateTextViewHeight() { let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: CGFloat.greatestFiniteMagnitude)) let height = min(max(size.height, textViewMinHeight), textViewMaxHeight) if textViewHeightConstraint == nil { textViewHeightConstraint = textView.layoutChain.height(height) } else { textViewHeightConstraint?.constant = height } UIView.setAnimationsEnabled(false) textView.layoutIfNeeded() UIView.setAnimationsEnabled(true) } // MARK: - Views private lazy var contentView: UIView = { let v = UIView() v.backgroundColor = .clear return v }() private lazy var topBar: UIView = { let v = UIView() v.backgroundColor = .clear return v }() private lazy var closeBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Common/back"), for: .normal) btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 30) return btn }() private lazy var titleLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 17, weight: .medium) label.textColor = UIColor(hexStr: "#333333") label.text = titleText label.textAlignment = .center return label }() lazy var textView: UITextView = { let tv = UITextView() tv.font = .systemFont(ofSize: 15) tv.textColor = UIColor(hexStr: "#333333") tv.backgroundColor = .white tv.cornerRadius = 4 tv.layer.borderWidth = 1 tv.layer.borderColor = ThemeManager.shared.color.lineColor.cgColor tv.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) tv.showsVerticalScrollIndicator = true tv.bounces = false tv.tintColor = UIColor(hexStr: "#16B3FF") return tv }() private lazy var countLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#BBBBBB") label.text = maxLength > 0 ? "0/\(maxLength)" : "" return label }() private lazy var confirmBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("确定", for: .normal) btn.setTitleColor(.white, for: .normal) btn.setTitleColor(.white, for: .disabled) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) btn.cornerRadius = 22 btn.isEnabled = false return btn }() }