vds_ios/VDS/Components/TextFields/TextArea/TextArea.swift
2024-06-07 13:12:12 -05:00

344 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// TextArea.swift
// VDS
//
// Created by Matt Bruce on 1/10/23.
//
import Foundation
import UIKit
import VDSCoreTokens
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 thats 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()
}