// // 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. @objcMembers @objc(VDSEntryField) open class EntryFieldBase: 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: String? { get { fatalError("must be read from subclass")} } open var defaultValue: AnyHashable? { 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]() 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() 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 else { return "" } return value } statusIcon.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return showError || hasInternalError ? "error" : nil } } /// Updates the UI open override func updateView() { super.updateView() updateRules() updateContainerView() updateContainerWidth() updateTitleLabel() updateErrorLabel() updateHelperLabel() } /// Resets to default settings. open override func reset() { super.reset() titleLabel.reset() errorLabel.reset() helperLabel.reset() 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 onChange = nil } 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(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 { let rule = RequiredRule() 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) } } }