// // 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 } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Accepts a string or number value to control the width of input stepper. /// auto(default) - The control's width is determined by the combined width of the value, trailing text and padding /// Value - The control's width can be set to a fixed pixel. open var controlWidth: String? = "auto" { didSet { setNeedsUpdate() } } /// Accepts percentage value to controlWidth of input stepper. open var controlWidthPercentage: CGFloat? { didSet { if let percentage = controlWidthPercentage, percentage > 100 { controlWidthPercentage = 100 } updateControlWidthPercentage() setNeedsUpdate() } } /// Accepts percentage value to width of parent container. open var widthPercentage: CGFloat? { didSet { if let percentage = widthPercentage, percentage > 100 { widthPercentage = 100 } updatePercentageWidth() setNeedsUpdate() } } /// Default value of the input stepper, defaults to '0'. open var defaultValue:Int = 0 { didSet { 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 //-------------------------------------------------- /// 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 updateConstraintsToFieldStackView(flag: false) updateContainerView(flag: false) // Update label text, style, color, ande surface. textLabel.text = String(defaultValue) + " " + (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. setControlWidth(controlWidth) updateControlWidthPercentage() 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 = "auto" controlWidthPercentage = nil widthPercentage = nil defaultValue = 0 id = nil minValue = 0 maxValue = 99 trailingText = nil size = .large helperTextPlacement = .bottom } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- internal override func updateContainerWidth() { 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 = minValue, let _maxValue = maxValue { defaultValue = defaultValue > _maxValue ? _maxValue : defaultValue < _minValue ? _minValue : defaultValue } } internal func decrementButtonClick() { defaultValue = defaultValue - 1 checkDefaultValue() updateButtonStates() } internal func incrementButtonClick() { defaultValue = defaultValue + 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 = defaultValue > minValue ?? 0 ? true : false incrementButton.isEnabled = defaultValue < 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. internal func setControlWidth(_ text: String?) { if let text, text == "auto" { stepperWidthConstraint?.deactivate() } else if let controlWidth = Int(text ?? "") { // Set controlWidth provided which is either pixel or percentage let width = width ?? CGFloat(containerView.frame.size.width) updateStepperContainerWidth(controlWidth: CGFloat(controlWidth), width: width) } } // Handling the controlwidth without going beyond the width of the parent container. internal 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() } } private func updateControlWidthPercentage() { let superWidth = width ?? CGFloat(containerView.frame.size.width) // Set the inputStepper's controlWidth based on the controlWidth percentage received relative to its parentView's frame. if let controlWidthPercentage { controlWidth = String( Int( max(superWidth * ((controlWidthPercentage) / 100), minWidth))) setControlWidth(controlWidth) } } private func updatePercentageWidth() { 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 { width = max(superWidth * ((widthPercentage) / 100), minWidth) if controlWidthPercentage != nil { updateControlWidthPercentage() } } } }