// // 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.' @objcMembers @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 var minWidth: CGFloat { self == .large ? 121 : 90 } var minHeight: CGFloat { self == .large ? 44 : 32 } var space: CGFloat { self == .large ? VDSLayout.space3X : VDSLayout.space2X } var padding: CGFloat { self == .large ? 6.0 : VDSLayout.space1X } var buttonContainerSize: Int { self == .large ? 32 : 24 } var textStyle: TextStyle { self == .large ? .boldBodyLarge : .boldBodySmall } } /// 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 //-------------------------------------------------- open override var children: [any ViewProtocol] { var current = super.children current.append(contentsOf: [decrementButton, incrementButton, textLabel]) return current } /// 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() } } private var _defaultValue: Int = 0 open override var defaultValue: Int? { get { _defaultValue } set { if let newValue { _defaultValue = newValue > maxValue ? maxValue : newValue < minValue ? minValue : newValue } else { _defaultValue = 0 } setNeedsUpdate() } } open override var value: Int? { return defaultValue } /// Maximum value of the input stepper, defaults to '99'. lazy open var maxValue: Int = { _defaultMaxValue }() { didSet { if maxValue > _defaultMaxValue || maxValue < _defaultMinValue && maxValue > minValue { maxValue = _defaultMaxValue } setNeedsUpdate() } } /// Minimum value of the input stepper, defaults to '0'. lazy open var minValue: Int = { _defaultMinValue }() { didSet { if minValue < _defaultMinValue && minValue >= _defaultMaxValue && minValue < maxValue { minValue = _defaultMinValue } setNeedsUpdate() } } /// The size of the input stepper. Defaults to 'large'. open var size: Size = .large { didSet { setNeedsUpdate() } } /// Accepts any text or character to appear next to input stepper value. open var trailingText: String? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var _controlWidth: ControlWidth? = nil private var _defaultMinValue: Int = 0 private var _defaultMaxValue: Int = 99 /// 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.minWidth, height: size.minHeight) } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- /// 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 defaultValue = 0 containerView.isEnabled = false statusIcon.isHidden = true //override the default settings since the containerView //fieldStackView relationShip needs to be updated //we are not applying spacing either in the edges since this //is the view that will take place of the containerView for the //design of the original "containerView". This will get refactored at //some point. fieldStackView.applyAlignment(.leading) // 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. stepperStackView.setCustomSpacing(size.space, after: decrementButton) stepperStackView.setCustomSpacing(size.space, after: textLabel) // stepperContainerView for controls in EntryFieldBase.controlContainerView stepperContainerView.addSubview(stepperStackView) // Update Edge insets relative to input Stepper size. stepperStackView.pinToSuperView(.uniform(size.padding)) stepperWidthConstraint = stepperContainerView.width(constant: containerSize.width, priority: .required) stepperHeightConstraint = stepperContainerView.height(constant: containerSize.height, priority: .required) 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 textLabel.isEnabled = isEnabled textLabel.surface = surface textLabel.text = "\(_defaultValue) " + (trailingText ?? "") textLabel.textStyle = size.textStyle updateButtonStates() } 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() controlWidth = nil widthPercentage = nil defaultValue = 0 minValue = _defaultMinValue maxValue = _defaultMaxValue trailingText = nil size = .large } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- override func updateContainerView() { //we are not calling super since we //are using the fieldStackView as the "containerView" //which will get the look/feel of the containerView. //this will get refactored in the future. fieldStackView.backgroundColor = containerBackgroundColor fieldStackView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor fieldStackView.layer.borderWidth = VDSFormControls.borderWidth } internal override func updateContainerWidth() { //we are not calling super here since //we are changing how the widths are getting calculated //now by including a percentage. defer { fieldStackView.layer.cornerRadius = containerSize.height / 2 } stepperWidthConstraint?.deactivate() widthConstraint?.deactivate() var widthConstraintConstant: CGFloat? if let widthPercentage, let superWidth = horizontalPinnedWidth() { // test value vs minimum width and take the greater value widthConstraintConstant = max(superWidth * (widthPercentage / 100), minWidth) } else if let width, width >= minWidth, width <= maxWidth { widthConstraintConstant = width } else if let parentWidth = width, parentWidth >= maxWidth { widthConstraintConstant = maxWidth } else if let parentWidth = width, parentWidth <= minWidth { widthConstraintConstant = minWidth } if let widthConstraintConstant { widthConstraint?.constant = widthConstraintConstant widthConstraint?.activate() } // Update Edge insets if size changes applied. stepperStackView.applyAlignment(.fill, edges: .uniform(size.padding)) // Update height if size changes applied. stepperHeightConstraint?.constant = containerSize.height //update the stepper's widthConstraint if //controlWidth was set guard let controlWidth else { return } // Set the inputStepper's controlWidth based on percentage received relative to its parentView's frame. let containerWidth: CGFloat = widthConstraintConstant ?? containerView.frame.size.width var stepperWidthConstant: CGFloat? var stepperWidth: CGFloat switch controlWidth { case .percentage(let percentage): stepperWidth = max(containerWidth * ((percentage) / 100), minWidth) case .value(let value): stepperWidth = value } //get the value of the stepperWidthConstant if stepperWidth >= containerSize.width && stepperWidth <= containerWidth { stepperWidthConstant = stepperWidth } else if stepperWidth >= containerWidth { stepperWidthConstant = containerWidth } if let stepperWidthConstant { stepperWidthConstraint?.constant = stepperWidthConstant stepperWidthConstraint?.activate() } } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- internal func decrementButtonClick() { if _defaultValue > minValue { defaultValue = _defaultValue - 1 sendActions(for: .valueChanged) } } internal func incrementButtonClick() { if _defaultValue < maxValue { defaultValue = _defaultValue + 1 sendActions(for: .valueChanged) } } internal func updateButtonStates() { decrementButton.customContainerSize = size.buttonContainerSize incrementButton.customContainerSize = size.buttonContainerSize decrementButton.surface = surface incrementButton.surface = surface if isReadOnly || !isEnabled { decrementButton.isEnabled = false incrementButton.isEnabled = false } else { decrementButton.isEnabled = (defaultValue ?? _defaultMaxValue ) > minValue ? true : false incrementButton.isEnabled = (defaultValue ?? _defaultMinValue) < maxValue ? true : false } } }