312 lines
9.8 KiB
Swift
312 lines
9.8 KiB
Swift
//
|
||
// 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<String>(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
|
||
}()
|
||
}
|