// // 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, UITextFieldDelegate { //-------------------------------------------------- // 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) } 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?.sendActions(for: .valueChanged) }.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 = "" textField.delegate = self 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() { super.updateView() textField.isEnabled = isEnabled textField.textColor = textFieldTextColorConfiguration.getColor(self) updateFieldType() //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 { statusIcon.isHidden = true 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? var isSecureTextEntry = false switch fieldType { case .text: break case .number: break case .inlineAction: minWidth = 102.0 case .password: let isHide = passwordActionType == .hide let buttonText = isHide ? hidePasswordButtonText : 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 } //-------------------------------------------------- // 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() } } } public class TextField: UITextField { open override var isSecureTextEntry: Bool { didSet { if isFirstResponder { _ = becomeFirstResponder() } } } public override func becomeFirstResponder() -> Bool { let success = super.becomeFirstResponder() if isSecureTextEntry, let text { self.text?.removeAll() insertText(text) } return success } }