// // 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 } private var _minHeight: Height = .twoX open var minHeight: Height? { get { return _minHeight } set { if let newValue { _minHeight = newValue } else { _minHeight = .twoX } textViewHeightConstraint?.constant = _minHeight.value setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- override var containerSize: CGSize { CGSize(width: 182, height: 88) } /// Enum used to describe the the height of TextArea. public enum Height: String, CaseIterable { case twoX = "2X" case fourX = "4X" case eightX = "8X" var value: CGFloat { switch self { case .twoX: 88 case .fourX: 176 case .eightX: 352 } } } /// The text of this textField. open override var text: String? { get { textView.text } set { textView.text = newValue value = newValue } } /// UITextView shown in the TextArea. open var textView = TextView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.sizeToFit() $0.isScrollEnabled = false } /// 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" 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 setNeedsUpdate() } /// 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.isEnabled = isEnabled textView.surface = surface //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)) { showInternalError = true internalErrorText = "You have exceeded the character limit." return countStr } else { showInternalError = false internalErrorText = nil return ("\(countStr)" + "/" + "\(maxLength ?? 0)") } } else { return "" } } open 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 } } 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 { 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 ((maxLength ?? 0) > 0) { 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) } } } /// Will move this into a new file, need to talk with Scott/Kyle open class TextView: UITextView, ViewProtocol { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero, textContainer: nil) initialSetup() } public override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- /// Set of Subscribers for any Publishers for this Control. open var subscribers = Set() //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var initialSetupPerformed = false //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- /// Key of whether or not updateView() is called in setNeedsUpdate() open var shouldUpdateView: Bool = true open var surface: Surface = .light { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the text. open var textAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// TextStyle used on the titleLabel. open var textStyle: TextStyle { .defaultStyle } /// Will determine if a scaled font should be used for the titleLabel font. open var useScaledFont: Bool = false { didSet { setNeedsUpdate() } } open var isEnabled: Bool = true { didSet { setNeedsUpdate() } } open var textColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable(){ didSet { setNeedsUpdate() }} open override var textColor: UIColor? { get { textColorConfiguration.getColor(self) } set { } } override public var text: String! { get { super.text } set { super.text = newValue updateLabel() } } override public var textAlignment: NSTextAlignment { didSet { if textAlignment != oldValue { // Text alignment can be part of our paragraph style, so we may need to // re-style when changed updateLabel() } } } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open func initialSetup() { if !initialSetupPerformed { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] setup() setNeedsUpdate() } } open func setup() { translatesAutoresizingMaskIntoConstraints = false } open func updateView() { updateLabel() } open func updateAccessibility() {} open func reset() { shouldUpdateView = false surface = .light text = nil accessibilityCustomActions = [] shouldUpdateView = true setNeedsUpdate() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateLabel() { //clear the arrays holding actions accessibilityCustomActions = [] if let text, !text.isEmpty { //create the primary string let mutableText = NSMutableAttributedString.mutableText(for: text, textStyle: textStyle, useScaledFont: useScaledFont, textColor: textColor!, alignment: textAlignment, lineBreakMode: .byWordWrapping) //apply any attributes if let attributes = textAttributes { mutableText.apply(attributes: attributes) } attributedText = mutableText } else { attributedText = nil } } }