// // 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 showError: 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() }} /// If true, the label will be displayed inside the dropdown containerView. Otherwise, the label will be above the dropdown containerView like a normal text input. open var showInlineLabel: Bool = false { didSet { setNeedsUpdate() }} /// If provided, will be used as context for the label on the input field. open var labelText: 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: Int? { 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)? 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 } var containerView: UIView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } var containerStack: UIStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.spacing = VDSFormControls.spaceInset } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var titleLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textAlignment = .left $0.textStyle = .bodySmall } open var inlineDisplayLabel = Label().with { $0.textAlignment = .left $0.textStyle = .boldBodyLarge $0.lineBreakMode = .byCharWrapping $0.sizeToFit() } open var selectedOptionLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textAlignment = .left $0.textStyle = .bodyLarge $0.lineBreakMode = .byCharWrapping } open var icon: Icon = Icon().with { $0.size = .medium $0.name = Icon.Name(name: "down-caret") } open var errorLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textAlignment = .left $0.textStyle = .bodySmall } open var helperLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textAlignment = .left $0.textStyle = .bodySmall } open var dropdownField = UITextField().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.tintColor = UIColor.clear $0.font = TextStyle.bodyLarge.font } open var optionsPicker = UIPickerView() //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var inlineWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- internal var containerSize: CGSize { CGSize(width: showInlineLabel ? minWidthInlineLabel : width ?? minWidthDefault, height: 44) } 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 borderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOnlight, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.disabled,.error]) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) $0.setSurfaceColors(VDSFormControlsColor.borderHoverOnlight, VDSFormControlsColor.borderHoverOndark, 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.pinToSuperView() stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: containerSize.height).isActive = true // containerView stack containerView.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) inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 0) inlineWidthConstraint?.isActive = true // component stackview subviews stackView.addArrangedSubview(titleLabel) stackView.addArrangedSubview(containerView) stackView.addArrangedSubview(errorLabel) stackView.addArrangedSubview(helperLabel) stackView.setCustomSpacing(4, after: titleLabel) stackView.setCustomSpacing(8, after: containerView) stackView.setCustomSpacing(8, after: errorLabel) // setting color config titleLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() errorLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() helperLabel.textColorConfiguration = secondaryColorConfig.eraseToAnyColorable() inlineDisplayLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() selectedOptionLabel.textColorConfiguration = primaryColorConfig.eraseToAnyColorable() icon.color = iconColorConfig.getColor(self) // Options PickerView 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() { containerView.backgroundColor = backgroundColorConfiguration.getColor(self) containerView.layer.borderWidth = VDSFormControls.widthBorder containerView.layer.cornerRadius = VDSFormControls.borderradius containerView.layer.borderColor = readOnly ? readOnlyBorderColorConfiguration.getColor(self).cgColor : borderColorConfiguration.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 selectedOptionLabel.isEnabled = isEnabled backgroundColor = surface.color } /// Resets to default settings. open override func reset() { super.reset() titleLabel.reset() inlineDisplayLabel.reset() selectedOptionLabel.reset() errorLabel.reset() helperLabel.reset() titleLabel.textStyle = .bodySmall inlineDisplayLabel.textStyle = .boldBodyLarge selectedOptionLabel.textStyle = .bodyLarge errorLabel.textStyle = .bodySmall helperLabel.textStyle = .bodySmall tooltipModel = nil labelText = nil errorText = nil showError = false isEnabled = false readOnly = false showInlineLabel = false helperText = nil transparentBackground = false required = false options = [] selectId = 0 } //-------------------------------------------------- // 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 = labelText updatedLabelText = showInlineLabel ? "" : updatedLabelText if let oldText = updatedLabelText, !required, !oldText.hasSuffix("Optional") { let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, length: 8, color: secondaryColorConfig.getColor(self)) updatedLabelText = showInlineLabel ? "Optional" : "\(oldText) Optional" attributes.append(optionColorAttr) } if let tooltipModel { attributes.append(TooltipLabelAttribute(surface: surface, model: tooltipModel, presenter: self)) } titleLabel.text = updatedLabelText titleLabel.attributes = attributes titleLabel.surface = surface titleLabel.isEnabled = isEnabled } open func updateInlineLabel() { inlineWidthConstraint?.isActive = false /// inline label text and selected option text separated by ':' if let labelText, !labelText.isEmpty { inlineDisplayLabel.text = showInlineLabel ? (labelText + ":") : "" } else { inlineDisplayLabel.text = showInlineLabel ? labelText : "" } inlineDisplayLabel.surface = surface inlineDisplayLabel.isEnabled = isEnabled /// Update width as per updated text size inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(equalToConstant: inlineDisplayLabel.intrinsicContentSize.width) inlineWidthConstraint?.isActive = true if let selectId, selectId < options.count { updateSelectedOptionLabel(text: options[selectId].text) } } 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.surface = surface } else { icon.name = .downCaret icon.surface = surface errorLabel.isHidden = true } icon.color = iconColorConfig.getColor(self) } 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() { optionsPicker.isHidden = true 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 } selectId = row updateSelectedOptionLabel(text: options[row].text) self.onDropdownItemSelect?(options[row]) } }