// // 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 or percentage value. open var controlWidth: String? = "auto" { didSet { 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? { get { return _maxValue } set { if let newValue, newValue <= 99 && newValue > 0 { _maxValue = newValue } else { _maxValue = 99 } setNeedsUpdate() } } /// Minimum value of the input stepper, defaults to '0'. open var minValue: Int? { get { return _minValue } set { if let newValue, newValue >= 0 && newValue < 99 { _minValue = newValue } else { _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() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var _maxValue: Int = 99 internal var _minValue: Int = 0 /// 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 } 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 } //-------------------------------------------------- // 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() // accessibility isAccessibilityElement = false accessibilityLabel = "Input Stepper" // 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) // 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) updateContainerView(flag: false) } /// Resets to default settings. open override func reset() { super.reset() textLabel.reset() textLabel.textStyle = .boldBodyLarge textLabel.text = "" controlWidth = nil minValue = nil maxValue = nil trailingText = nil helperTextPlacement = .bottom } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- internal func checkDefaultValue() { 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 ? true : false incrementButton.isEnabled = defaultValue < _maxValue ? 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() } } }