vds_ios/VDS/Components/DropdownSelect/DropdownSelect.swift

370 lines
13 KiB
Swift

//
// 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()
containerView.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()
containerView.accessibilityLabel = "Dropdown Select, \(accessibilityLabelText)"
containerView.accessibilityHint = isReadOnly || !isEnabled ? "" : "has popup, Double tap to open."
containerView.accessibilityValue = value
}
open override var accessibilityElements: [Any]? {
get {
var elements = [Any]()
elements.append(contentsOf: [titleLabel, containerView])
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: containerView)
}
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 {
dropdownField.becomeFirstResponder()
UIAccessibility.post(notification: .layoutChanged, argument: optionsPicker)
} 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)
}
}