vds_ios/VDS/Components/TextFields/EntryFieldBase.swift
Matt Bruce 9edd360303 Merge branch 'develop' of https://gitlab.verizon.com/BPHV_MIPS/vds_ios.git into mbruce/inputStepper
# Conflicts:
#	VDS/Components/TextFields/EntryFieldBase.swift

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-08-09 17:25:19 -05:00

609 lines
22 KiB
Swift

//
// EntryField.swift
// VDS
//
// Created by Matt Bruce on 10/3/22.
//
import Foundation
import UIKit
import VDSCoreTokens
import Combine
/// Base Class used to build out a Input controls.
open class EntryFieldBase<ValueType>: Control, Changeable, FormFieldInternalValidatable {
//--------------------------------------------------
// 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: - Enums
//--------------------------------------------------
/// Enum used to describe the position of the helper text.
public enum HelperTextPlacement: String, CaseIterable {
case bottom, right
}
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
internal var responder: UIResponder? { return nil }
internal let mainStackView = UIStackView().with {
$0.axis = .vertical
$0.alignment = .fill
$0.spacing = VDSLayout.space1X
$0.translatesAutoresizingMaskIntoConstraints = false
}
internal let contentStackView = UIStackView().with {
$0.axis = .vertical
$0.alignment = .fill
$0.distribution = .fill
$0.spacing = VDSLayout.space2X
$0.translatesAutoresizingMaskIntoConstraints = false
}
/// only used for helperTextPosition == .right
internal let row1StackView = UIStackView().with {
$0.axis = .horizontal
$0.spacing = VDSLayout.space3X
$0.alignment = .top
$0.distribution = .fillEqually
}
/// only used for helperTextPosition == .right
internal let row2StackView = UIStackView().with {
$0.axis = .horizontal
$0.spacing = VDSLayout.space3X
$0.alignment = .top
$0.distribution = .fillEqually
}
internal var fieldStackView: UIStackView = {
return UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .horizontal
$0.distribution = .fill
$0.alignment = .top
}
}()
/// This is a vertical stack used for the errorLabel and helperLabel.
internal var bottomContainerStackView: UIStackView = {
return UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .vertical
$0.distribution = .fill
$0.spacing = VDSLayout.space2X
}
}()
/// This is set by a local method.
internal var bottomContainerView: UIView!
internal var containerBackgroundColor: UIColor {
if showError || hasInternalError {
return backgroundColorConfiguration.getColor(self)
} else {
return transparentBackground ? .clear : backgroundColorConfiguration.getColor(self)
}
}
//--------------------------------------------------
// MARK: - Constraints
//--------------------------------------------------
internal var widthConstraint: NSLayoutConstraint?
internal var trailingEqualsConstraint: NSLayoutConstraint?
internal var trailingLessThanEqualsConstraint: NSLayoutConstraint?
//--------------------------------------------------
// MARK: - Configuration Properties
//--------------------------------------------------
// Sizes are from InVision design specs.
internal var maxWidth: CGFloat { frame.size.width }
internal var minWidth: CGFloat { containerSize.width }
internal var containerSize: CGSize { CGSize(width: minWidth, height: 44) }
internal let primaryColorConfiguration = ViewColorConfiguration().with {
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false)
}
internal let secondaryColorConfiguration = ViewColorConfiguration().with {
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true)
$0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: false)
}
internal var backgroundColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal)
$0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error)
$0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: [.error, .focused])
}
internal var borderColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: .normal)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .focused)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.focused, .error])
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error)
$0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .readonly)
}
internal let iconColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal)
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error)
}
internal var readOnlyBorderColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .normal)
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// This is the view that will be wrapped with the border for userInteraction.
/// The only subview of this view is the fieldStackView
open var containerView = View().with {
$0.isAccessibilityElement = true
}
open var onChangeSubscriber: AnyCancellable?
open var titleLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textStyle = .bodySmall
}
open var errorLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textStyle = .bodySmall
$0.accessibilityValue = "error"
}
open var helperLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textStyle = .bodySmall
}
open var statusIcon: Icon = Icon().with {
$0.size = .medium
$0.isAccessibilityElement = true
}
open var useRequiredRule: Bool = true { didSet { setNeedsUpdate() } }
open var labelText: String? { didSet { setNeedsUpdate() } }
open var helperText: String? { didSet { setNeedsUpdate() } }
/// Whether not to show the error.
open var showError: Bool = false { didSet { setNeedsUpdate() } }
/// FormFieldValidator
open var validator: (any FormFieldValidatorable)?
/// Override UIControl state to add the .error state if showError is true.
open override var state: UIControl.State {
get {
var state = super.state
if isEnabled {
if !isReadOnly && (showError || hasInternalError){
state.insert(.error)
}
if isReadOnly {
state.insert(.readonly)
}
if let responder, responder.isFirstResponder {
state.insert(.focused)
}
}
return state
}
}
open var errorText: String? { didSet { setNeedsUpdate() } }
open var tooltipModel: Tooltip.TooltipModel? { didSet { setNeedsUpdate() } }
open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } }
open var width: CGFloat? { didSet { setNeedsUpdate() } }
open var inputId: String? { didSet { setNeedsUpdate() } }
/// The text of this textField.
open var value: ValueType? {
get { fatalError("must be read from subclass")}
}
open var defaultValue: ValueType? { didSet { setNeedsUpdate() } }
open var isRequired: Bool = false { didSet { setNeedsUpdate() } }
open var isReadOnly: Bool = false { didSet { setNeedsUpdate() } }
open var helperTextPlacement: HelperTextPlacement = .bottom {
didSet {
updateHelperTextPosition()
}
}
open var rules = [AnyRule<ValueType>]()
open var accessibilityHintText: String = "Double tap to open"
//--------------------------------------------------
// 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()
let layoutGuide = UILayoutGuide()
addLayoutGuide(layoutGuide)
layoutGuide
.pinTop()
.pinLeading()
.pinBottom()
trailingEqualsConstraint = layoutGuide.pinTrailing(anchor: trailingAnchor)
// width constraints
trailingLessThanEqualsConstraint = layoutGuide.pinTrailingLessThanOrEqualTo(anchor: trailingAnchor)?.deactivate()
widthConstraint = layoutGuide.widthAnchor.constraint(equalToConstant: 0).deactivate()
// Add mainStackView to the view
addSubview(mainStackView)
mainStackView.pinTop(anchor: layoutGuide.topAnchor)
mainStackView.pinLeading(anchor: layoutGuide.leadingAnchor)
mainStackView.pinBottom(anchor: layoutGuide.bottomAnchor)
mainStackView.pinTrailing(anchor: layoutGuide.trailingAnchor)
//add ContainerStackView
//this is the horizontal stack that contains
//InputContainer, Icons, Buttons
containerView.addSubview(fieldStackView)
fieldStackView.pinToSuperView(.uniform(VDSLayout.space3X))
let fieldContainerView = getFieldContainer()
fieldContainerView.translatesAutoresizingMaskIntoConstraints = false
//add the view to add input fields
fieldStackView.addArrangedSubview(fieldContainerView)
fieldStackView.addArrangedSubview(statusIcon)
fieldStackView.setCustomSpacing(VDSLayout.space3X, after: fieldContainerView)
//get the container this is what show helper text, error text
//can include other for character count, max length
bottomContainerView = getBottomContainer()
//this is the vertical stack that contains error text, helper text
bottomContainerStackView.addArrangedSubview(errorLabel)
bottomContainerStackView.addArrangedSubview(helperLabel)
// Add arranged subviews to textFieldStackView
contentStackView.addArrangedSubview(containerView)
contentStackView.addArrangedSubview(bottomContainerView)
// Add arranged subviews to mainStackView
mainStackView.addArrangedSubview(titleLabel)
mainStackView.addArrangedSubview(contentStackView)
// Initial position of the helper label
updateHelperTextPosition()
// colorconfigs
titleLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable()
errorLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable()
helperLabel.textColorConfiguration = secondaryColorConfiguration.eraseToAnyColorable()
}
/// Updates the UI
open override func updateView() {
super.updateView()
updateRules()
updateContainerView()
updateContainerWidth()
updateTitleLabel()
updateErrorLabel()
updateHelperLabel()
}
open override func setDefaults() {
super.setDefaults()
titleLabel.textStyle = .bodySmall
errorLabel.textStyle = .bodySmall
helperLabel.textStyle = .bodySmall
labelText = nil
helperText = nil
showError = false
errorText = nil
tooltipModel = nil
transparentBackground = false
width = nil
inputId = nil
defaultValue = nil
isRequired = false
isReadOnly = false
helperTextPlacement = .bottom
rules = []
onChange = nil
containerView.bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return "" }
var accessibilityLabels = [String]()
if let text = titleLabel.text?.trimmingCharacters(in: .whitespaces) {
accessibilityLabels.append(text)
}
if isReadOnly {
accessibilityLabels.append("read only")
}
if !isEnabled {
accessibilityLabels.append("dimmed")
}
if let errorText, showError {
accessibilityLabels.append("error, \(errorText)")
}
return accessibilityLabels.joined(separator: ", ")
}
containerView.bridge_accessibilityHintBlock = { [weak self] in
guard let self else { return "" }
return isReadOnly || !isEnabled ? "" : accessibilityHintText
}
containerView.bridge_accessibilityValueBlock = { [weak self] in
guard let self, let value else { return "" }
return "\(value)"
}
statusIcon.bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return "" }
return showError || hasInternalError ? "error" : nil
}
}
/// Resets to default settings.
open override func reset() {
titleLabel.reset()
errorLabel.reset()
helperLabel.reset()
super.reset()
}
open override var canBecomeFirstResponder: Bool {
responder?.canBecomeFirstResponder ?? super.canBecomeFirstResponder
}
open override func becomeFirstResponder() -> Bool {
responder?.becomeFirstResponder() ?? super.becomeFirstResponder()
}
open override var canResignFirstResponder: Bool {
responder?.canResignFirstResponder ?? super.canResignFirstResponder
}
open override func resignFirstResponder() -> Bool {
responder?.resignFirstResponder() ?? super.resignFirstResponder()
}
//--------------------------------------------------
// MARK: - Public Methods
//--------------------------------------------------
/// Container for the area in which the user interacts.
open func getFieldContainer() -> UIView {
fatalError("Subclass must return the view that contains the field/view the user will interact with.")
}
/// Container for the area in which helper or error text presents.
open func getBottomContainer() -> UIView {
return bottomContainerStackView
}
open func validate(){
validator = FormFieldValidator<EntryFieldBase>(field: self, rules: rules)
validator?.validate()
setNeedsUpdate()
}
open func updateTitleLabel() {
//update the local vars for the label since we no
//long have a model
var attributes: [any LabelAttributeModel] = []
var updatedLabelText = labelText
//dealing with the "Optional" addition to the text
if let oldText = updatedLabelText, !isRequired, !oldText.hasSuffix("Optional") {
if isEnabled {
let optionColorAttr = ColorLabelAttribute(location: oldText.count + 1,
length: 8,
color: secondaryColorConfiguration.getColor(self))
attributes.append(optionColorAttr)
}
updatedLabelText = "\(oldText) Optional"
}
if let tooltipModel {
attributes.append(TooltipLabelAttribute(surface: surface, model: tooltipModel, presenter: self))
}
//set the titleLabel
titleLabel.text = updatedLabelText
titleLabel.attributes = attributes
titleLabel.surface = surface
titleLabel.isEnabled = isEnabled
}
open func updateErrorLabel(){
/// always show the errorIcon if there is an error
if showError || hasInternalError {
statusIcon.name = .error
statusIcon.surface = surface
statusIcon.isHidden = !isEnabled || state.contains(.focused)
} else {
statusIcon.isHidden = true
}
statusIcon.color = iconColorConfiguration.getColor(self)
// only show errorLabel if there is a message
var message: String?
if showError, let errorText {
message = errorText
} else if hasInternalError, let internalErrorText {
message = internalErrorText
}
if let message {
errorLabel.text = message
errorLabel.surface = surface
errorLabel.isEnabled = isEnabled
errorLabel.isHidden = false
} else {
errorLabel.isHidden = true
}
}
open func updateHelperLabel(){
//set the helper label position
if let helperText {
helperLabel.text = helperText
helperLabel.surface = surface
helperLabel.isEnabled = isEnabled
helperLabel.isHidden = false
} else {
helperLabel.isHidden = true
}
}
open override var accessibilityElements: [Any]? {
get {
var elements = [Any]()
elements.append(contentsOf: [titleLabel, containerView])
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 }
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
internal func updateRules() {
rules.removeAll()
if isRequired && useRequiredRule && ValueType.self == String.self {
let rule = RequiredRule<ValueType>()
if let errorText, !errorText.isEmpty {
rule.errorMessage = errorText
} else if let labelText{
rule.errorMessage = "You must enter a \(labelText)"
} else {
rule.errorMessage = "You must enter a value"
}
rules.append(.init(rule))
}
}
internal func updateContainerView() {
containerView.backgroundColor = containerBackgroundColor
containerView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor
containerView.layer.borderWidth = VDSFormControls.borderWidth
containerView.layer.cornerRadius = VDSFormControls.borderRadius
}
internal func updateContainerWidth() {
widthConstraint?.deactivate()
trailingLessThanEqualsConstraint?.deactivate()
trailingEqualsConstraint?.deactivate()
if let width, width >= minWidth, width <= maxWidth {
widthConstraint?.constant = width
widthConstraint?.activate()
trailingLessThanEqualsConstraint?.activate()
} else {
trailingEqualsConstraint?.activate()
}
}
internal func updateHelperTextPosition() {
titleLabel.removeFromSuperview()
helperLabel.removeFromSuperview()
contentStackView.removeFromSuperview()
mainStackView.removeArrangedSubviews()
//rows for helper-right
row1StackView.removeArrangedSubviews()
row2StackView.removeArrangedSubviews()
row1StackView.removeFromSuperview()
row2StackView.removeFromSuperview()
switch helperTextPlacement {
case .bottom:
//add helper back into the contentView
bottomContainerStackView.addArrangedSubview(helperLabel)
mainStackView.addArrangedSubview(titleLabel)
mainStackView.addArrangedSubview(contentStackView)
case .right:
//first row
row1StackView.addArrangedSubview(titleLabel)
//add spacer
row1StackView.addArrangedSubview(UIView())
//second row
row2StackView.addArrangedSubview(contentStackView)
//add under spacer
row2StackView.addArrangedSubview(helperLabel)
//add 2 rows to vertical stack to create the grid
mainStackView.addArrangedSubview(row1StackView)
mainStackView.addArrangedSubview(row2StackView)
}
}
}
extension UIStackView {
public func removeArrangedSubviews() {
arrangedSubviews.forEach { removeArrangedSubview($0) }
}
}