// // DropdownSelect.swift // VDS // // Created by Kanamarlapudi, Vasavi on 28/03/24. // import Foundation import UIKit import VDSCoreTokens 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() }} //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var minWidthDefault = 66.0 internal var minWidthInlineLabel = 102.0 internal override var minWidth: CGFloat { showInlineLabel ? minWidthInlineLabel : minWidthDefault } internal override var maxWidth: CGFloat { let frameWidth = frame.size.width return helperTextPlacement == .right ? (frameWidth - horizontalStackView.spacing) / 2 : frameWidth } /// The is used for the for adding the helperLabel to the right of the containerView. internal var horizontalStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fillEqually $0.spacing = VDSLayout.space3X $0.alignment = .top } }() //-------------------------------------------------- // 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.numberOfLines = 1 $0.sizeToFit() } open var selectedOptionLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.setContentCompressionResistancePriority(.required, for: .horizontal) $0.textAlignment = .left $0.textStyle = .bodyLarge $0.numberOfLines = 1 } 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 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() fieldStackView.isAccessibilityElement = true 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 accessView = UIView(frame: .init(origin: .zero, size: .init(width: UIScreen.main.bounds.width, height: containerSize.height))) accessView.backgroundColor = .white accessView.addBorder(side: .top, width: 1, color: .lightGray) let done = UIButton(type: .system) done.setTitle("Done", for: .normal) done.translatesAutoresizingMaskIntoConstraints = false done.addTarget(self, action: #selector(pickerDoneClicked), for: .touchUpInside) accessView.addSubview(done) done.pinCenterY() .pinTrailing(16) return accessView }() // tap gesture containerView .publisher(for: UITapGestureRecognizer()) .sink { [weak self] _ in self?.launchPicker() } .store(in: &subscribers) containerView.height(44) } 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 } /// 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 + 1, 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 || !optionsPicker.isHidden { statusIcon.name = .downCaret } statusIcon.surface = surface statusIcon.isHidden = isReadOnly ? true : false statusIcon.color = iconColorConfiguration.getColor(self) } open override func updateAccessibility() { super.updateAccessibility() fieldStackView.accessibilityLabel = "Dropdown Select, \(accessibilityLabelText)" fieldStackView.accessibilityHint = isReadOnly || !isEnabled ? "" : "Double tap to open." fieldStackView.accessibilityValue = value } 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 var canBecomeFirstResponder: Bool { return dropdownField.canBecomeFirstResponder } open override func becomeFirstResponder() -> Bool { return dropdownField.becomeFirstResponder() } open override var canResignFirstResponder: Bool { return dropdownField.canResignFirstResponder } open override func resignFirstResponder() -> Bool { return dropdownField.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 updateContainerView() updateErrorLabel() } 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) } }