// // TextEntryField.swift // VDS // // Created by Matt Bruce on 10/3/22. // import Foundation import UIKit import VDSTokens import Combine /// An input field is an input wherein a customer enters information. They typically appear in forms. /// Specialized input fields capture credit card numbers, inline actions, passwords, phone numbers, /// dates and security codes in their correct formats. @objc(VDSInputField) open class InputField: 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: - Enums //-------------------------------------------------- /// Enum used to describe the input type. public enum FieldType: String, CaseIterable { case text, number, inlineAction, password, creditCard, tel, date, securityCode } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var inputFieldStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.spacing = 12 } }() internal var minWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Label to render the successText. open var successLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodySmall } /// UITextField shown in the InputField. open var textField = TextField().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.font = TextStyle.bodyLarge.font } /// Color configuration for the textField. open var textFieldTextColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable() /// Representing the type of input. open var fieldType: FieldType = .text { didSet { setNeedsUpdate() } } open var leftIcon: Icon = Icon().with { $0.size = .medium } open var actionTextLink = TextLink().with { $0.contentEdgeInsets = .top(-2) } open var actionTextLinkModel: TextLinkModel? { didSet { setNeedsUpdate() } } /// The text of this TextField. open var text: String? { get { textField.text } set { textField.text = newValue setNeedsUpdate() } } /// Value for the textField open override var value: String? { textField.text } var _showError: Bool = false /// Whether not to show the error. open override var showError: Bool { get { _showError } set { if !showSuccess && _showError != newValue { _showError = newValue setNeedsUpdate() } } } var _showSuccess: Bool = false /// Whether not to show the success. open var showSuccess: Bool { get { _showSuccess } set { if !showError && _showSuccess != newValue { _showSuccess = newValue setNeedsUpdate() } } } /// 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 showSuccess { state.insert(.success) } else if textField.isFirstResponder { state.insert(.focused) } return state } } /// If given, this will be shown if showSuccess if true. open var successText: String? { didSet { setNeedsUpdate() } } /// Determines the placement of the helper text. open var helperTextPlacement: HelperTextPlacement = .bottom { didSet { setNeedsUpdate() } } //-------------------------------------------------- // 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() minWidthConstraint = containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 0) minWidthConstraint?.isActive = true // stackview for controls in EntryFieldBase.controlContainerView let controlStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.spacing = VDSLayout.space3X } controlContainerView.addSubview(controlStackView) controlStackView.pinToSuperView() controlStackView.addArrangedSubview(leftIcon) controlStackView.addArrangedSubview(textField) textField.heightAnchor.constraint(equalToConstant: 20).isActive = true textField .textPublisher .sink { [weak self] newText in print("textPublisher newText: \(newText)") self?.text = newText self?.validate() self?.sendActions(for: .valueChanged) }.store(in: &subscribers) textField .publisher(for: .editingDidBegin) .sink { [weak self] _ in self?.setNeedsUpdate() }.store(in: &subscribers) textField .publisher(for: .editingDidEnd) .sink { [weak self] _ in self?.validate() }.store(in: &subscribers) stackView.addArrangedSubview(successLabel) stackView.setCustomSpacing(8, after: successLabel) containerStackView.addArrangedSubview(actionTextLink) successLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() backgroundColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessBackgroundOnlight, VDSColor.feedbackSuccessBackgroundOndark, forState: .success) borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success) } /// Resets to default settings. open override func reset() { super.reset() textField.text = "" successLabel.reset() successLabel.textStyle = .bodySmall fieldType = .text showSuccess = false successText = nil helperTextPlacement = .bottom } /// 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() { //update fieldType first updateFieldType() super.updateView() textField.isEnabled = isEnabled textField.textColor = textFieldTextColorConfiguration.getColor(self) } open override func updateErrorLabel() { super.updateErrorLabel() //show error or success if showError, let _ = errorText { successLabel.isHidden = true } else if showSuccess, let successText { successLabel.text = successText successLabel.surface = surface successLabel.isEnabled = isEnabled successLabel.isHidden = false errorLabel.isHidden = true statusIcon.name = .checkmarkAlt statusIcon.color = VDSColor.paletteBlack statusIcon.surface = surface statusIcon.isHidden = !isEnabled } else { successLabel.isHidden = true } } open override func updateHelperLabel(){ //remove first helperLabel.removeFromSuperview() super.updateHelperLabel() //set the helper label position if helperText != nil { if helperTextPlacement == .right { inputFieldStackView.spacing = 12 inputFieldStackView.distribution = .fillEqually inputFieldStackView.addArrangedSubview(helperLabel) } else { inputFieldStackView.spacing = 0 inputFieldStackView.distribution = .fill stackView.addArrangedSubview(helperLabel) } } } open func updateFieldType() { var minWidth: CGFloat = 40.0 var leftIconName: Icon.Name? var actionModel: InputField.TextLinkModel? var toolTipModel: Tooltip.TooltipModel? = tooltipModel var isSecureTextEntry = false var rules = [AnyRule]() if self.isRequired { let rule = RequiredRule() if let errorText { rule.errorMessage = errorText } rules.append(.init(rule)) } switch fieldType { case .text: break case .number: break case .inlineAction: minWidth = 102.0 case .password: let isHide = passwordActionType == .hide let buttonText = isHide ? hidePasswordButtonText.isEmpty ? "Hide" : hidePasswordButtonText : showPasswordButtonText.isEmpty ? "Show" : showPasswordButtonText isSecureTextEntry = !isHide let nextPasswordActionType = passwordActionType.toggle() if let text, !text.isEmpty { actionModel = .init(text: buttonText, onClick: { [weak self] _ in guard let self else { return } self.passwordActionType = nextPasswordActionType }) } else { passwordActionType = .show } minWidth = 62.0 case .creditCard: minWidth = 288.0 case .tel: minWidth = 176.0 case .date: minWidth = 114.0 case .securityCode: minWidth = 88.0 } //textField textField.isSecureTextEntry = isSecureTextEntry //leftIcon leftIcon.surface = surface leftIcon.color = iconColorConfiguration.getColor(self) leftIcon.name = leftIconName leftIcon.isHidden = leftIconName == nil //actionLink actionTextLink.surface = surface if let actionModel { actionTextLink.text = actionModel.text actionTextLink.onClick = actionModel.onClick actionTextLink.isHidden = false containerStackView.setCustomSpacing(VDSLayout.space2X, after: statusIcon) } else { actionTextLink.isHidden = true containerStackView.setCustomSpacing(0, after: statusIcon) } //set the width constraints if let width, width > minWidth { widthConstraint?.constant = width widthConstraint?.isActive = true minWidthConstraint?.isActive = false } else { minWidthConstraint?.constant = minWidth widthConstraint?.isActive = false minWidthConstraint?.isActive = true } //tooltip tooltipModel = toolTipModel } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() textField.accessibilityLabel = showError ? "error" : nil } open override var accessibilityElements: [Any]? { get { var elements = [Any]() elements.append(contentsOf: [titleLabel, textField]) if showError { elements.append(statusIcon) if let errorText, !errorText.isEmpty { elements.append(errorLabel) } } else if showSuccess, let successText, !successText.isEmpty { elements.append(successLabel) } if let helperText, !helperText.isEmpty { elements.append(helperLabel) } return elements } set { super.accessibilityElements = newValue } } open override var canBecomeFirstResponder: Bool { true } open override func resignFirstResponder() -> Bool { if textField.isFirstResponder { textField.resignFirstResponder() } return super.resignFirstResponder() } //-------------------------------------------------- // MARK: - Password //-------------------------------------------------- enum PasswordAction { case show, hide func toggle() -> PasswordAction { self == .hide ? .show : .hide } } internal var passwordActionType: PasswordAction = .show { didSet { setNeedsUpdate() } } open var hidePasswordButtonText: String = "Hide" { didSet { setNeedsUpdate() } } open var showPasswordButtonText: String = "Show" { didSet { setNeedsUpdate() } } } extension InputField.FieldType { public var keyboardType: UIKeyboardType { switch self { case .number: .numberPad case .tel: .phonePad case .creditCard: .numberPad case .date: .numberPad case .securityCode: .numberPad default: .default } } }