// // 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: - 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 leftImageView = UIImageView().with { $0.height(21); $0.width(32) } 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? { if fieldType == .creditCard { return creditCardRawNumber } else { return 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(leftImageView) controlStackView.addArrangedSubview(textField) textField.heightAnchor.constraint(equalToConstant: 20).isActive = true textField.delegate = self textField .textPublisher .sink { [weak self] newText in print("textPublisher newText: \(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 = "" 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 leftImageName: String? var actionModel: InputField.TextLinkModel? var toolTipModel: Tooltip.TooltipModel? = tooltipModel var isSecureTextEntry = false var placeholderText: String? 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 leftImageName = creditCardType.imageName case .tel: minWidth = 176.0 case .date: minWidth = 114.0 placeholderText = dateFormat.placeholderText case .securityCode: minWidth = 88.0 isSecureTextEntry = true } //textField textField.isSecureTextEntry = isSecureTextEntry //leftIcon if let leftImageName { leftImageView.image = BundleManager.shared.image(for: creditCardType.imageName)?.withTintColor(iconColorConfiguration.getColor(self)) } leftImageView.isHidden = leftImageName == 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 } //placeholder textField.placeholder = placeholderText //tooltip tooltipModel = toolTipModel } override func updateRules() { super.updateRules() switch fieldType { case .creditCard: if let text = textField.text, text.count > 0 { let rule = CharacterCountRule().copyWith { $0.maxLength = creditCardType.maxLength $0.compareType = .equals $0.errorMessage = "Enter a valid credit card." } rules.append(.init(rule)) } case .tel: if let text = textField.text, text.count > 0 { let rule = CharacterCountRule().copyWith { $0.maxLength = "XXX-XXX-XXXX".count $0.compareType = .equals $0.errorMessage = "Enter a valid telephone." } rules.append(.init(rule)) } case .date: if let text = textField.text, text.count > 0 { let rule = CharacterCountRule().copyWith { $0.maxLength = dateFormat.maxLength $0.compareType = .equals $0.errorMessage = "Enter a valid date." } rules.append(.init(rule)) } default: break } } /// 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: - Private Methods //-------------------------------------------------- internal func cursorPosition(textField: UITextField, range: NSRange, replacementString string: String, rawNumber: String, formattedNumber: String) -> UITextPosition? { let start = range.location let length = string.count let newCursorLocation = start + length // Adjust the cursor position to skip over formatting characters var formattedCharacterCount = 0 for (index, character) in formattedNumber.enumerated() { if index >= newCursorLocation + formattedCharacterCount { break } if !character.isNumber { formattedCharacterCount += 1 } } let finalCursorLocation = min(newCursorLocation + formattedCharacterCount, formattedNumber.count) return textField.position(from: textField.beginningOfDocument, offset: finalCursorLocation) } //-------------------------------------------------- // MARK: - Password //-------------------------------------------------- internal var passwordActionType: PasswordAction = .show { didSet { setNeedsUpdate() } } open var hidePasswordButtonText: String = "Hide" { didSet { setNeedsUpdate() } } open var showPasswordButtonText: String = "Show" { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Date //-------------------------------------------------- open var dateFormat: DateFormat = .mmddyy { didSet { setNeedsUpdate() } } //--------------------------------------------------- // MARK: - Credit Card //--------------------------------------------------- internal var creditCardRawNumber: String = "" internal var creditCardType: CreditCardType = .generic { didSet { setNeedsUpdate() } } //--------------------------------------------------- // MARK: - Telephone //--------------------------------------------------- } extension InputField: UITextFieldDelegate { public func textFieldDidBeginEditing(_ textField: UITextField) { if fieldType == .creditCard { textField.text = formatCreditCardNumber(creditCardRawNumber) } } public func textFieldDidEndEditing(_ textField: UITextField) { if self.fieldType == .creditCard { textField.text = maskCreditCardNumber(creditCardRawNumber) } validate() } public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { // case text, number, inlineAction, password, creditCard, tel, date, securityCode switch fieldType { case .creditCard: let allowedCharacters = CharacterSet.decimalDigits if string.rangeOfCharacter(from: allowedCharacters.inverted) != nil && !string.isEmpty { return false } // Get the current text let currentText = textField.text ?? "" // Calculate the new text let newText = (currentText as NSString).replacingCharacters(in: range, with: string) // Remove any existing formatting let rawNumber = newText.filter { $0.isNumber } if rawNumber.count > creditCardType.maxLength { return false } // Format the number with spaces let formattedNumber = formatCreditCardNumber(rawNumber) // Update the icon based on the first four digits updateCardTypeIcon(rawNumber: rawNumber) // Check again if rawNumber.count > creditCardType.maxLength { return false } // Set the formatted text textField.text = formattedNumber // Calculate the new cursor position if let newPosition = cursorPosition(textField: textField, range: range, replacementString: string, rawNumber: rawNumber, formattedNumber: formattedNumber) { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } // if all passes, then set the number1 creditCardRawNumber = rawNumber // Prevent the default behavior return false case .date: // Allow only numbers and limit the length of text. guard let oldText = textField.text, let textRange = Range(range, in: oldText), string.rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil else { return false } let newText = oldText.replacingCharacters(in: textRange, with: string) if newText.count > dateFormat.maxLength { return false } if newText.count <= dateFormat.maxLength { textField.text = formatDate(newText) return false } else { return true } case .number: // Allow only numbers let allowedCharacters = CharacterSet.decimalDigits let characterSet = CharacterSet(charactersIn: string) return allowedCharacters.isSuperset(of: characterSet) case .tel: // Allow only numbers and limit the length of text. let allowedCharacters = CharacterSet(charactersIn: "01233456789") let characterSet = CharacterSet(charactersIn: string) let currentText = textField.text ?? "" if !allowedCharacters.isSuperset(of: characterSet) { return false } // Calculate the new text let newText = (currentText as NSString).replacingCharacters(in: range, with: string) // Remove any existing formatting let rawNumber = newText.filter { $0.isNumber } // Format the number with dashes let formattedNumber = formatUSNumber(rawNumber) // Set the formatted text textField.text = formattedNumber // Calculate the new cursor position if let newPosition = cursorPosition(textField: textField, range: range, replacementString: string, rawNumber: rawNumber, formattedNumber: formattedNumber) { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } // Prevent the default behavior return false case .securityCode: // Allow only numbers and limit the length of text. let allowedCharacters = CharacterSet.decimalDigits let characterSet = CharacterSet(charactersIn: string) return allowedCharacters.isSuperset(of: characterSet) && ((textField.text?.count ?? 0) + string.count - range.length) <= 4 default: return true } } } extension String { internal static func format(_ value: String, indices: [Int], with separator: String) -> String { var formattedString = "" var currentIndex = value.startIndex for index in 0..