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