334 lines
12 KiB
Swift
334 lines
12 KiB
Swift
//
|
||
// 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 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 textView
|
||
private var _text: String?
|
||
open override var text: String? {
|
||
get { textView.text }
|
||
set {
|
||
if let newValue, newValue != _text {
|
||
_text = newValue
|
||
textView.text = newValue
|
||
value = newValue
|
||
}
|
||
setNeedsUpdate()
|
||
}
|
||
}
|
||
|
||
/// UITextView shown in the TextArea.
|
||
open var textView = TextView().with {
|
||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||
$0.sizeToFit()
|
||
$0.isScrollEnabled = false
|
||
}
|
||
|
||
open override var maxLength: Int? { willSet { countRule.maxLength = newValue }}
|
||
|
||
/// 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"
|
||
validator = FormFieldValidator<TextArea>(field: self, rules: [.init(countRule)])
|
||
|
||
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
|
||
}
|
||
|
||
characterCounterLabel.text = getCharacterCounterText()
|
||
|
||
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 let maxLength, maxLength > 0 {
|
||
if count > maxLength {
|
||
return countStr
|
||
} else {
|
||
return ("\(countStr)" + "/" + "\(maxLength)")
|
||
}
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
//--------------------------------------------------
|
||
// MARK: - Validation
|
||
//--------------------------------------------------
|
||
var countRule = CharacterCountRule()
|
||
|
||
class CharacterCountRule: Rule {
|
||
var maxLength: Int?
|
||
var errorMessage: String = "You have exceeded the character limit."
|
||
|
||
func isValid(value: String?) -> Bool {
|
||
guard let text = value, let maxLength, maxLength > 0 else { return true }
|
||
return text.count <= maxLength
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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)
|
||
}
|
||
}
|
||
}
|