// // SelectorItemBase.swift // VDS // // Created by Matt Bruce on 6/5/23. // import Foundation import UIKit import Combine import VDSCoreTokens /// Base Class used to build out a SelectorControlable control. open class SelectorItemBase: Control, Errorable, Changeable, Groupable { //-------------------------------------------------- // 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: - Private Properties //-------------------------------------------------- private var shouldShowError: Bool { guard showError && isEnabled && errorText?.isEmpty == false else { return false } return true } private var shouldShowLabels: Bool { guard labelText?.isEmpty == false || childText?.isEmpty == false || labelAttributedText?.string.isEmpty == false || childAttributedText?.string.isEmpty == false else { return false } return true } private var mainStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .vertical } private var selectorStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .horizontal } private var selectorLabelStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var onChangeSubscriber: AnyCancellable? /// Label used to render labelText. open var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .boldBodyLarge } /// Label used to render childText. open var childLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodyLarge } /// Label used to render errorText. open var errorLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodyMedium } /// Generic Object used to allow the user to 'Select'. open var selectorView = Selector() open override var isSelected: Bool { didSet { setNeedsUpdate() } } /// Text shown in the label. open var labelText: String? { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the labelText. open var labelTextAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// Instead of use labelText and labelTextAttirbutes, this is a fully baked NSAttributedString with both text and attributes. open var labelAttributedText: NSAttributedString? { didSet { label.attributedText = labelAttributedText setNeedsUpdate() } } /// Text shown in the childLabel. open var childText: String? { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the childText. open var childTextAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// Instead of use childText and childTextAttirbutes, this is a fully baked NSAttributedString with both text and attributes. open var childAttributedText: NSAttributedString? { didSet { childLabel.attributedText = childAttributedText setNeedsUpdate() } } /// Override UIControl state to add the .error state if showError is true. open override var state: UIControl.State { get { var state = super.state if showError { state.insert(.error) } return state } } var _showError: Bool = false open var showError: Bool { get { _showError } set { if !isSelected && _showError != newValue { _showError = newValue setNeedsUpdate() } } } open var errorText: String? { didSet { setNeedsUpdate() } } open var inputId: String? { didSet { setNeedsUpdate() } } open var value: AnyHashable? { hiddenValue } open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } open override var accessibilityAction: ((Control) -> Void)? { didSet { selectorView.accessibilityAction = { [weak self] selectorItemBase in guard let self else { return } accessibilityAction?(self) } } } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() onClick = { [weak self] control in guard let self, isEnabled else { return } toggle() } selectorView.accessibilityAction = { [weak self] _ in guard let self, isEnabled else { return } toggle() } selectorView.bridge_accessibilityLabelBlock = { [weak self ] in guard let self else { return "" } var accessibilityLabels = [String]() if isSelected { accessibilityLabels.append("selected") } accessibilityLabels.append("\(Selector.self)") if let text = labelText, !text.isEmpty { accessibilityLabels.append(text) } if let text = childText, !text.isEmpty { accessibilityLabels.append(text) } if !isEnabled { accessibilityLabels.append("dimmed") } if let errorText, showError, !errorText.isEmpty { accessibilityLabels.append("error, \(errorText)") } return accessibilityLabels.joined(separator: ", ") } selectorView.bridge_accessibilityHintBlock = { [weak self] in guard let self else { return "" } return !isEnabled ? "" : "Double tap to activate." } } /// 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() selectorView.isAccessibilityElement = true isAccessibilityElement = false addSubview(mainStackView) mainStackView.isUserInteractionEnabled = false mainStackView.addArrangedSubview(selectorStackView) mainStackView.addArrangedSubview(errorLabel) selectorStackView.addArrangedSubview(selectorView) selectorStackView.addArrangedSubview(selectorLabelStackView) selectorLabelStackView.addArrangedSubview(label) selectorLabelStackView.addArrangedSubview(childLabel) mainStackView .pinTop() .pinLeading() .pinTrailing() .pinBottom(0, .defaultHigh) } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateLabels() selectorView.showError = showError selectorView.isSelected = isSelected selectorView.isHighlighted = isHighlighted selectorView.isEnabled = isEnabled selectorView.surface = surface } open override var accessibilityElements: [Any]? { get { var elements = [Any]() elements.append(selectorView) if let text = labelText, !text.isEmpty { elements.append(label) } if let text = childText, !text.isEmpty { elements.append(childLabel) } if let errorText, showError, !errorText.isEmpty { elements.append(errorLabel) } return elements } set { super.accessibilityElements = newValue } } /// Overriden to take the hit if there is an onClickSubscriber and the view is not a UIControl open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard isEnabled else { return super.hitTest(point, with: event) } let labelPoint = convert(point, to: label) let childLabelPoint = convert(point, to: childLabel) if label.isAction(for: labelPoint) { return label } else if childLabel.isAction(for: childLabelPoint) { return childLabel } else { guard !UIAccessibility.isVoiceOverRunning else { return nil } return super.hitTest(point, with: event) } } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false label.reset() childLabel.reset() errorLabel.reset() label.textStyle = .boldBodyLarge childLabel.textStyle = .bodyLarge errorLabel.textStyle = .bodyMedium labelText = nil labelTextAttributes = nil labelAttributedText = nil childText = nil childTextAttributes = nil childAttributedText = nil showError = false errorText = nil inputId = nil isSelected = false onChange = nil shouldUpdateView = true setNeedsUpdate() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- /// Update all labels with Text as well as adding and removing the labels. func updateLabels() { //deal with labels if shouldShowLabels { //add the stackview to hold the 2 labels //top label if let labelText { label.surface = surface label.isEnabled = isEnabled label.attributes = labelTextAttributes label.text = labelText label.isHidden = false } else if labelAttributedText != nil { label.isHidden = false } else { label.isHidden = true } //bottom label if let childText { childLabel.text = childText childLabel.surface = surface childLabel.isEnabled = isEnabled childLabel.attributes = childTextAttributes childLabel.isHidden = false } else if childAttributedText != nil { childLabel.isHidden = false } else { childLabel.isHidden = true } selectorStackView.spacing = 12 selectorLabelStackView.spacing = 4 selectorLabelStackView.isHidden = false } else { selectorStackView.spacing = 0 selectorLabelStackView.spacing = 0 selectorLabelStackView.isHidden = true } //either add/remove the error from the main stack if let errorText, shouldShowError { errorLabel.text = errorText errorLabel.surface = surface errorLabel.isEnabled = isEnabled mainStackView.spacing = 8 errorLabel.isHidden = false } else { mainStackView.spacing = 0 errorLabel.isHidden = true } } /// This will change to state of the Selector. open func toggle() {} open override func accessibilityActivate() -> Bool { guard isEnabled, isUserInteractionEnabled else { return false } var value = true // if #available(iOS 17, *) { // if let block = accessibilityAction { // block(self) // // } else if let block = accessibilityActivateBlock { // value = block() // // } else if let block = bridge_accessibilityActivateBlock { // value = block() // // } else { // toggle() // } // } else { if let block = accessibilityAction { block(self) } else if let block = bridge_accessibilityActivateBlock { value = block() } else { toggle() } // } return value } }