349 lines
12 KiB
Swift
349 lines
12 KiB
Swift
//
|
||
// 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()
|
||
let label = "\(isReadOnly ? "read only" : "")"
|
||
if let errorText, showError {
|
||
textView.accessibilityLabel = "\(label) ,error, \(errorText)"
|
||
} else {
|
||
textView.accessibilityLabel = label
|
||
}
|
||
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()
|
||
}
|