424 lines
16 KiB
Swift
424 lines
16 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
//--------------------------------------------------
|
|
/// 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.
|
|
setControlWidth(controlWidth)
|
|
updateControlWidthPercentage()
|
|
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 = "auto"
|
|
controlWidthPercentage = nil
|
|
widthPercentage = nil
|
|
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, 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 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.
|
|
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()
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|