// // InputStepper.swift // VDS // // Created by Kanamarlapudi, Vasavi on 24/06/24. // import Foundation import UIKit import VDSCoreTokens import Combine /// A stepper is a two-segment control that people use to increase or decrease an incremental value. @objc(VDSInputStepper) open class InputStepper: 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 size of Input Stepper. public enum Size: String, CaseIterable { case large, small } /// Enum used to describe the width of a fixed value or percentage of the input stepper control. public enum ControlWidth { case percentage(CGFloat) case value(CGFloat) } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// If there is a width that is larger than this size's minimumWidth, the input stepper will resize to this width. open var controlWidth: ControlWidth? { get { _controlWidth } set { if let newValue { switch newValue { case .percentage(let percentage): if percentage <= 100.0 { _controlWidth = newValue } case .value(let value): if value > 0 && value > containerSize.width { _controlWidth = newValue } } } else { _controlWidth = nil } setNeedsUpdate() } } /// Accepts percentage value to width of parent container. open var widthPercentage: CGFloat? { didSet { if let percentage = widthPercentage, percentage > 100 { widthPercentage = 100 } setNeedsUpdate() } } /// Allows an id to be passed to input stepper. open var id: Int? { didSet { setNeedsUpdate() } } /// Maximum value of the input stepper, defaults to '99'. open var maxValue: Int? = 99 { didSet { if let value = maxValue, value > 99 || value < 0 { maxValue = 99 } setNeedsUpdate() } } /// Minimum value of the input stepper, defaults to '0'. open var minValue: Int? = 0 { didSet { if let value = minValue, value < 0 && value >= 99 { minValue = 0 } setNeedsUpdate() } } /// The size of the input stepper. Defaults to 'large'. open var size: Size = .large { didSet { updateStepperContainerViewSize() setNeedsUpdate() } } /// Accepts any text or character to appear next to input stepper value. open var trailingText: String? { didSet { setNeedsUpdate() } } /// Value for the textField open override var value: String? { return nil } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var _controlWidth: ControlWidth? = nil /// Default Int value of the input stepper, defaults to '0'. internal var defaultIntValue: Int { guard let intValue = defaultValue as? Int else { return 0 } return intValue } /// This is the view that will be wrapped with the border for userInteraction. /// The only subview of this view is the stepperStackView. internal var stepperContainerView = View().with { $0.isAccessibilityElement = true $0.accessibilityLabel = "Input Stepper" } internal var stepperStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.alignment = .fill } internal var decrementButton = ButtonIcon().with { $0.kind = .ghost $0.iconName = Icon.Name(name: "minus") $0.iconOffset = .init(x: -2, y: 0) $0.customContainerSize = 32 $0.icon.customSize = 16 $0.backgroundColor = .clear } internal var incrementButton = ButtonIcon().with { $0.kind = .ghost $0.iconName = Icon.Name(name: "plus") $0.iconOffset = .init(x: 2, y: 0) $0.customContainerSize = 32 $0.icon.customSize = 16 $0.backgroundColor = .clear } internal var textLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .boldBodyLarge $0.numberOfLines = 1 $0.lineBreakMode = .byTruncatingTail $0.textAlignment = .center } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var stepperWidthConstraint: NSLayoutConstraint? internal var stepperHeightConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- internal override var containerSize: CGSize { CGSize(width: size == .large ? largeMinWidth : smallMinWidth, height: size == .large ? largeMinHeight : smallMinHeight) } internal var largeMinWidth = 121 internal var smallMinWidth = 90 internal var largeMinHeight = 44 internal var smallMinHeight = 32 internal let labelColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) internal let labelDisabledColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark) //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func initialSetup() { super.initialSetup() } /// 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() // Set initial states containerView.isEnabled = false statusIcon.isHidden = true // Add listeners decrementButton.onClick = { _ in self.decrementButtonClick() } incrementButton.onClick = { _ in self.incrementButtonClick() } // setting color config textLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() } open override func getFieldContainer() -> UIView { stepperStackView.addArrangedSubview(decrementButton) stepperStackView.addArrangedSubview(textLabel) stepperStackView.addArrangedSubview(incrementButton) // Set space between decrement button, label, and increment button relative to input Stepper size. let space = size == .large ? VDSLayout.space3X : VDSLayout.space2X stepperStackView.setCustomSpacing(space, after: decrementButton) stepperStackView.setCustomSpacing(space, after: textLabel) // Update Edge insets relative to input Stepper size. stepperStackView.pinToSuperView(.uniform(size == .large ? 6.0 : VDSLayout.space1X)) // stepperContainerView for controls in EntryFieldBase.controlContainerView stepperContainerView.addSubview(stepperStackView) stepperWidthConstraint = stepperContainerView.widthAnchor.constraint(equalToConstant: containerSize.width) stepperWidthConstraint?.deactivate() stepperHeightConstraint = stepperContainerView.heightAnchor.constraint(equalToConstant: containerSize.height) stepperHeightConstraint?.deactivate() return stepperContainerView } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() statusIcon.isHidden = true // Update label text, style, color, ande surface. textLabel.text = String(defaultIntValue) + " " + (trailingText ?? "") textLabel.textStyle = size == .large ? .boldBodyLarge : .boldBodySmall textLabel.textColorConfiguration = !isEnabled ? labelDisabledColorConfiguration.eraseToAnyColorable() : labelColorConfiguration.eraseToAnyColorable() textLabel.surface = surface // Update increment and decrement button. updateButtonStates() // Update stepper container border and corner radius. updateContainerWidthWithPercentage() updateInputStepperWidth() updateStepperView() setNeedsLayout() } open override var accessibilityElements: [Any]? { get { var elements = [Any]() if !isReadOnly || isEnabled { elements.append(contentsOf: [titleLabel, containerView, decrementButton, textLabel, incrementButton]) } else { elements.append(contentsOf: [titleLabel, containerView, textLabel]) } if showError { if let errorText, !errorText.isEmpty { elements.append(errorLabel) } } if let helperText, !helperText.isEmpty { elements.append(helperLabel) } return elements } set { super.accessibilityElements = newValue } } /// Resets to default settings. open override func reset() { super.reset() textLabel.reset() textLabel.textStyle = .boldBodyLarge controlWidth = nil widthPercentage = nil id = nil minValue = 0 maxValue = 99 trailingText = nil size = .large helperTextPlacement = .bottom } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- internal override func updateContainerWidth() { stepperWidthConstraint?.deactivate() widthConstraint?.deactivate() trailingLessThanEqualsConstraint?.deactivate() trailingEqualsConstraint?.deactivate() if let width, width >= minWidth, width <= maxWidth { widthConstraint?.constant = width widthConstraint?.activate() trailingLessThanEqualsConstraint?.activate() } else if let parentWidth = width, parentWidth >= maxWidth { width = maxWidth widthConstraint?.constant = maxWidth widthConstraint?.activate() trailingLessThanEqualsConstraint?.activate() } else if let parentWidth = width, parentWidth <= minWidth { width = minWidth widthConstraint?.constant = minWidth widthConstraint?.activate() trailingLessThanEqualsConstraint?.activate() } else { trailingEqualsConstraint?.activate() } } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- internal func checkDefaultValue() { if let minValue, let maxValue { defaultValue = defaultIntValue > maxValue ? maxValue : defaultIntValue < minValue ? minValue : defaultIntValue } } internal func decrementButtonClick() { defaultValue = defaultIntValue - 1 checkDefaultValue() updateButtonStates() } internal func incrementButtonClick() { defaultValue = defaultIntValue + 1 checkDefaultValue() updateButtonStates() } internal func updateButtonStates() { decrementButton.customContainerSize = size == .large ? 32 : 24 incrementButton.customContainerSize = size == .large ? 32 : 24 decrementButton.surface = surface incrementButton.surface = surface if isReadOnly || !isEnabled { decrementButton.isEnabled = false incrementButton.isEnabled = false } else { decrementButton.isEnabled = defaultIntValue > minValue ?? 0 ? true : false incrementButton.isEnabled = defaultIntValue < maxValue ?? 99 ? true : false } } // Update edge insets and height when size changes. internal func updateStepperContainerViewSize() { updateButtonStates() // Update Edge insets if size changes applied. stepperStackView.removeFromSuperview() stepperContainerView.addSubview(stepperStackView) stepperStackView.pinToSuperView(.uniform(size == .large ? 6.0 : VDSLayout.space1X)) // Update height if size changes applied. stepperHeightConstraint?.deactivate() stepperHeightConstraint = stepperContainerView.heightAnchor.constraint(equalToConstant: containerSize.height) stepperHeightConstraint?.activate() } // Set control width to input stepper. private func updateInputStepperWidth() { guard let controlWidth else { return } switch controlWidth { case .percentage(let percentage): // Set the inputStepper's controlWidth based on percentage received relative to its parentView's frame. let superWidth = width ?? CGFloat(containerView.frame.size.width) let value = max(superWidth * ((percentage) / 100), minWidth) updateStepperContainerWidth(controlWidth: value, width: superWidth) case .value(let value): let superWidth = width ?? CGFloat(containerView.frame.size.width) updateStepperContainerWidth(controlWidth: value, width: superWidth) } } // Handling the controlwidth without exceeding the width of the parent container. private func updateStepperContainerWidth(controlWidth: CGFloat, width: CGFloat) { if controlWidth >= containerSize.width && controlWidth <= width { stepperWidthConstraint?.deactivate() stepperWidthConstraint?.constant = controlWidth stepperWidthConstraint?.activate() } else if controlWidth >= width { stepperWidthConstraint?.deactivate() stepperWidthConstraint?.constant = width stepperWidthConstraint?.activate() } } // Update the container view width based on the percentage received. private func updateContainerWidthWithPercentage() { guard let superWidth = superview?.frame.width else { return } // Set width of Parent container based on width perecentage received relative to its superview frame. if let widthPercentage { // test value vs minimum width and take the greater value width = max(superWidth * (widthPercentage / 100), minWidth) updateContainerWidth() } } // Add border and update constratints to stepper view. private func updateStepperView() { fieldStackView.removeConstraints() fieldStackView.pinTop().pinLeading().pinBottom().pinTrailingLessThanOrEqualTo() containerView.backgroundColor = .clear containerView.layer.borderColor = nil containerView.layer.borderWidth = 0 containerView.layer.cornerRadius = 0 fieldStackView.backgroundColor = containerBackgroundColor fieldStackView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor fieldStackView.layer.borderWidth = VDSFormControls.borderWidth fieldStackView.layer.cornerRadius = containerView.frame.size.height / 2 } }