// // 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 $0.lineBreakMode = .byCharWrapping } 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 stackView.backgroundColor = transparentBackground ? .clear : surface.color 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]) } }