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