vds_ios/VDS/Components/DropdownSelect/DropdownSelect.swift

467 lines
18 KiB
Swift

//
// DropdownSelect.swift
// VDS
//
// Created by Kanamarlapudi, Vasavi on 28/03/24.
//
import Foundation
import UIKit
import VDSColorTokens
import VDSFormControlsTokens
import Combine
/// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection.
@objc(VDSDropdownSelect)
open class DropdownSelect: Control {
//--------------------------------------------------
// 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: - Public Properties
//--------------------------------------------------
/// Boolean value that determines if component should show the error state/error message.
open var error: Bool = false { didSet { setNeedsUpdate() }}
/// Message displayed when there is an error.
open var errorText: String? { didSet { setNeedsUpdate() }}
/// If provided, will be used as text for the helper label.
open var helperText: String? { didSet { setNeedsUpdate() }}
/// Used if the component is enabled or not.
open override var isEnabled: Bool { didSet { setNeedsUpdate() }}
/// If true, the label will be displayed inside the dropdown container. Otherwise, the label will be above the dropdown container like a normal text input.
open var inlineLabel: Bool = false { didSet { setNeedsUpdate() }}
/// If provided, will be used as context for the label on the input field.
open var label: String? { didSet { setNeedsUpdate() }}
/// Not allowed the user interaction to select/change input if it is true.
open var readOnly: Bool = false { didSet { setNeedsUpdate() }}
/// Used to show optional indicator for the label.
open var required: Bool = false { didSet { setNeedsUpdate() }}
/// Allows unique ID to be passed to the element.
open var selectId: String? { didSet { setNeedsUpdate() }}
/// Config object for tooltip option, is optional.
open var tooltipModel: Tooltip.TooltipModel? { didSet { setNeedsUpdate() } }
/// If provided, will render with trnasparent background.
open var transparentBackground: Bool = false { didSet { setNeedsUpdate() }}
/// Used to set width for the Dropdown Select.
open var width: CGFloat? { didSet { setNeedsUpdate() } }
/// Array of options to show
open var options: [DropdownOptionModel] = [] { didSet { setNeedsUpdate() }}
/// A callback when the selected option changes. Passes parameters (option).
open var onDropdownItemSelect: ((DropdownOptionModel) -> Void)?
/// Boolean value that determines if component should show the error state/error message. Functon receives the 'event' object on input change.
open var showError: Bool = false { didSet { setNeedsUpdate() }}
open override var state: UIControl.State {
get {
var state = super.state
if showError {
state.insert(.error)
}
return state
}
}
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
internal var minWidthDefault = 66.0
internal var minWidthInlineLabel = 102.0
var stackView: UIStackView = UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .vertical
}
private var eyebrowLabel = TrailingTooltipLabel().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.labelTextAlignment = .left
$0.labelTextStyle = .bodySmall
}
var container: UIView = UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
}
var containerStack: UIStackView = UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .horizontal
$0.spacing = VDSFormControls.spaceInset
}
private var dropdownField = UITextField().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.tintColor = UIColor.clear
$0.font = TextStyle.bodyLarge.font
}
private var inlineDisplayLabel = Label().with {
$0.textAlignment = .left
$0.textStyle = .boldBodyLarge
$0.sizeToFit()
}
private var selectedOptionLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textAlignment = .left
$0.textStyle = .bodyLarge
}
private var icon: Icon = Icon().with {
$0.size = .medium
$0.name = Icon.Name(name: "down-caret")
}
private var errorLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textAlignment = .left
$0.textStyle = .bodySmall
}
private var helperLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textAlignment = .left
$0.textStyle = .bodySmall
}
private var optionsPicker = UIPickerView()
//--------------------------------------------------
// MARK: - Constraints
//--------------------------------------------------
internal var widthConstraint: NSLayoutConstraint?
internal var inlineWidthConstraint: NSLayoutConstraint?
//--------------------------------------------------
// MARK: - Configuration Properties
//--------------------------------------------------
internal var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) }
/// Color configuration for error icon.
internal let primaryColorConfig = ViewColorConfiguration().with {
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false)
}
internal let secondaryColorConfig = ViewColorConfiguration().with {
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forDisabled: true)
$0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: false)
}
internal let iconColorConfig = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal)
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .error)
}
internal var backgroundColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal)
$0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error)
}
internal var containerBorderColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOnlight, forState: .normal)
$0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled)
$0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error)
$0.setSurfaceColors(VDSFormControlsColor.borderHoverOnlight, VDSFormControlsColor.borderHoverOndark, forState: .focused)
}
internal var errorBorderColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error)
$0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .focused)
}
internal var readOnlyBorderColorConfiguration = ControlColorConfiguration().with {
$0.setSurfaceColors(VDSFormControlsColor.borderReadonlyOnlight, VDSFormControlsColor.borderReadonlyOndark, forState: .normal)
}
//--------------------------------------------------
// MARK: - Overrides
//--------------------------------------------------
/// 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()
isAccessibilityElement = true
accessibilityLabel = "Dropdown Select"
// stackview
addSubview(stackView)
stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height).isActive = true
// container stack
container.addSubview(containerStack)
let spacing = VDSFormControls.spaceInset
containerStack.pinToSuperView(.init(top: spacing, left: spacing, bottom: spacing, right: spacing))
containerStack.addArrangedSubview(dropdownField)
containerStack.addArrangedSubview(inlineDisplayLabel)
containerStack.addArrangedSubview(selectedOptionLabel)
containerStack.addArrangedSubview(icon)
containerStack.setCustomSpacing(0, after: dropdownField)
containerStack.setCustomSpacing(VDSLayout.Spacing.space1X.value, after: inlineDisplayLabel)
containerStack.setCustomSpacing(VDSLayout.Spacing.space3X.value, after: selectedOptionLabel)
dropdownField.width(0)
// component stackview subviews
stackView.addArrangedSubview(eyebrowLabel)
stackView.addArrangedSubview(container)
stackView.addArrangedSubview(errorLabel)
stackView.addArrangedSubview(helperLabel)
stackView.setCustomSpacing(4, after: eyebrowLabel)
stackView.setCustomSpacing(8, after: container)
stackView.setCustomSpacing(8, after: errorLabel)
stackView.pinToSuperView()
inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(equalToConstant: 0)
inlineWidthConstraint?.isActive = true
widthConstraint = stackView.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidthDefault)
widthConstraint?.isActive = true
eyebrowLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable()
errorLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable()
helperLabel.textColorConfiguration = secondaryColorConfig.eraseToAnyColorable()
inlineDisplayLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable()
selectedOptionLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable()
icon.color = iconColorConfig.getColor(self)
optionsPicker.delegate = self
optionsPicker.dataSource = self
optionsPicker.isHidden = true
dropdownField.inputView = optionsPicker
dropdownField.inputAccessoryView = toolBarForPicker()
containerStack.publisher(for: UITapGestureRecognizer()).sink { [weak self] _ in
self?.launchPicker()
}.store(in: &subscribers)
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
container.backgroundColor = backgroundColorConfiguration.getColor(self)
container.layer.borderWidth = VDSFormControls.widthBorder
container.layer.cornerRadius = VDSFormControls.borderradius
container.layer.borderColor = readOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : (showError ? errorBorderColorConfiguration.getColor(self).cgColor : containerBorderColorConfiguration.getColor(self).cgColor)
dropdownField.isUserInteractionEnabled = readOnly ? false : true
updateTitleLabel()
updateInlineLabel()
updateErrorLabel()
updateHelperLabel()
if readOnly {
icon.name = nil
}
selectedOptionLabel.surface = surface
backgroundColor = surface.color
}
/// Used to update any Accessibility properties.
open override func updateAccessibility() {
super.updateAccessibility()
if showError {
setAccessibilityLabel(for: [eyebrowLabel, selectedOptionLabel, errorLabel, helperLabel])
} else {
setAccessibilityLabel(for: [eyebrowLabel, selectedOptionLabel, helperLabel])
}
}
/// Resets to default settings.
open override func reset() {
super.reset()
eyebrowLabel.reset()
inlineDisplayLabel.reset()
selectedOptionLabel.reset()
errorLabel.reset()
helperLabel.reset()
eyebrowLabel.labelTextStyle = .bodySmall
inlineDisplayLabel.textStyle = .boldBodyLarge
selectedOptionLabel.textStyle = .bodyLarge
errorLabel.textStyle = .bodySmall
helperLabel.textStyle = .bodySmall
tooltipModel = nil
label = nil
errorText = nil
error = false
isEnabled = false
readOnly = false
inlineLabel = false
helperText = nil
transparentBackground = false
required = false
options = []
}
//--------------------------------------------------
// MARK: - Public Methods
//--------------------------------------------------
open func updateTitleLabel() {
//update the local vars for the label since we no long have a model
var attributes: [any LabelAttributeModel] = []
var updatedLabelText = label
updatedLabelText = inlineLabel ? "" : updatedLabelText
if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") {
let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2,
length: 8,
color: secondaryColorConfig.getColor(self))
updatedLabelText = "\(oldText) Optional"
attributes.append(optionColorAttr)
}
if let tooltipModel {
attributes.append(TooltipLabelAttribute(surface: surface, model: tooltipModel, presenter: self))
}
eyebrowLabel.labelText = updatedLabelText
eyebrowLabel.labelAttributes = attributes
eyebrowLabel.tooltipModel = tooltipModel
eyebrowLabel.surface = surface
eyebrowLabel.isEnabled = isEnabled
}
open func updateInlineLabel() {
/// inline label text and selected option text separated by ':'
if let label, !label.isEmpty {
inlineDisplayLabel.text = inlineLabel ? (label + ":") : ""
} else {
inlineDisplayLabel.text = inlineLabel ? label : ""
}
inlineDisplayLabel.surface = surface
/// Minimum width with inline text as per design
inlineWidthConstraint?.constant = inlineDisplayLabel.intrinsicContentSize.width
inlineWidthConstraint?.isActive = !inlineLabel
widthConstraint?.constant = inlineLabel ? minWidthInlineLabel : minWidthDefault
widthConstraint?.isActive = true
}
open func updateSelectedOptionLabel(text: String? = nil) {
selectedOptionLabel.text = text ?? ""
}
open func updateErrorLabel() {
if showError, let errorText {
errorLabel.text = errorText
errorLabel.surface = surface
errorLabel.isEnabled = isEnabled
errorLabel.isHidden = false
icon.name = .error
icon.size = .medium
icon.color = .black
icon.surface = surface
} else {
icon.name = Icon.Name(name: "down-caret")
icon.surface = surface
errorLabel.isHidden = true
}
}
open func updateHelperLabel() {
if let helperText {
helperLabel.text = helperText
helperLabel.surface = surface
helperLabel.isEnabled = isEnabled
helperLabel.isHidden = false
} else {
helperLabel.isHidden = true
}
}
@objc open func pickerDoneClicked() {
dropdownField.resignFirstResponder()
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
private func toolBarForPicker() -> UIToolbar {
let inputToolbar: UIToolbar = UIToolbar()
inputToolbar.barStyle = .default
inputToolbar.isTranslucent = true
// add a done button to the toolbar
inputToolbar.items=[
UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, target: self, action: nil),
UIBarButtonItem(title: "Done", style: UIBarButtonItem.Style.done, target: self, action: #selector(pickerDoneClicked))
]
inputToolbar.sizeToFit()
return inputToolbar
}
}
extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource, UITextFieldDelegate {
func launchPicker() {
if optionsPicker.isHidden {
dropdownField.becomeFirstResponder()
} else {
dropdownField.resignFirstResponder()
}
optionsPicker.isHidden = !optionsPicker.isHidden
}
//--------------------------------------------------
// MARK: - UIPickerView Delegate & Datasource
//--------------------------------------------------
public func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 1
}
public func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return options.count
}
public func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
guard options.count > row else { return nil }
return options[row].text
}
public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
guard options.count > row else { return }
updateSelectedOptionLabel(text: options[row].text)
self.onDropdownItemSelect?(options[row])
}
}