Visual_Novel_iOS/crush/Crush/Src/Components/UI/TextViews/TitleTextView.swift

403 lines
14 KiB
Swift
Raw Normal View History

2025-10-09 10:29:35 +00:00
//
// 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()
}
}