// // DropdownSelect.swift // VDS // // Created by Kanamarlapudi, Vasavi on 28/03/24. // import Foundation import UIKit import VDSTokens 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: 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: - Public Properties //-------------------------------------------------- /// Override UIControl state to add the .error state if showSuccess is true and if showError is true. open override var state: UIControl.State { get { var state = super.state if dropdownField.isFirstResponder { state.insert(.focused) } return state } } /// 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() }} /// Allows unique ID to be passed to the element. open var selectId: Int? { didSet { setNeedsUpdate() }} /// Current SelectedItem Value open override var value: String? { selectedItem?.value } /// Current SelectedItem open var selectedItem: DropdownOptionModel? { guard let selectId else { return nil } return options[selectId] } /// Array of options to show open var options: [DropdownOptionModel] = [] { didSet { setNeedsUpdate() }} /// A callback when the selected option changes. Passes parameters (option). open var onItemSelected: ((Int, DropdownOptionModel) -> Void)? //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var minWidthDefault = 66.0 internal var minWidthInlineLabel = 102.0 internal var minWidth: CGFloat { showInlineLabel ? minWidthInlineLabel : minWidthDefault } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var inlineDisplayLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textAlignment = .left $0.textStyle = .boldBodyLarge $0.lineBreakMode = .byCharWrapping $0.sizeToFit() } open var selectedOptionLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textAlignment = .left $0.textStyle = .bodyLarge $0.lineBreakMode = .byCharWrapping } open var dropdownField = UITextField().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.tintColor = UIColor.clear $0.font = TextStyle.bodyLarge.font } /// Determines the placement of the helper text. open var helperTextPlacement: HelperTextPlacement = .bottom { didSet { setNeedsUpdate() } } open var optionsPicker = UIPickerView() //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var maxWidthConstraint: NSLayoutConstraint? internal var minWidthConstraint: NSLayoutConstraint? internal var inlineWidthConstraint: NSLayoutConstraint? internal var titleLabelWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- internal override var containerSize: CGSize { .init(width: minWidthDefault, height: 44) } //-------------------------------------------------- // 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() minWidthConstraint = containerView.widthGreaterThanEqualTo(constant: containerSize.width) maxWidthConstraint = containerView.width(constant: containerSize.width) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabelWidthConstraint = titleLabel.width(constant: 0) fieldStackView.isAccessibilityElement = true fieldStackView.accessibilityLabel = "Dropdown Select" inlineDisplayLabel.isAccessibilityElement = true dropdownField.width(0) inlineWidthConstraint = inlineDisplayLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 0) inlineWidthConstraint?.isActive = true // setting color config inlineDisplayLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() selectedOptionLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() // Options PickerView optionsPicker.delegate = self optionsPicker.dataSource = self optionsPicker.isHidden = true dropdownField.inputView = optionsPicker dropdownField.inputAccessoryView = { let inputToolbar = UIToolbar().with { $0.barStyle = .default $0.isTranslucent = true $0.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 }() // tap gesture fieldStackView .publisher(for: UITapGestureRecognizer()) .sink { [weak self] _ in self?.launchPicker() } .store(in: &subscribers) } open override func getFieldContainer() -> UIView { let controlStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill $0.spacing = VDSFormControls.spaceInset } controlStackView.addArrangedSubview(dropdownField) controlStackView.addArrangedSubview(inlineDisplayLabel) controlStackView.addArrangedSubview(selectedOptionLabel) controlStackView.setCustomSpacing(0, after: dropdownField) controlStackView.setCustomSpacing(VDSLayout.space1X, after: inlineDisplayLabel) controlStackView.setCustomSpacing(VDSLayout.space3X, after: selectedOptionLabel) return controlStackView } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateInlineLabel() dropdownField.isUserInteractionEnabled = isReadOnly ? false : true selectedOptionLabel.surface = surface selectedOptionLabel.isEnabled = isEnabled //set the width constraints if let width, width > minWidth { widthConstraint?.constant = width widthConstraint?.isActive = true minWidthConstraint?.isActive = false maxWidthConstraint?.isActive = false } else { minWidthConstraint?.constant = minWidth maxWidthConstraint?.constant = frame.width widthConstraint?.isActive = false minWidthConstraint?.isActive = true maxWidthConstraint?.isActive = true } } /// Resets to default settings. open override func reset() { super.reset() inlineDisplayLabel.textStyle = .boldBodyLarge selectedOptionLabel.textStyle = .bodyLarge showInlineLabel = false options = [] selectId = nil } //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- open override 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, !isRequired, !oldText.hasSuffix("Optional") { let optionColorAttr = ColorLabelAttribute(location: oldText.count + 2, length: 8, color: secondaryColorConfiguration.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(option: options[selectId]) } } open func updateSelectedOptionLabel(option: DropdownOptionModel? = nil) { selectedOptionLabel.text = option?.text ?? "" } open override func updateErrorLabel() { super.updateErrorLabel() if !showError && !hasInternalError { statusIcon.name = .downCaret } statusIcon.surface = surface statusIcon.isHidden = isReadOnly ? true : false statusIcon.color = iconColorConfiguration.getColor(self) } open override func updateHelperLabel(){ //remove first helperLabel.removeFromSuperview() super.updateHelperLabel() //set the helper label position if helperText != nil { if helperTextPlacement == .right { middleStackView.spacing = VDSLayout.space3X middleStackView.distribution = .fillEqually middleStackView.addArrangedSubview(helperLabel) } else { middleStackView.spacing = 0 middleStackView.distribution = .fill bottomContainerStackView.addArrangedSubview(helperLabel) } } } open override func updateAccessibility() { super.updateAccessibility() let selectedOption = selectedOptionLabel.text ?? "" fieldStackView.accessibilityLabel = "Dropdown Select, \(selectedOption) \(isReadOnly ? ", read only" : "")" fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." } open override var accessibilityElements: [Any]? { get { var elements = [Any]() elements.append(contentsOf: [titleLabel, fieldStackView]) if showError { elements.append(statusIcon) if let errorText, !errorText.isEmpty { elements.append(errorLabel) } } if let helperText, !helperText.isEmpty { elements.append(helperLabel) } return elements } set { super.accessibilityElements = newValue } } @objc open func pickerDoneClicked() { optionsPicker.isHidden = true dropdownField.resignFirstResponder() setNeedsUpdate() UIAccessibility.post(notification: .layoutChanged, argument: fieldStackView) } open override func layoutSubviews() { super.layoutSubviews() titleLabelWidthConstraint?.constant = containerView.frame.width titleLabelWidthConstraint?.isActive = helperTextPlacement == .right } open override var canBecomeFirstResponder: Bool { true } open override func resignFirstResponder() -> Bool { if dropdownField.isFirstResponder { dropdownField.resignFirstResponder() } return super.resignFirstResponder() } } //-------------------------------------------------- // MARK: - UIPickerView Delegate & Datasource //-------------------------------------------------- extension DropdownSelect: UIPickerViewDelegate, UIPickerViewDataSource { internal func launchPicker() { if optionsPicker.isHidden { UIAccessibility.post(notification: .layoutChanged, argument: optionsPicker) dropdownField.becomeFirstResponder() } else { dropdownField.resignFirstResponder() } optionsPicker.isHidden = !optionsPicker.isHidden setNeedsUpdate() } 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(option: options[row]) sendActions(for: .valueChanged) self.onItemSelected?(row, options[row]) } }