// // TextField.swift // VDS // // Created by Matt Bruce on 5/1/24. // import Foundation import UIKit import Combine import VDSCoreTokens @objcMembers @objc(VDSTextField) open class TextField: UITextField, ViewProtocol, Errorable { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) initialSetup() } public override init(frame: CGRect) { super.init(frame: .zero) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- /// Set of Subscribers for any Publishers for this Control. open var subscribers = Set() //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var initialSetupPerformed = false private var horizontalPadding: CGFloat = 0 //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- /// Set to true to hide the blinking textField cursor. open var hideBlinkingCaret = false open var enableClipboardActions: Bool = true open var onDidDeleteBackwards: (() -> Void)? /// Key of whether or not updateView() is called in setNeedsUpdate() open var shouldUpdateView: Bool = true private var formatLabel = Label().with { $0.tag = 999 $0.textColorConfiguration = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: false) }.eraseToAnyColorable() } /// Format String similar to placeholder open var formatText: String? /// TextStyle used on the titleLabel. open var textStyle: TextStyle = .defaultStyle { didSet { setNeedsUpdate() } } /// Will determine if a scaled font should be used for the titleLabel font. open var useScaledFont: Bool = false { didSet { setNeedsUpdate() } } open var surface: Surface = .light { didSet { setNeedsUpdate() } } open var showError: Bool = false { didSet { setNeedsUpdate() } } open var errorText: String? { didSet { setNeedsUpdate() } } open var lineBreakMode: NSLineBreakMode = .byClipping { didSet { setNeedsUpdate() } } open override var isEnabled: Bool { didSet { setNeedsUpdate() } } open var textColorConfiguration: AnyColorable = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable(){ didSet { setNeedsUpdate() }} open override var textColor: UIColor? { get { textColorConfiguration.getColor(self) } set { } } override public var text: String! { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- private func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true shouldUpdateView = false setup() setDefaults() shouldUpdateView = true setNeedsUpdate() } } open func setup() { translatesAutoresizingMaskIntoConstraints = false setContentCompressionResistancePriority(.defaultLow, for: .horizontal) clipsToBounds = true let accessView = UIView(frame: .init(origin: .zero, size: .init(width: UIScreen.main.bounds.width, height: 44))) accessView.backgroundColor = .white accessView.addBorder(side: .top, width: 1, color: .lightGray) let done = UIButton(type: .system) done.setTitle("Done", for: .normal) done.translatesAutoresizingMaskIntoConstraints = false done.addTarget(self, action: #selector(doneButtonAction), for: .touchUpInside) accessView.addSubview(done) done.pinCenterY() .pinTrailing(16) inputAccessoryView = accessView } open func setDefaults() { backgroundColor = .clear surface = .light text = nil formatText = nil useScaledFont = false showError = false errorText = nil textStyle = .defaultStyle } @objc func doneButtonAction() { // Resigns the first responder status when 'Done' is tapped let _ = resignFirstResponder() } open func updateView() { updateLabel() updateFormat() } open func updateFormat() { guard let formatText else { formatLabel.text = "" return } if viewWithTag(999) == nil { addSubview(formatLabel) formatLabel.pinToSuperView() } var attributes: [any LabelAttributeModel]? var finalFormatText = formatText if let text, !text.isEmpty { //make the color of the matching text clear attributes = [ColorLabelAttribute(location: 0, length: text.count, color: .clear)] let startIndex = formatText.index(formatText.startIndex, offsetBy: text.count) if startIndex < formatText.endIndex { finalFormatText = text + formatText[startIndex...] } } //set the label formatLabel.surface = surface formatLabel.text = finalFormatText formatLabel.attributes = attributes } open func updateAccessibility() { if let errorText, showError { accessibilityLabel = "error, \(errorText)" } else { accessibilityLabel = nil } } open func reset() { shouldUpdateView = false setDefaults() shouldUpdateView = true setNeedsUpdate() } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override func textRect(forBounds bounds: CGRect) -> CGRect { let rect = super.textRect(forBounds: bounds) return rect.insetBy(dx: -horizontalPadding, dy: 0) } open override func editingRect(forBounds bounds: CGRect) -> CGRect { let rect = super.editingRect(forBounds: bounds) return rect.insetBy(dx: -horizontalPadding, dy: 0) } open override func placeholderRect(forBounds bounds: CGRect) -> CGRect { let rect = super.placeholderRect(forBounds: bounds) return rect.insetBy(dx: -horizontalPadding, dy: 0) } open override var isSecureTextEntry: Bool { didSet { if isFirstResponder { _ = becomeFirstResponder() } } } open override func becomeFirstResponder() -> Bool { let success = super.becomeFirstResponder() if isSecureTextEntry, let text { self.text?.removeAll() insertText(text) } return success } open override func caretRect(for position: UITextPosition) -> CGRect { if hideBlinkingCaret { return .zero } let caretRect = super.caretRect(for: position) return CGRect(origin: caretRect.origin, size: CGSize(width: 1, height: caretRect.height)) } open override func deleteBackward() { super.deleteBackward() onDidDeleteBackwards?() } open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { enableClipboardActions } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateLabel() { //clear the arrays holding actions accessibilityCustomActions = [] if let text, !text.isEmpty { //create the primary string let mutableText = NSMutableAttributedString.mutableText(for: text, textStyle: textStyle, useScaledFont: useScaledFont, textColor: textColor!, alignment: .left, lineBreakMode: lineBreakMode) attributedText = mutableText } else { attributedText = nil } } //-------------------------------------------------- // MARK: - Accessibility //-------------------------------------------------- open var accessibilityAction: ((TextField) -> Void)? open override var isAccessibilityElement: Bool { get { var block: AXBoolReturnBlock? // if #available(iOS 17, *) { // block = isAccessibilityElementBlock // } if block == nil { block = bridge_isAccessibilityElementBlock } if let block { return block() } else { return super.isAccessibilityElement } } set { super.isAccessibilityElement = newValue } } open override var accessibilityLabel: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityLabelBlock // } if block == nil { block = bridge_accessibilityLabelBlock } if let block { return block() } else { return super.accessibilityLabel } } set { super.accessibilityLabel = newValue } } open override var accessibilityHint: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityHintBlock // } if block == nil { block = bridge_accessibilityHintBlock } if let block { return block() } else { return super.accessibilityHint } } set { super.accessibilityHint = newValue } } open override var accessibilityValue: String? { get { var block: AXStringReturnBlock? // if #available(iOS 17, *) { // block = accessibilityHintBlock // } if block == nil { block = bridge_accessibilityValueBlock } if let block{ return block() } else { return super.accessibilityValue } } set { super.accessibilityValue = newValue } } open override func accessibilityActivate() -> Bool { guard isEnabled, isUserInteractionEnabled else { return false } // if #available(iOS 17, *) { // if let block = accessibilityAction { // block(self) // return true // } else if let block = accessibilityActivateBlock { // return block() // // } else if let block = bridge_accessibilityActivateBlock { // return block() // // } else { // return super.accessibilityActivate() // // } // // } else { if let block = accessibilityAction { block(self) return true } else if let block = bridge_accessibilityActivateBlock { return block() } else { return super.accessibilityActivate() } // } } } extension UITextField { public func cursorPosition(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 position(from: beginningOfDocument, offset: finalCursorLocation) } }