vds_ios/VDS/Components/InputStepper/InputStepper.swift
Matt Bruce 6c4fb1c5ba moved code up to 1 point rather than duplicating
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-08-07 16:42:19 -05:00

423 lines
15 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.'
@objcMembers
@objc(VDSInputStepper)
open class InputStepper: EntryFieldBase<Int> {
//--------------------------------------------------
// 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
//--------------------------------------------------
/// 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() {
defer {
fieldStackView.layer.cornerRadius = containerSize.height / 2
}
//we are not calling super here since
//we are changing how the widths are getting calculated
//now by including a percentage.
stepperWidthConstraint?.deactivate()
widthConstraint?.deactivate()
trailingLessThanEqualsConstraint?.deactivate()
trailingEqualsConstraint?.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()
trailingLessThanEqualsConstraint?.activate()
} else {
trailingEqualsConstraint?.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
}
}
}