added subclass of UITextField to deal with text rendering and attributes. Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
462 lines
17 KiB
Swift
462 lines
17 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 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
|
||
}
|
||
}
|
||
|
||
/// 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"
|
||
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
|
||
minHeight = .twoX
|
||
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)) {
|
||
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 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 {
|
||
//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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<AnyCancellable>()
|
||
|
||
//--------------------------------------------------
|
||
// 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
|
||
}
|
||
}
|
||
|
||
|
||
}
|