403 lines
14 KiB
Swift
403 lines
14 KiB
Swift
//
|
||
// TitleTextView.swift
|
||
// Crush
|
||
//
|
||
// Created by Leon on 2025/7/20.
|
||
//
|
||
|
||
import UIKit
|
||
import SnapKit
|
||
|
||
private let defaultHeightForTextView: CGFloat = 140.0
|
||
|
||
class TitleTextView: UIView, UITextViewDelegate {
|
||
// MARK: - Properties
|
||
var textDidChanged: ((CLTextView) -> Void)?
|
||
var textDidEndEditing: ((CLTextView) -> Void)?
|
||
var textDidBeginEditing: ((CLTextView) -> Void)?
|
||
var textShouldBeginEditing: ((CLTextView) -> Void)?
|
||
|
||
var maxLimit: Int = 0 {
|
||
didSet {
|
||
if maxLimit > 0 {
|
||
countLabel.isHidden = false
|
||
countLabel.text = "0/\(maxLimit)"
|
||
textView.limit.maxCharacterNumber = maxLimit
|
||
textView.snp.updateConstraints { make in
|
||
make.bottom.equalTo(textContainer).offset(-48)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var minLimit: Int = 0
|
||
|
||
var titleLabel: UILabel
|
||
var textView: CLTextView
|
||
var supportLabel: UILabel
|
||
|
||
var placeholder: String? {
|
||
didSet {
|
||
textView.placeholder = placeholder
|
||
}
|
||
}
|
||
|
||
/// 同时更新countLabel
|
||
var defaultText: String?{
|
||
didSet{
|
||
textView.text = defaultText
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {[weak self] in
|
||
guard let self = self else{return}
|
||
self.subTextDidChange(self.textView)
|
||
}
|
||
|
||
}
|
||
}
|
||
var errorMsg: String?
|
||
|
||
/// 配置固定高度
|
||
var constHeightOfTextView: CGFloat = 0
|
||
|
||
private var stackV: UIStackView
|
||
private var textContainer: UIView
|
||
private var countLabel: UILabel
|
||
private var optionLabel: EPOptionFlagLabel?
|
||
private var queryButton: EPIconTertiaryButton?
|
||
private var lastText: String?
|
||
private var location: Int = 0
|
||
private var length: Int = 0
|
||
private var focusBorderColor: UIColor
|
||
private var errorBorderColor: UIColor
|
||
private var nowBorderColor: UIColor
|
||
private var errorBorderShowMode: Bool = false
|
||
private var realDefaultHeightForTextView: CGFloat
|
||
private var tapQueryButton: (() -> Void)?
|
||
|
||
// MARK: - Initialization
|
||
override init(frame: CGRect) {
|
||
titleLabel = UILabel()
|
||
textView = CLTextView()
|
||
supportLabel = UILabel()
|
||
stackV = UIStackView()
|
||
textContainer = UIView()
|
||
countLabel = UILabel()
|
||
focusBorderColor = .c.cpvn
|
||
errorBorderColor = .c.civn
|
||
nowBorderColor = .clear
|
||
realDefaultHeightForTextView = defaultHeightForTextView
|
||
|
||
super.init(frame: frame)
|
||
initialViews()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
// MARK: - Setup
|
||
private func initialViews() {
|
||
// Configure stack view
|
||
stackV.axis = .vertical
|
||
stackV.alignment = .leading
|
||
stackV.spacing = 16
|
||
addSubview(stackV)
|
||
stackV.snp.makeConstraints { make in
|
||
make.top.bottom.leading.trailing.equalTo(self)
|
||
}
|
||
|
||
// Configure title label
|
||
titleLabel.font = CLSystemToken.font(token: .tlm)//EPSystemToken.typography(.txtLabelM).font
|
||
titleLabel.textColor = .c.ctpn
|
||
|
||
// Configure text container
|
||
textContainer.backgroundColor = .c.csen
|
||
textContainer.layer.cornerRadius = CLSystemToken.radius(token: .rs)
|
||
textContainer.layer.borderWidth = CLSystemToken.border(token: .bs)//EPSystemToken.border(.borderS)
|
||
textContainer.layer.borderColor = UIColor.clear.cgColor
|
||
|
||
// Configure text view
|
||
textView.delegate = self
|
||
textView.backgroundColor = .clear
|
||
textView.font = CLSystemToken.font(token: .tbl)//EPSystemToken.typography(.txtBodyL).font
|
||
textView.placeholderLabel.font = CLSystemToken.font(token: .tbl)
|
||
//textView.iqPlaceholderLabel?.font = EPSystemToken.typography(.txtBodyL).font
|
||
textView.placeholderTextColor = .c.cttn
|
||
textView.textColor = .c.ctpn
|
||
|
||
textContainer.addSubview(textView)
|
||
textView.snp.makeConstraints { make in
|
||
make.top.equalTo(textContainer).offset(8)
|
||
make.leading.equalTo(textContainer).offset(16)
|
||
make.trailing.equalTo(textContainer).offset(-16)
|
||
make.bottom.equalTo(textContainer).offset(-16)
|
||
make.height.greaterThanOrEqualTo(realDefaultHeightForTextView)
|
||
make.height.equalTo(defaultHeightForTextView)
|
||
}
|
||
|
||
// Update text view height based on placeholder size
|
||
|
||
|
||
textView.updateByPlaceholderSize { [weak self] rect in
|
||
guard let self = self, rect.size.height > defaultHeightForTextView else { return }
|
||
self.realDefaultHeightForTextView = rect.size.height
|
||
self.textView.snp.updateConstraints { make in
|
||
make.height.greaterThanOrEqualTo(rect.size.height)
|
||
}
|
||
}
|
||
|
||
// Configure count label
|
||
countLabel.font = CLSystemToken.font(token: .tbs)//EPSystemToken.typography(.txtBodyS).font
|
||
countLabel.textColor = .c.ctsn//EPSystemToken.color(.txtSecondaryNormal)
|
||
|
||
textContainer.addSubview(countLabel)
|
||
countLabel.snp.makeConstraints { make in
|
||
make.trailing.equalTo(textContainer).offset(-16)
|
||
make.bottom.equalTo(textContainer).offset(-16)
|
||
}
|
||
|
||
// Configure support label
|
||
supportLabel.numberOfLines = 0
|
||
supportLabel.font = .t.tbs//EPSystemToken.typography(.txtBodyS).font
|
||
supportLabel.textColor = .c.ctsn// EPSystemToken.color(.txtSecondaryNormal)
|
||
supportLabel.isHidden = true
|
||
|
||
// Add subviews to stack
|
||
stackV.addArrangedSubview(titleLabel)
|
||
stackV.addArrangedSubview(textContainer)
|
||
stackV.addArrangedSubview(supportLabel)
|
||
|
||
textContainer.snp.makeConstraints { make in
|
||
make.left.right.equalTo(stackV)
|
||
}
|
||
|
||
setupNotStrictCheck()
|
||
}
|
||
|
||
private func setupNotStrictCheck() {
|
||
textView.autocapitalizationType = .none
|
||
textView.autocorrectionType = .no
|
||
}
|
||
|
||
// MARK: - Public Methods
|
||
func showErrorMsg(_ string: String) {
|
||
supportLabel.isHidden = false
|
||
supportLabel.textColor = .c.civn//EPSystemToken.color(.importantVariantNormal)
|
||
supportLabel.text = string
|
||
textContainer.layer.borderColor = errorBorderColor.cgColor
|
||
errorBorderShowMode = true
|
||
setNeedsLayout()
|
||
}
|
||
|
||
func hideErrorMsg() {
|
||
supportLabel.isHidden = true
|
||
supportLabel.textColor = .c.ctsn
|
||
textContainer.layer.borderColor = textView.isFirstResponder ? focusBorderColor.cgColor : UIColor.clear.cgColor
|
||
errorBorderShowMode = false
|
||
setNeedsLayout()
|
||
}
|
||
|
||
// func configDefaultText(_ txt: String?) {
|
||
// guard let txt = txt else { return }
|
||
// textView.text = txt
|
||
// if !txt.removingWhitespaceAndNewlines.isEmpty {
|
||
// textViewDidChange(textView)
|
||
// }
|
||
// }
|
||
|
||
func setupTextSync(_ text: String?) {
|
||
textView.text = text
|
||
subTextDidChange(textView)
|
||
}
|
||
|
||
func titleAppendOptionalLabel() {
|
||
if optionLabel == nil {
|
||
optionLabel = {
|
||
let v = EPOptionFlagLabel()
|
||
addSubview(v )
|
||
v.snp.makeConstraints { make in
|
||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||
make.centerY.equalTo(titleLabel)
|
||
}
|
||
return v
|
||
}()
|
||
}
|
||
optionLabel?.isHidden = false
|
||
}
|
||
|
||
func titleAppendQuestionIcon(_ block: @escaping () -> Void) {
|
||
if queryButton == nil {
|
||
tapQueryButton = block
|
||
queryButton = EPIconTertiaryButton(radius: .round, iconSize: .xxs, iconCode: .question)
|
||
queryButton?.touchAreaInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||
queryButton?.addTarget(self, action: #selector(tapQueryBtn), for: .touchUpInside)
|
||
addSubview(queryButton!)
|
||
queryButton?.snp.makeConstraints { make in
|
||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||
make.centerY.equalTo(titleLabel)
|
||
make.trailing.lessThanOrEqualTo(self).offset(-8)
|
||
}
|
||
}
|
||
queryButton?.isHidden = false
|
||
}
|
||
|
||
func setupMaxShowLineAndTruncatingTail(_ line: Int) {
|
||
textView.isScrollEnabled = false
|
||
textView.textContainer.maximumNumberOfLines = line
|
||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
||
}
|
||
|
||
func setupTest() {
|
||
titleLabel.text = "Label.M"
|
||
countLabel.text = "0/100"
|
||
maxLimit = 100
|
||
}
|
||
|
||
// MARK: - Functions
|
||
|
||
private func refreshViewsAboutMinLimitOrmaxLimit(){
|
||
if maxLimit > 0 {
|
||
countLabel.text = "\(textView.text.count)/\(maxLimit)"
|
||
}
|
||
|
||
if minLimit > 0{
|
||
if textView.text.count < minLimit && textView.text.count > 0{
|
||
if let msg = errorMsg{
|
||
showErrorMsg(msg)
|
||
}else{
|
||
dlog("⚠️error msg未设置[TitleTextView]")
|
||
}
|
||
}else{
|
||
hideErrorMsg()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func refreshTextViewsHeight(){
|
||
let calHeight = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)).height
|
||
//print("calHeight: \(calHeight), realDefaultHeightForTextView: \(realDefaultHeightForTextView)")
|
||
if constHeightOfTextView > 0 && !(textView.text.isEmpty) {
|
||
textView.snp.updateConstraints { make in
|
||
make.height.equalTo(constHeightOfTextView)
|
||
}
|
||
} else if calHeight > realDefaultHeightForTextView {
|
||
if textView.bounds.height != calHeight {
|
||
textView.snp.updateConstraints { make in
|
||
make.height.equalTo(calHeight)
|
||
}
|
||
}
|
||
} else {
|
||
if textView.bounds.height != realDefaultHeightForTextView {
|
||
textView.snp.updateConstraints { make in
|
||
make.height.equalTo(realDefaultHeightForTextView)
|
||
}
|
||
}
|
||
}
|
||
layoutIfNeeded()
|
||
}
|
||
|
||
// MARK: - Actions
|
||
@objc private func tapQueryBtn() {
|
||
tapQueryButton?()
|
||
}
|
||
|
||
// MARK: - Overrides
|
||
override func becomeFirstResponder() -> Bool {
|
||
textView.becomeFirstResponder()
|
||
}
|
||
|
||
// MARK: - UITextViewDelegate
|
||
func subTextDidChange(_ textView: CLTextView) {
|
||
errorBorderShowMode = false
|
||
nowBorderColor = focusBorderColor
|
||
if textView.isFirstResponder {
|
||
textContainer.layer.borderColor = nowBorderColor.cgColor
|
||
}
|
||
|
||
// Handle text length limiting
|
||
if textView.markedTextRange == nil {
|
||
let nsTextContent = textView.text ?? ""
|
||
let existTextNum = nsTextContent.count
|
||
|
||
if existTextNum > maxLimit && maxLimit > 0 {
|
||
// Convert maxLimit - 1 to String.Index
|
||
if let stringIndex = nsTextContent.index(nsTextContent.startIndex, offsetBy: maxLimit - 1, limitedBy: nsTextContent.endIndex) {
|
||
let range = nsTextContent.rangeOfComposedCharacterSequence(at: stringIndex)
|
||
var str = String(nsTextContent.prefix(maxLimit))
|
||
|
||
// Convert range.upperBound to an Int offset for comparison
|
||
let upperBoundOffset = nsTextContent.distance(from: nsTextContent.startIndex, to: range.upperBound)
|
||
if upperBoundOffset > maxLimit {
|
||
str = String(nsTextContent.prefix(upTo: range.lowerBound))
|
||
}
|
||
|
||
textView.text = str
|
||
} else {
|
||
// Handle out-of-bounds case
|
||
textView.text = ""
|
||
}
|
||
}
|
||
|
||
lastText = textView.text
|
||
} else {
|
||
let markRange = textView.markedTextRange
|
||
let beginning = textView.beginningOfDocument
|
||
let selectionStart = markRange?.start
|
||
let selectionEnd = markRange?.end
|
||
location = selectionStart != nil ? textView.offset(from: beginning, to: selectionStart!) : 0
|
||
length = (selectionStart != nil && selectionEnd != nil) ? textView.offset(from: selectionStart!, to: selectionEnd!) : 0
|
||
}
|
||
|
||
refreshViewsAboutMinLimitOrmaxLimit()
|
||
|
||
refreshTextViewsHeight()
|
||
}
|
||
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
subTextDidChange(textView as! CLTextView)
|
||
textDidChanged?(self.textView)
|
||
}
|
||
|
||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||
if !errorBorderShowMode {
|
||
textContainer.layer.borderColor = focusBorderColor.cgColor
|
||
}
|
||
textDidBeginEditing?(self.textView)
|
||
}
|
||
|
||
func textViewDidEndEditing(_ textView: UITextView) {
|
||
if !errorBorderShowMode {
|
||
textContainer.layer.borderColor = UIColor.clear.cgColor
|
||
}
|
||
textDidEndEditing?(self.textView)
|
||
}
|
||
|
||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||
if text.isEmpty || maxLimit == 0 {
|
||
return true
|
||
}
|
||
|
||
if textView.markedTextRange == nil {
|
||
if (lastText?.count ?? 0) == maxLimit {
|
||
return false
|
||
}
|
||
location = range.location
|
||
length = text.count
|
||
}
|
||
return true
|
||
}
|
||
|
||
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
|
||
textShouldBeginEditing?(self.textView)
|
||
return textShouldBeginEditing == nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Extensions
|
||
extension String {
|
||
var removingWhitespaceAndNewlines: String {
|
||
components(separatedBy: .whitespacesAndNewlines).joined()
|
||
}
|
||
}
|