vds_ios/VDS/Components/DropdownSelect/DropdownSelect.swift
Matt Bruce cd0b066701 refactored entryfield base
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-05-13 11:19:57 -05:00

406 lines
15 KiB
Swift

//
// 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 }
/// 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.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 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()
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
}
/// 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
secondaryStackView.removeFromSuperview()
helperLabel.removeFromSuperview()
super.updateHelperLabel()
//set the helper label position
if helperText != nil {
if helperTextPlacement == .right {
horizontalStackView.addArrangedSubview(secondaryStackView)
horizontalStackView.addArrangedSubview(helperLabel)
primaryStackView.addArrangedSubview(horizontalStackView)
} else {
bottomContainerStackView.addArrangedSubview(helperLabel)
primaryStackView.addArrangedSubview(secondaryStackView)
}
} else {
primaryStackView.addArrangedSubview(secondaryStackView)
}
//set the width constraints
let frameWidth = frame.size.width
let maxwidth = helperTextPlacement == .right ? (frameWidth - horizontalStackView.spacing) / 2 : frameWidth
if let width, width > minWidth && width < maxwidth {
widthConstraint?.constant = width
} else {
widthConstraint?.constant = maxwidth >= minWidth ? maxwidth : minWidth
}
}
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])
}
}