// // RadioBox.swift // VDS // // Created by Matt Bruce on 8/23/22. // import Foundation import UIKit import Combine import VDSCoreTokens /// Radio boxes are single-select components through which a customer indicates a choice /// that are used within a ``RadioBoxGroup``. @objc(VDSRadioBoxItem) open class RadioBoxItem: Control, Changeable, FormFieldable, 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 selectorStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.distribution = .fill $0.axis = .horizontal $0.spacing = VDSLayout.space3X } private var selectorLeftLabelStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical $0.alignment = .top $0.spacing = 0 $0.isHidden = false } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var onChangeSubscriber: AnyCancellable? /// Label used to render the text. open var textLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .boldBodyLarge } /// Label used to render the subText. open var subTextLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textStyle = .bodyLarge } /// Label used to render the subTextRight. open var subTextRightLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textAlignment = .right $0.textStyle = .bodyLarge } /// Selector for this RadioBox. open var selectorView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } /// If provided, the RadioBox text will be rendered. open var text: String? { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the text. open var textAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// If provided, the RadioBox textAttributedText will be rendered. open var textAttributedText: NSAttributedString? { didSet { textLabel.attributedText = textAttributedText setNeedsUpdate() } } /// If provided, the RadioBox subtext will be rendered. open var subText: String? { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the subText. open var subTextAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// If provided, the RadioBox subTextAttributedText will be rendered. open var subTextAttributedText: NSAttributedString? { didSet { subTextLabel.attributedText = subTextAttributedText setNeedsUpdate() } } /// If provided, the RadioBox subtextRight will be rendered. open var subTextRight: String? { didSet { setNeedsUpdate() } } /// Array of LabelAttributeModel objects used in rendering the subTextRight. open var subTextRightAttributes: [any LabelAttributeModel]? { didSet { setNeedsUpdate() } } /// If provided, the RadioBox subTextRightAttributedText will be rendered. open var subTextRightAttributedText: NSAttributedString? { didSet { subTextRightLabel.attributedText = subTextRightAttributedText setNeedsUpdate() } } /// If provided, the radio box will be rendered to show the option with a strikethrough. open var strikethrough: Bool = false { didSet { setNeedsUpdate() } } open var strikethroughAccessibilityText: String = "not available" { didSet { setNeedsUpdate() } } open var inputId: String? { didSet { setNeedsUpdate() } } open var value: AnyHashable? { hiddenValue } open var hiddenValue: AnyHashable? { didSet { setNeedsUpdate() } } open var accessibilityValueText: String? open var accessibilityLabelText: String { var accessibilityLabels = [String]() accessibilityLabels.append("Radiobox") if let text { accessibilityLabels.append(text) } if let text = subText { accessibilityLabels.append(text) } if let text = subTextRight { accessibilityLabels.append(text) } if !isEnabled { accessibilityLabels.append("dimmed") } return accessibilityLabels.joined(separator: ", ") } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- private var strikeThroughLineThickness: CGFloat = VDSFormControls.borderWidth private var selectorCornerRadius: CGFloat = VDSFormControls.borderRadius private var selectorBorderWidthSelected: CGFloat = VDSFormControls.borderWidth + VDSFormControls.borderWidth private var selectorBorderWidth: CGFloat = VDSFormControls.borderWidth private var backgroundColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .normal) $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .disabled) $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .selected) $0.setSurfaceColors(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark, forState: .highlighted) } private var borderColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .selected) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() onClick = { control in control.toggle() } } /// 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() isAccessibilityElement = false selectorView.isAccessibilityElement = true selectorView.accessibilityTraits = .button addSubview(selectorView) selectorView.isUserInteractionEnabled = false selectorView.addSubview(selectorStackView) selectorStackView.addArrangedSubview(selectorLeftLabelStackView) selectorStackView.addArrangedSubview(subTextRightLabel) selectorLeftLabelStackView.addArrangedSubview(textLabel) selectorLeftLabelStackView.addArrangedSubview(subTextLabel) selectorView .pinTop() .pinLeading() .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) selectorStackView.pinToSuperView(.uniform(VDSLayout.space3X)) } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false textLabel.reset() subTextLabel.reset() subTextRightLabel.reset() textLabel.textStyle = .boldBodyLarge subTextLabel.textStyle = .bodyLarge subTextRightLabel.textStyle = .bodyLarge text = nil textAttributes = nil textAttributedText = nil subText = nil subTextAttributes = nil subTextAttributedText = nil subTextRight = nil subTextRightAttributes = nil subTextRightAttributedText = nil strikethrough = false inputId = nil hiddenValue = nil isSelected = false onChange = nil shouldUpdateView = true setNeedsUpdate() } /// This will change the state of the Selector and execute the actionBlock if provided. open func toggle() { //removed error isSelected.toggle() sendActions(for: .valueChanged) } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateLabels() setNeedsLayout() } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() accessibilityLabel = accessibilityLabelText if let accessibilityValueText { accessibilityValue = strikethrough ? "\(strikethroughAccessibilityText), \(accessibilityValueText)" : accessibilityValueText } else { accessibilityValue = strikethrough ? "\(strikethroughAccessibilityText)" : accessibilityValueText } } open override var accessibilityElements: [Any]? { get { var items = [Any]() items.append(selectorView) let elements = gatherAccessibilityElements(from: selectorView) let views = elements.compactMap({ $0 as? UIView }) //update accessibilityLabel selectorView.setAccessibilityLabel(for: views) //disabled if !isEnabled { if let label = selectorView.accessibilityLabel, !label.isEmpty { selectorView.accessibilityLabel = "\(label), dimmed" } else { selectorView.accessibilityLabel = "dimmed" } } //append all children that are accessible items.append(contentsOf: elements) return items } set {} } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- func updateLabels() { var leftCount = 0 //add the stackview to hold the 2 labels //text label if let text, !text.isEmpty { textLabel.text = text textLabel.surface = surface textLabel.isEnabled = isEnabled textLabel.attributes = textAttributes textLabel.isHidden = false leftCount += 1 } else if textAttributedText != nil { textLabel.isHidden = false } else { textLabel.isHidden = true } //subText label if let subText, !subText.isEmpty { subTextLabel.text = subText subTextLabel.surface = surface subTextLabel.isEnabled = isEnabled subTextLabel.attributes = subTextAttributes subTextLabel.isHidden = false leftCount += 1 } else if subTextAttributedText != nil { subTextLabel.isHidden = false } else { subTextLabel.isHidden = true } //subTextRight label if let subTextRight, !subTextRight.isEmpty { subTextRightLabel.text = subTextRight subTextRightLabel.surface = surface subTextRightLabel.isEnabled = isEnabled subTextRightLabel.attributes = subTextRightAttributes subTextRightLabel.isHidden = false } else if subTextAttributedText != nil { subTextRightLabel.isHidden = false } else { subTextRightLabel.isHidden = true } selectorLeftLabelStackView.spacing = leftCount > 1 ? 4 : 0 } //-------------------------------------------------- // MARK: - RadioBox View Updates //-------------------------------------------------- /// Manages the appearance of the radioBox. private var shapeLayer: CAShapeLayer? open override func layoutSubviews() { super.layoutSubviews() //get the colors let backgroundColor = backgroundColorConfiguration.getColor(self) let borderColor = borderColorConfiguration.getColor(self) let borderWidth = (isSelected || isHighlighted) && isEnabled ? selectorBorderWidthSelected : selectorBorderWidth selectorView.backgroundColor = backgroundColor selectorView.layer.borderColor = borderColor.cgColor selectorView.layer.cornerRadius = selectorCornerRadius selectorView.layer.borderWidth = borderWidth shapeLayer?.removeFromSuperlayer() shapeLayer = nil if strikethrough { let bounds = selectorView.bounds let length = max(bounds.size.height, bounds.size.width) guard length > 0.0, shapeLayer == nil else { return } let border = CAShapeLayer() border.name = "strikethrough" border.fillColor = nil border.opacity = 1.0 border.lineWidth = strikeThroughLineThickness border.strokeColor = borderColor.cgColor let linePath = UIBezierPath() let offsetPercent: CGFloat = 0.005 linePath.move(to: CGPoint(x: selectorCornerRadius, y: bounds.height * (1 - offsetPercent))) linePath.addLine(to: CGPoint(x: bounds.width - selectorCornerRadius, y: bounds.height * offsetPercent)) border.path = linePath.cgPath shapeLayer = border layer.addSublayer(border) } } }