// // TextArea.swift // VDS // // Created by Matt Bruce on 1/10/23. // import Foundation import UIKit import VDSTokens import Combine /// A text area is an input wherein a customer enters long-form information. /// Use a text area when you want customers to enter text that’s longer than a single line. @objc(VDSTextArea) open class TextArea: EntryFieldBase { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var textViewHeightConstraint: NSLayoutConstraint? internal var inputFieldStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.spacing = VDSLayout.space3X } }() open var characterCounterLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodySmall $0.textAlignment = .right $0.numberOfLines = 1 } open var minHeight: Height = .twoX { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Override UIControl state to add the .error state if showSuccess is true and if showError is true. open override var state: UIControl.State { get { var state = super.state if textView.isFirstResponder { state.insert(.focused) } return state } } override var containerSize: CGSize { CGSize(width: 182, height: Height.twoX.value) } /// Enum used to describe the the height of TextArea. public enum Height: String, CaseIterable { case twoX = "2X" case fourX = "4X" case eightX = "8X" var containerVerticalPadding: CGFloat { VDSLayout.space3X * 2 } var value: CGFloat { switch self { case .twoX: 88 - containerVerticalPadding case .fourX: 176 - containerVerticalPadding case .eightX: 352 - containerVerticalPadding } } } /// The text of this TextArea. private var _text: String? open var text: String? { get { textView.text } set { textView.text = newValue setNeedsUpdate() } } /// The value of this textField. open override var value: String? { return textView.text } /// UITextView shown in the TextArea. open var textView = TextView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.sizeToFit() $0.isScrollEnabled = false $0.textContainerInset = .zero $0.textContainer.lineFragmentPadding = 0 } open var maxLength: Int? { willSet { countRule.maxLength = newValue } didSet { validate() } } /// Color configuration for character counter's highlight background color internal var highlightBackgroundColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight, forState: .normal) } /// Color configuration for character counter's highlight text color internal var highlightTextColor = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .normal) } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() fieldStackView.pinToSuperView(.uniform(VDSFormControls.spaceInset)) textView.isScrollEnabled = true textView.autocorrectionType = .no //events textView .publisher(for: .editingChanged) .sink { [weak self] control in self?.textViewDidChange(control) }.store(in: &subscribers) textView .publisher(for: .editingDidBegin) .sink { [weak self] _ in self?.setNeedsUpdate() }.store(in: &subscribers) textView .publisher(for: .editingDidEnd) .sink { [weak self] _ in self?.validate() }.store(in: &subscribers) textViewHeightConstraint = textView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height) textViewHeightConstraint?.isActive = true characterCounterLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() bottomContainerStackView.spacing = VDSLayout.space2X } /// Resets to default settings. open override func reset() { super.reset() textView.text = "" characterCounterLabel.reset() characterCounterLabel.textStyle = .bodySmall setNeedsUpdate() } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() textView.isEditable = isEnabled textView.isEnabled = isEnabled textView.surface = surface textViewHeightConstraint?.constant = minHeight.value characterCounterLabel.text = getCharacterCounterText() textView.isEditable = !isEnabled || isReadOnly ? false : true textView.tintColor = iconColorConfiguration.getColor(self) characterCounterLabel.surface = surface highlightCharacterOverflow() } open override func updateAccessibility() { super.updateAccessibility() textView.accessibilityLabel = accessibilityLabelText textView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." } override func updateRules() { super.updateRules() rules.append(.init(countRule)) } open override func getFieldContainer() -> UIView { textView } /// Container for the area showing helper text, error text, character count, maximum length value. open override func getBottomContainer() -> UIView { let stackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.alignment = .top $0.spacing = VDSLayout.space2X } stackView.addArrangedSubview(super.getBottomContainer()) stackView.addArrangedSubview(characterCounterLabel) return stackView } open override var accessibilityElements: [Any]? { get { var elements = [Any]() elements.append(contentsOf: [titleLabel, textView]) if showError { elements.append(statusIcon) if let errorText, !errorText.isEmpty { elements.append(errorLabel) } } if let helperText, !helperText.isEmpty { elements.append(helperLabel) } return elements } set { super.accessibilityElements = newValue } } open override var canBecomeFirstResponder: Bool { return textView.canBecomeFirstResponder } open override func becomeFirstResponder() -> Bool { return textView.becomeFirstResponder() } open override var canResignFirstResponder: Bool { return textView.canResignFirstResponder } open override func resignFirstResponder() -> Bool { return textView.resignFirstResponder() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func getCharacterCounterText() -> String? { let count = textView.text.count let countStr = (count > maxLength ?? 0) ? ("-" + "\(count-(maxLength ?? 0))") : "\(count)" if let maxLength, maxLength > 0 { if count > maxLength { return countStr } else { return ("\(countStr)" + "/" + "\(maxLength)") } } else { return nil } } func textViewDidChange(_ textView: UITextView) { //dynamic textView Height sizing based on Figma //if you want it to work "as-is" delete this code //since it will autogrow with the current settings if let textViewHeightConstraint, textView.isEditable { var height = textView.contentSize.height height = max(height, minHeight.value) if height > Height.twoX.value && height <= Height.fourX.value { textViewHeightConstraint.constant = Height.fourX.value } else if height > Height.fourX.value { textViewHeightConstraint.constant = Height.eightX.value } else { textViewHeightConstraint.constant = Height.twoX.value } } //The exceeding characters will be highlighted to help users correct their entry. if let maxLength, maxLength > 0 { // allow - 20% of character limit let overflowLimit = Double(maxLength) * 0.20 let allowCharCount = Int(overflowLimit) + maxLength if textView.text.count <= allowCharCount { highlightCharacterOverflow() //setting the value and firing control event text = textView.text sendActions(for: .valueChanged) } else { textView.text.removeLast() highlightCharacterOverflow() } } else { //setting the value and firing control event text = textView.text sendActions(for: .valueChanged) } validate() } private func highlightCharacterOverflow() { let count = textView.text.count guard let maxLength, maxLength > 0, count > maxLength else { textView.textAttributes = nil return } var textAttributes = [any LabelAttributeModel]() let location = maxLength let length = count - maxLength textAttributes.append(ColorLabelAttribute(location: location, length: length, color: highlightBackgroundColor.getColor(self), isForegroundColor: false)) textAttributes.append(ColorLabelAttribute(location: location, length: length, color: highlightTextColor.getColor(self), isForegroundColor: true)) textView.textAttributes = textAttributes } //-------------------------------------------------- // MARK: - Validation //-------------------------------------------------- var countRule = CharacterCountRule() }