jsdw_ios/QuickLocation/Section/Common/TextInput/TextInputViewController.swift

312 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}()
}