// // EntryField.swift // VDS // // Created by Matt Bruce on 10/3/22. // import Foundation import UIKit import VDSTokens import Combine /// Base Class used to build out a Input controls. @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 primaryStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical $0.distribution = .fill $0.alignment = .leading } }() internal let secondaryStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical $0.distribution = .fill } /// This is the view that will be wrapped with the border for userInteraction. internal var containerView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() /// This is a horizontal Stack View that is placed inside the containterView (bordered view) /// The first arrangedView will be the view from getFieldContainer() /// The second view is the statusIcon. 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 } }() open var rules = [AnyRule]() //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. internal var containerSize: CGSize { CGSize(width: 45, 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.elementsPrimaryOnlight, forState: .focused) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forState: [.focused, .error]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.disabled,.error]) } 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 //-------------------------------------------------- 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 } 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 showError || hasInternalError { state.insert(.error) } 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() } } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var heightConstraint: NSLayoutConstraint? internal var widthConstraint: NSLayoutConstraint? //-------------------------------------------------- // 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() isAccessibilityElement = false addSubview(primaryStackView) //create the wrapping view heightConstraint = containerView.heightGreaterThanEqualTo(constant: containerSize.height) widthConstraint = containerView.width(constant: 0) secondaryStackView.addArrangedSubview(containerView) secondaryStackView.setCustomSpacing(8, after: containerView) //add ContainerStackView //this is the horizontal stack that contains //the left, 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 let bottomContainer = getBottomContainer() //this is the vertical stack that contains error text, helper text bottomContainerStackView.addArrangedSubview(errorLabel) bottomContainerStackView.addArrangedSubview(helperLabel) primaryStackView.addArrangedSubview(titleLabel) primaryStackView.addArrangedSubview(secondaryStackView) secondaryStackView.addArrangedSubview(bottomContainer) primaryStackView.setCustomSpacing(4, after: titleLabel) primaryStackView .pinTop() .pinLeading() .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) titleLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() errorLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() helperLabel.textColorConfiguration = secondaryColorConfiguration.eraseToAnyColorable() } /// 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 } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateContainerView() updateTitleLabel() updateErrorLabel() updateHelperLabel() } open func validate(){ updateRules() validator = FormFieldValidator(field: self, rules: rules) validator?.validate() setNeedsUpdate() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateContainerView() { containerView.backgroundColor = backgroundColorConfiguration.getColor(self) containerView.layer.borderColor = isReadOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = VDSFormControls.borderWidth containerView.layer.cornerRadius = VDSFormControls.borderRadius } //-------------------------------------------------- // 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 } internal func updateRules() { rules.removeAll() if self.isRequired { 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)) } } 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 + 2, length: 8, color: VDSColor.elementsSecondaryOnlight) 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(){ if showError, let errorText { errorLabel.text = errorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false statusIcon.name = .error statusIcon.surface = surface statusIcon.isHidden = !isEnabled || state.contains(.focused) } else if hasInternalError, let internalErrorText { errorLabel.text = internalErrorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled errorLabel.isHidden = false statusIcon.name = .error statusIcon.surface = surface statusIcon.isHidden = !isEnabled || state.contains(.focused) } else { statusIcon.isHidden = true errorLabel.isHidden = true } statusIcon.color = iconColorConfiguration.getColor(self) } 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 } } }