// // TextArea.swift // VDS // // Created by Matt Bruce on 1/10/23. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens 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 minWidthConstraint: NSLayoutConstraint? internal var textViewHeightConstraint: NSLayoutConstraint? internal var allowCharCount: Int = 0 internal var inputFieldStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.spacing = VDSLayout.Spacing.space3X.value } }() internal var bottomView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() internal var bottomStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.alignment = .top $0.spacing = VDSLayout.Spacing.space2X.value } }() open var characterCounterLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodySmall $0.textAlignment = .right $0.numberOfLines = 1 } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- override var containerSize: CGSize { CGSize(width: 182, height: 88) } /// UITextView shown in the TextArea. open var textView = UITextView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.font = TextStyle.bodyLarge.font $0.sizeToFit() $0.isScrollEnabled = false } /// Color configuration for the textView. open var textViewTextColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable() { didSet { setNeedsUpdate() } } /// Color configuration for error icon. internal var iconColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) } /// 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() accessibilityLabel = "TextArea" isAccessibilityElement = true containerStackView.pinToSuperView(.uniform(VDSFormControls.spaceInset)) minWidthConstraint = containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: containerSize.width) minWidthConstraint?.isActive = true controlContainerView.addSubview(textView) textView .pinTop() .pinLeading() .pinTrailingLessThanOrEqualTo(nil, 0, .defaultHigh) .pinBottom(0, .defaultHigh) textView.isScrollEnabled = true textView.autocorrectionType = .no textViewHeightConstraint = textView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height) textViewHeightConstraint?.isActive = true backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: .success) borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success) borderColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .focused) textView.delegate = self characterCounterLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() bottomContainerStackView.spacing = VDSLayout.Spacing.space2X.value } /// Resets to default settings. open override func reset() { super.reset() textView.text = "" characterCounterLabel.reset() characterCounterLabel.textStyle = .bodySmall } /// Container for the area in which the user interacts. open override func getContainer() -> UIView { inputFieldStackView.addArrangedSubview(containerView) return inputFieldStackView } /// 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.textColor = textViewTextColorConfiguration.getColor(self) //set the width constraints if let width { widthConstraint?.constant = width widthConstraint?.isActive = true minWidthConstraint?.isActive = false } else { minWidthConstraint?.constant = containerSize.width widthConstraint?.isActive = false minWidthConstraint?.isActive = true } if let maxLength, maxLength > 0 { // allow - 20% of character limit let overflowLimit = Double(maxLength) * 0.20 allowCharCount = Int(overflowLimit) + maxLength characterCounterLabel.text = getCharacterCounterText() } else { characterCounterLabel.text = "" } icon.size = .medium icon.color = iconColorConfiguration.getColor(self) containerView.layer.borderColor = readOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.getColor(self).cgColor textView.isEditable = readOnly ? false : true textView.backgroundColor = backgroundColorConfiguration.getColor(self) textView.tintColor = iconColorConfiguration.getColor(self) characterCounterLabel.surface = surface highlightCharacterOverflow() } /// Container for the area showing helper text, error text, character count, maximum length value. open override func getBottomContainer() -> UIView { bottomView.addSubview(bottomStackView) bottomStackView.pinToSuperView() bottomStackView.addArrangedSubview(bottomContainerView) bottomStackView.addArrangedSubview(characterCounterLabel) return bottomView } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() if showError { setAccessibilityLabel(for: [titleLabel, textView, errorLabel, helperLabel]) } else { setAccessibilityLabel(for: [titleLabel, textView, helperLabel]) } } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func getCharacterCounterText() -> String { let count = textView.text.count let countStr = (count > maxLength ?? 0) ? ("-" + "\(count-(maxLength ?? 0))") : "\(count)" if ((maxLength ?? 0) > 0) { if (count > (maxLength ?? 0)) { showError = true errorText = "You have exceeded the character limit." return countStr } else { showError = false errorText = "" return ("\(countStr)" + "/" + "\(maxLength ?? 0)") } } else { return "" } } open func highlightCharacterOverflow() { let count = textView.text.count guard let maxLength, count > maxLength, let text = textView.attributedText?.mutableCopy() as? NSMutableAttributedString else { return } text.addAttribute(NSAttributedString.Key.backgroundColor, value: highlightBackgroundColor.getColor(self), range: NSRange(location:maxLength, length: (count - maxLength))) text.addAttribute(NSAttributedString.Key.foregroundColor, value: highlightTextColor.getColor(self), range: NSRange(location:maxLength, length: (count - maxLength))) textView.attributedText = text.copy() as? NSAttributedString } } extension TextArea: UITextViewDelegate { //-------------------------------------------------- // MARK: - UITextViewDelegate //-------------------------------------------------- public 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 { let height = textView.contentSize.height if height > 88 && height < 176 { textViewHeightConstraint.constant = 176 } else if height > 176 { textViewHeightConstraint.constant = 352 } else { textViewHeightConstraint.constant = 88 } textViewHeightConstraint.isActive = true } //The exceeding characters will be highlighted to help users correct their entry. if ((maxLength ?? 0) > 0) { if textView.text.count <= allowCharCount { //setting the value and firing control event value = textView.text sendActions(for: .valueChanged) if (textView.text.count > (maxLength ?? 0)) { highlightCharacterOverflow() } } else { textView.text.removeLast() highlightCharacterOverflow() } } else { //setting the value and firing control event value = textView.text sendActions(for: .valueChanged) } } }