diff --git a/VDS/Components/TextFields/EntryFieldBase.swift b/VDS/Components/TextFields/EntryFieldBase.swift index cf2dffa2..78c1077a 100644 --- a/VDS/Components/TextFields/EntryFieldBase.swift +++ b/VDS/Components/TextFields/EntryFieldBase.swift @@ -356,6 +356,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { /// Updates the UI open override func updateView() { super.updateView() + updateRules() updateContainerView() updateContainerWidth() updateTitleLabel() @@ -418,7 +419,6 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable { } open func validate(){ - updateRules() validator = FormFieldValidator(field: self, rules: rules) validator?.validate() setNeedsUpdate() diff --git a/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift index 89417c19..10255be6 100644 --- a/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift +++ b/VDS/Components/TextFields/InputField/FieldTypes/Telephone.swift @@ -10,6 +10,35 @@ import UIKit extension InputField { + public class TelephoneNumberValidator: Rule, Withable { + public var format: String + public var errorMessage: String = "Please enter a valid telephone number" + + public init(format: String) { + self.format = format + } + + public func isValid(value: String?) -> Bool { + guard let value, !value.isEmpty else { return true } + let regex = createRegex(from: format) + let predicate = NSPredicate(format: "SELF MATCHES %@", regex) + let valid = predicate.evaluate(with: value) + return valid + } + + private func createRegex(from format: String) -> String { + // Escape special regex characters in the format string + let escapedFormat = NSRegularExpression.escapedPattern(for: format) + + // Replace placeholder characters with regex patterns + let regex = escapedFormat + .replacingOccurrences(of: "X", with: "\\d") + + return "^" + regex + "$" + } + } + + class TelephoneHandler: FieldTypeHandler { static let shared = TelephoneHandler() @@ -25,14 +54,7 @@ extension InputField { } override func appendRules(_ inputField: InputField) { - if let text = inputField.textField.text, text.count > 0 { - let rule = CharacterCountRule().copyWith { - $0.maxLength = "XXX-XXX-XXXX".count - $0.compareType = .equals - $0.errorMessage = "Enter a valid telephone." - } - inputField.rules.append(.init(rule)) - } + inputField.rules.append(.init(TelephoneNumberValidator(format: "XXX-XXX-XXXX"))) } override func textField(_ inputField: InputField, textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { @@ -49,7 +71,7 @@ extension InputField { let rawNumber = newText.filter { $0.isNumber } // Format the number with dashes - let formattedNumber = formatUSNumber(rawNumber) + let formattedNumber = rawNumber.formatUSNumber() // Set the formatted text textField.text = formattedNumber @@ -62,6 +84,8 @@ extension InputField { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } + value = formattedNumber + // Prevent the default behavior return false @@ -69,43 +93,45 @@ extension InputField { override func textFieldDidEndEditing(_ inputField: InputField, textField: UITextField) { if let text = inputField.text { - let rawNumber = text.filter { $0.isNumber } - textField.text = formatUSNumber(rawNumber) + textField.text = text.formatUSNumber() + value = textField.text } } - - func formatUSNumber(_ number: String) -> String { - // Format the number in the style XXX-XXX-XXXX - let areaCodeLength = 3 - let centralOfficeCodeLength = 3 - let lineNumberLength = 4 - - var formattedNumber = "" - - if number.count > 0 { - formattedNumber.append(contentsOf: number.prefix(areaCodeLength)) - } - - if number.count > areaCodeLength { - let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength) - let endIndex = number.index(startIndex, offsetBy: min(centralOfficeCodeLength, number.count - areaCodeLength)) - let centralOfficeCode = number[startIndex.. areaCodeLength + centralOfficeCodeLength { - let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength + centralOfficeCodeLength) - let endIndex = number.index(startIndex, offsetBy: min(lineNumberLength, number.count - areaCodeLength - centralOfficeCodeLength)) - let lineNumber = number[startIndex.. String { + // Format the number in the style XXX-XXX-XXXX + let areaCodeLength = 3 + let centralOfficeCodeLength = 3 + let lineNumberLength = 4 + + var formattedNumber = "" + let number = filter { $0.isNumber } + + if number.count > 0 { + formattedNumber.append(contentsOf: number.prefix(areaCodeLength)) + } + + if number.count > areaCodeLength { + let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength) + let endIndex = number.index(startIndex, offsetBy: min(centralOfficeCodeLength, number.count - areaCodeLength)) + let centralOfficeCode = number[startIndex.. areaCodeLength + centralOfficeCodeLength { + let startIndex = number.index(number.startIndex, offsetBy: areaCodeLength + centralOfficeCodeLength) + let endIndex = number.index(startIndex, offsetBy: min(lineNumberLength, number.count - areaCodeLength - centralOfficeCodeLength)) + let lineNumber = number[startIndex.. Bool { diff --git a/VDS/Components/TextFields/TextArea/TextArea.swift b/VDS/Components/TextFields/TextArea/TextArea.swift index f8a9a6b3..8768352e 100644 --- a/VDS/Components/TextFields/TextArea/TextArea.swift +++ b/VDS/Components/TextFields/TextArea/TextArea.swift @@ -111,6 +111,8 @@ open class TextArea: EntryFieldBase { } didSet { + setNeedsUpdate() + if textView.isFirstResponder { validate() } @@ -191,8 +193,9 @@ open class TextArea: EntryFieldBase { override func updateRules() { super.updateRules() - - rules.append(.init(countRule)) + if let maxLength, maxLength > 0 { + rules.append(.init(countRule)) + } } open override func getFieldContainer() -> UIView { diff --git a/VDS/Components/TileContainer/TileContainer.swift b/VDS/Components/TileContainer/TileContainer.swift index 6eeaf316..131a62f5 100644 --- a/VDS/Components/TileContainer/TileContainer.swift +++ b/VDS/Components/TileContainer/TileContainer.swift @@ -74,7 +74,7 @@ open class TileContainerBase: Control where Padding case custom(UIColor) private var reflectedValue: String { String(reflecting: self) } - + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.reflectedValue == rhs.reflectedValue } @@ -86,7 +86,7 @@ open class TileContainerBase: Control where Padding case gradient(UIColor, UIColor) case none } - + /// Enum used to describe the aspect ratios used for this component. public enum AspectRatio: String, CaseIterable { case ratio1x1 = "1:1" @@ -109,7 +109,7 @@ open class TileContainerBase: Control where Padding $0.contentMode = .scaleAspectFill $0.clipsToBounds = true } - + open var containerView = View().with { $0.setContentHuggingPriority(.defaultLow, for: .horizontal) $0.setContentHuggingPriority(.defaultLow, for: .vertical) @@ -125,27 +125,27 @@ open class TileContainerBase: Control where Padding /// This is the container in which views will be pinned. open var contentView = View() - + /// This is the view used to show the high light color for a onClick. open var highlightView = View().with { $0.isUserInteractionEnabled = false } - + /// This controls the aspect ratio for the component. open var aspectRatio: AspectRatio = .ratio1x1 { didSet { setNeedsUpdate() } } - + /// Sets the background color for the component. open var color: BackgroundColor? { didSet { setNeedsUpdate() } } /// Sets the background effect for the component. open var backgroundEffect: BackgroundEffect = .none { didSet { setNeedsUpdate() } } - + /// Sets the inside padding for the component open var padding: PaddingType = PaddingType.defaultValue { didSet { setNeedsUpdate() } } /// Applies a background color if backgroundImage prop fails or has trouble loading. open var imageFallbackColor: Surface = .light { didSet { setNeedsUpdate() } } - + private var _width: CGFloat? /// Sets the width for the component. Accepts a pixel value. open var width: CGFloat? { @@ -159,7 +159,7 @@ open class TileContainerBase: Control where Padding setNeedsUpdate() } } - + private var _height: CGFloat? /// Sets the height for the component. Accepts a pixel value. open var height: CGFloat? { @@ -179,13 +179,14 @@ open class TileContainerBase: Control where Padding /// Determines if there is a drop shadow or not. open var showDropShadow: Bool = false { didSet { setNeedsUpdate() } } - + //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var widthConstraint: NSLayoutConstraint? internal var heightConstraint: NSLayoutConstraint? - + internal var aspectRatioConstraint: NSLayoutConstraint? + //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- @@ -228,13 +229,13 @@ open class TileContainerBase: Control where Padding containerView.addSubview(backgroundImageView) backgroundImageView.pinToSuperView() - + containerView.addSubview(contentView) contentView.pinToSuperView() - + containerView.addSubview(highlightView) highlightView.pinToSuperView() - + widthConstraint = widthAnchor.constraint(equalToConstant: 0).deactivate() heightConstraint = heightAnchor.constraint(equalToConstant: 0).deactivate() @@ -266,7 +267,7 @@ open class TileContainerBase: Control where Padding setNeedsUpdate() } }.store(in: &subscribers) - + } /// Overriden to take the hit if there is an onClickSubscriber and the view is not a UIControl @@ -291,7 +292,7 @@ open class TileContainerBase: Control where Padding shouldUpdateView = true setNeedsUpdate() } - + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() @@ -301,13 +302,14 @@ open class TileContainerBase: Control where Padding containerView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = showBorder ? VDSFormControls.borderWidth : 0 - + contentView.removeConstraints() contentView.pinToSuperView(.uniform(padding.value)) updateContainerView() + } - + open override var accessibilityElements: [Any]? { get { var items = [Any]() @@ -328,7 +330,7 @@ open class TileContainerBase: Control where Padding //append all children that are accessible items.append(contentsOf: elements) - + return items } set {} @@ -337,7 +339,7 @@ open class TileContainerBase: Control where Padding //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- - + /// This will place a view within the contentView of this component. public func addContentView(_ view: UIView, shouldPin: Bool = true) { view.removeFromSuperview() @@ -346,7 +348,7 @@ open class TileContainerBase: Control where Padding view.pinToSuperView() } } - + //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- @@ -379,55 +381,10 @@ open class TileContainerBase: Control where Padding containerView.backgroundColor = color.withAlphaComponent(alphaConfiguration) } } - - private func ratioSize(for width: CGFloat) -> CGSize { - var height: CGFloat = width - switch aspectRatio { - case .ratio1x1: - break; - case .ratio3x4: - height = (4 / 3) * width - case .ratio4x3: - height = (3 / 4) * width - case .ratio2x3: - height = (3 / 2) * width - case .ratio3x2: - height = (2 / 3) * width - case .ratio9x16: - height = (16 / 9) * width - case .ratio16x9: - height = (9 / 16) * width - case .ratio1x2: - height = (2 / 1) * width - case .ratio2x1: - height = (1 / 2) * width - - default: - break - } - - return CGSize(width: width, height: height) - } - - private func sizeContainerView(width: CGFloat? = nil, height: CGFloat? = nil) { - if let width, width > 0 { - widthConstraint?.constant = width - widthConstraint?.activate() - } - - if let height, height > 0 { - heightConstraint?.constant = height - heightConstraint?.activate() - } - } - private func updateContainerView() { applyBackgroundEffects() - - widthConstraint?.deactivate() - heightConstraint?.deactivate() - + if showDropShadow, surface == .light { containerView.addDropShadow(dropShadowConfiguration) } else { @@ -436,50 +393,100 @@ open class TileContainerBase: Control where Padding containerView.dropShadowLayers?.forEach { $0.frame = containerView.bounds } containerView.gradientLayers?.forEach { $0.frame = containerView.bounds } + + //sizing the container with constraints + + //Set local vars + var containerViewWidth: CGFloat? = width + let containerViewHeight: CGFloat? = height + let multiplier = aspectRatio.multiplier - if width != nil || height != nil { - var containerViewWidth: CGFloat? - var containerViewHeight: CGFloat? - //run logic to determine which to activate - if let width, aspectRatio == .none && height == nil{ - containerViewWidth = width - - } else if let height, aspectRatio == .none && width == nil{ - containerViewHeight = height - - } else if let height, let width { - containerViewWidth = width - containerViewHeight = height - - } else if let width { - let size = ratioSize(for: width) - containerViewWidth = size.width - containerViewHeight = size.height + //turn off the constraints + aspectRatioConstraint?.deactivate() + widthConstraint?.deactivate() + heightConstraint?.deactivate() - } else if let height { - let size = ratioSize(for: height) - containerViewWidth = size.width - containerViewHeight = size.height - } - sizeContainerView(width: containerViewWidth, height: containerViewHeight) - } else { - if let parentSize = horizontalPinnedSize() { - - var containerViewWidth: CGFloat? - var containerViewHeight: CGFloat? - - let size = ratioSize(for: parentSize.width) - if aspectRatio == .none { - containerViewWidth = size.width - } else { - containerViewWidth = size.width - containerViewHeight = size.height - } - - sizeContainerView(width: containerViewWidth, height: containerViewHeight) - } + //------------------------------------------------------------------------- + //Overriding Nil Width Rules + //------------------------------------------------------------------------- + //Rule 1: + //In the scenario where we only have a height but the multiplie is nil, we + //want to set the width with the parent's width which will more or less "fill" + //the container horizontally + //- height is set + //- width is not set + //- aspectRatio is not set + if let superviewWidth, superviewWidth > 0, + containerViewHeight != nil, + containerViewWidth == nil, + multiplier == nil { + containerViewWidth = superviewWidth + } + + //Rule 2: + //In the scenario where no width and height is set, want to set the width with the + //parent's width which will more or less "fill" the container horizontally + //- height is not set + //- width is not set + else if let superviewWidth, superviewWidth > 0, + containerViewWidth == nil, + containerViewHeight == nil { + containerViewWidth = superviewWidth + } + //------------------------------------------------------------------------- + + + //------------------------------------------------------------------------- + //Width + AspectRatio Constraint - Will exit out if set + //------------------------------------------------------------------------- + if let containerViewWidth, + let multiplier, + containerViewWidth > 0, + containerViewHeight == nil { + widthConstraint?.constant = containerViewWidth + widthConstraint?.activate() + aspectRatioConstraint = heightAnchor.constraint(equalTo: widthAnchor, multiplier: multiplier) + aspectRatioConstraint?.activate() + return + } + //------------------------------------------------------------------------- + //Height + AspectRatio Constraint - Will exit out if set + //------------------------------------------------------------------------- + else if let containerViewHeight, + let multiplier, + containerViewHeight > 0, + containerViewWidth == nil { + heightConstraint?.constant = containerViewHeight + heightConstraint?.activate() + aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: multiplier) + aspectRatioConstraint?.activate() + return + } + + //------------------------------------------------------------------------- + //Width Constraint + //------------------------------------------------------------------------- + if let containerViewWidth, + containerViewWidth > 0 { + widthConstraint?.constant = containerViewWidth + widthConstraint?.activate() + } + + //------------------------------------------------------------------------- + //Height Constraint + //------------------------------------------------------------------------- + if let containerViewHeight, + containerViewHeight > 0 { + heightConstraint?.constant = containerViewHeight + heightConstraint?.activate() } } + + /// This is the size of the superview's allowed space for this container first by constrained size which would include padding/inset values an + private var superviewWidth: CGFloat? { + horizontalPinnedWidth() ?? superview?.frame.size.width + } + } extension TileContainerBase { @@ -519,3 +526,30 @@ extension TileContainerBase { } } } + +extension TileContainerBase.AspectRatio { + var multiplier: CGFloat? { + switch self { + case .ratio1x1: + return 1 + case .ratio3x4: + return 4 / 3 + case .ratio4x3: + return 3 / 4 + case .ratio2x3: + return 3 / 2 + case .ratio3x2: + return 2 / 3 + case .ratio9x16: + return 16 / 9 + case .ratio16x9: + return 9 / 16 + case .ratio1x2: + return 2 / 1 + case .ratio2x1: + return 1 / 2 + case .none: + return nil + } + } +} diff --git a/VDS/Protocols/FormFieldable.swift b/VDS/Protocols/FormFieldable.swift index 8a620765..82666772 100644 --- a/VDS/Protocols/FormFieldable.swift +++ b/VDS/Protocols/FormFieldable.swift @@ -20,6 +20,9 @@ public protocol FormFieldable { /// Protocol for FormFieldable that require internal validation. public protocol FormFieldInternalValidatable: FormFieldable, Errorable { + /// Rules that drive the validator + var rules: [AnyRule] { get set } + /// Is there an internalError var hasInternalError: Bool { get } /// Internal Error Message that will show. diff --git a/VDS/Protocols/LayoutConstraintable.swift b/VDS/Protocols/LayoutConstraintable.swift index ba08e3c2..bee145ad 100644 --- a/VDS/Protocols/LayoutConstraintable.swift +++ b/VDS/Protocols/LayoutConstraintable.swift @@ -705,11 +705,11 @@ extension LayoutConstraintable { } // Method to check if the view is pinned to its superview - public func isPinnedToSuperview() -> Bool { - isPinnedVerticallyToSuperview() && isPinnedHorizontallyToSuperview() + public func isPinnedEqual() -> Bool { + isPinnedEqualVertically() && isPinnedEqualHorizontally() } - public func horizontalPinnedSize() -> CGSize? { + public func horizontalPinnedWidth() -> CGFloat? { guard let view = self as? UIView, let superview = view.superview else { return nil } let constraints = superview.constraints @@ -735,44 +735,106 @@ extension LayoutConstraintable { if let leadingView = leadingObject as? UIView, let trailingView = trailingObject as? UIView { let leadingPosition = leadingView.convert(leadingView.bounds.origin, to: superview).x let trailingPosition = trailingView.convert(trailingView.bounds.origin, to: superview).x + trailingView.bounds.width - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingGuide = leadingObject as? UILayoutGuide, let trailingGuide = trailingObject as? UILayoutGuide { let leadingPosition = leadingGuide.layoutFrame.minX let trailingPosition = trailingGuide.layoutFrame.maxX - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingView = leadingObject as? UIView, let trailingGuide = trailingObject as? UILayoutGuide { let leadingPosition = leadingView.convert(leadingView.bounds.origin, to: superview).x let trailingPosition = trailingGuide.layoutFrame.maxX - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } else if let leadingGuide = leadingObject as? UILayoutGuide, let trailingView = trailingObject as? UIView { let leadingPosition = leadingGuide.layoutFrame.minX let trailingPosition = trailingView.convert(trailingView.bounds.origin, to: superview).x + trailingView.bounds.width - return CGSize(width: trailingPosition - leadingPosition, height: view.bounds.size.height) + return trailingPosition - leadingPosition } } else if let pinnedObject = leadingPinnedObject { if let view = pinnedObject as? UIView { - return view.bounds.size + return view.bounds.size.width } else if let layoutGuide = pinnedObject as? UILayoutGuide { - return layoutGuide.layoutFrame.size + return layoutGuide.layoutFrame.size.width } } else if let pinnedObject = trailingPinnedObject { if let view = pinnedObject as? UIView { - return view.bounds.size + return view.bounds.size.width } else if let layoutGuide = pinnedObject as? UILayoutGuide { - return layoutGuide.layoutFrame.size + return layoutGuide.layoutFrame.size.width } } return nil } + + public func verticalPinnedHeight() -> CGFloat? { + guard let view = self as? UIView, let superview = view.superview else { return nil } + let constraints = superview.constraints + + var topPinnedObject: AnyObject? + var bottomPinnedObject: AnyObject? + + for constraint in constraints { + if (constraint.firstItem === view && (constraint.firstAttribute == .top || constraint.firstAttribute == .topMargin)) { + topPinnedObject = constraint.secondItem as AnyObject? + } else if (constraint.secondItem === view && (constraint.secondAttribute == .top || constraint.secondAttribute == .topMargin)) { + topPinnedObject = constraint.firstItem as AnyObject? + } else if (constraint.firstItem === view && (constraint.firstAttribute == .bottom || constraint.firstAttribute == .bottomMargin)) { + bottomPinnedObject = constraint.secondItem as AnyObject? + } else if (constraint.secondItem === view && (constraint.secondAttribute == .bottom || constraint.secondAttribute == .bottomMargin)) { + bottomPinnedObject = constraint.firstItem as AnyObject? + } + } + + // Ensure both top and bottom pinned objects are identified + if let topObject = topPinnedObject, let bottomObject = bottomPinnedObject { + + // Calculate the size based on the pinned objects + if let topView = topObject as? UIView, let bottomView = bottomObject as? UIView { + let topPosition = topView.convert(topView.bounds.origin, to: superview).y + let bottomPosition = bottomView.convert(bottomView.bounds.origin, to: superview).y + bottomView.bounds.height + return bottomPosition - topPosition + + } else if let topGuide = topObject as? UILayoutGuide, let bottomGuide = bottomObject as? UILayoutGuide { + let topPosition = topGuide.layoutFrame.minY + let bottomPosition = bottomGuide.layoutFrame.maxY + return bottomPosition - topPosition + + } else if let topView = topObject as? UIView, let bottomGuide = bottomObject as? UILayoutGuide { + let topPosition = topView.convert(topView.bounds.origin, to: superview).y + let bottomPosition = bottomGuide.layoutFrame.maxY + return bottomPosition - topPosition + + } else if let topGuide = topObject as? UILayoutGuide, let bottomView = bottomObject as? UIView { + let topPosition = topGuide.layoutFrame.minY + let bottomPosition = bottomView.convert(bottomView.bounds.origin, to: superview).y + bottomView.bounds.height + return bottomPosition - topPosition + } + + } else if let pinnedObject = topPinnedObject { + if let view = pinnedObject as? UIView { + return view.bounds.size.height + } else if let layoutGuide = pinnedObject as? UILayoutGuide { + return layoutGuide.layoutFrame.size.height + } + + } else if let pinnedObject = bottomPinnedObject { + if let view = pinnedObject as? UIView { + return view.bounds.size.height + } else if let layoutGuide = pinnedObject as? UILayoutGuide { + return layoutGuide.layoutFrame.size.height + } + } + + return nil + } - public func isPinnedHorizontallyToSuperview() -> Bool { + public func isPinnedEqualHorizontally() -> Bool { guard let view = self as? UIView, let superview = view.superview else { return false } let constraints = superview.constraints var leadingPinned = false @@ -796,7 +858,7 @@ extension LayoutConstraintable { return leadingPinned && trailingPinned } - public func isPinnedVerticallyToSuperview() -> Bool { + public func isPinnedEqualVertically() -> Bool { guard let view = self as? UIView, let superview = view.superview else { return false } let constraints = superview.constraints var topPinned = false