// // RadioButton.swift // VDS // // Created by Matt Bruce on 7/22/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine /** A custom implementation of Apple's UISwitch. By default this class begins in the off state. */ public class RadioButton: RadioButtonBase{} open class RadioButtonBase: Control, Changable { //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var mainStackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.alignment = .top stackView.axis = .vertical return stackView }() private var radioButtonStackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.alignment = .top stackView.axis = .horizontal return stackView }() private var radioButtonLabelStackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical return stackView }() private var primaryLabel: Label = { let label = Label() label.translatesAutoresizingMaskIntoConstraints = false return label }() private var secondaryLabel: Label = { let label = Label() label.translatesAutoresizingMaskIntoConstraints = false return label }() private var errorLabel: Label = { let label = Label() label.translatesAutoresizingMaskIntoConstraints = false return label }() private var radioButtonView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false return view }() //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- public let radioButtonSize = CGSize(width: 20, height: 20) public let radioButtonSelectedSize = CGSize(width: 10, height: 10) private var radioButtonBackgroundColorConfiguration: RadioButtonErrorColorConfiguration = { let config = RadioButtonErrorColorConfiguration() //error doesn't care enabled/disable config.error.forTrue.lightColor = VDSColor.elementsPrimaryOnlight config.error.forTrue.darkColor = VDSColor.elementsPrimaryOndark config.error.forFalse.lightColor = VDSColor.feedbackErrorBackgroundOnlight config.error.forFalse.darkColor = VDSColor.feedbackErrorBackgroundOndark return config }() private var radioButtonBorderColorConfiguration: RadioButtonErrorColorConfiguration = { let config = RadioButtonErrorColorConfiguration() config.forTrue.enabled.lightColor = VDSColor.elementsPrimaryOnlight config.forTrue.enabled.darkColor = VDSColor.elementsPrimaryOndark config.forFalse.enabled.lightColor = VDSFormControlsColor.borderOnlight config.forFalse.enabled.darkColor = VDSFormControlsColor.borderOndark config.forTrue.disabled.lightColor = VDSColor.interactiveDisabledOnlight config.forTrue.disabled.darkColor = VDSColor.interactiveDisabledOndark config.forFalse.disabled.lightColor = VDSColor.interactiveDisabledOnlight config.forFalse.disabled.darkColor = VDSColor.interactiveDisabledOndark //error doesn't care enabled/disable config.error.forTrue.lightColor = VDSColor.elementsPrimaryOnlight config.error.forTrue.darkColor = VDSColor.elementsPrimaryOndark config.error.forFalse.lightColor = VDSColor.feedbackErrorOnlight config.error.forFalse.darkColor = VDSColor.feedbackErrorOndark return config }() private var radioButtonCheckColorConfiguration: BinaryDisabledSurfaceColorConfiguration = { let config = BinaryDisabledSurfaceColorConfiguration() config.forTrue.enabled.lightColor = VDSColor.elementsPrimaryOnlight config.forTrue.enabled.darkColor = VDSColor.elementsPrimaryOndark config.forTrue.disabled.lightColor = VDSColor.interactiveDisabledOnlight config.forTrue.disabled.darkColor = VDSColor.interactiveDisabledOndark return config }() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var onChange: Blocks.ActionBlock? @Proxy(\.model.id) open var id: String? @Proxy(\.model.on) open var isOn: Bool @Proxy(\.model.labelText) open var labelText: String? @Proxy(\.model.childText) open var childText: String? @Proxy(\.model.showError) open var showError: Bool @Proxy(\.model.errorText) open var errorText: String? @Proxy(\.model.inputId) open var inputId: String? @Proxy(\.model.value) open var value: AnyHashable? @Proxy(\.model.dataAnalyticsTrack) open var dataAnalyticsTrack: String? @Proxy(\.model.dataClickStream) open var dataClickStream: String? @Proxy(\.model.dataTrack) open var dataTrack: String? @Proxy(\.model.accessibilityHintEnabled) open var accessibilityHintEnabled: String? @Proxy(\.model.accessibilityHintDisabled) open var accessibilityHintDisabled: String? @Proxy(\.model.accessibilityValueEnabled) open var accessibilityValueEnabled: String? @Proxy(\.model.accessibilityValueDisabled) open var accessibilityValueDisabled: String? @Proxy(\.model.accessibilityLabelEnabled) open var accessibilityLabelEnabled: String? @Proxy(\.model.accessibilityLabelDisabled) open var accessibilityLabelDisabled: String? //-------------------------------------------------- // MARK: - Computed Properties //-------------------------------------------------- open override var isEnabled: Bool { get { !model.disabled } set { //create local vars for clear coding let disabled = !newValue if model.disabled != disabled { model.disabled = disabled } } } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public convenience init() { self.init(with: ModelType()) } required public init(with model: ModelType) { super.init(with: model) } required public init?(coder: NSCoder) { super.init(with: ModelType()) } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? private var knobHeightConstraint: NSLayoutConstraint? private var knobWidthConstraint: NSLayoutConstraint? private var radioButtonHeightConstraint: NSLayoutConstraint? private var radioButtonWidthConstraint: NSLayoutConstraint? //functions //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(RadioButton.toggleAndAction))) isAccessibilityElement = true accessibilityTraits = .button addSubview(mainStackView) mainStackView.addArrangedSubview(radioButtonStackView) mainStackView.addArrangedSubview(errorLabel) radioButtonStackView.addArrangedSubview(radioButtonView) radioButtonStackView.addArrangedSubview(radioButtonLabelStackView) radioButtonLabelStackView.addArrangedSubview(primaryLabel) radioButtonLabelStackView.addArrangedSubview(secondaryLabel) radioButtonHeightConstraint = radioButtonView.heightAnchor.constraint(equalToConstant: radioButtonSize.height) radioButtonHeightConstraint?.isActive = true radioButtonWidthConstraint = radioButtonView.widthAnchor.constraint(equalToConstant: radioButtonSize.width) radioButtonWidthConstraint?.isActive = true updateRadioButton(model) mainStackView.topAnchor.constraint(equalTo: topAnchor).isActive = true mainStackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true mainStackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true mainStackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true } func updateLabels(_ viewModel: ModelType) { //deal with labels if model.shouldShowLabels { //add the stackview to hold the 2 labels //top label if let labelModel = viewModel.labelModel { primaryLabel.set(with: labelModel) primaryLabel.isHidden = false } else { primaryLabel.isHidden = true } //bottom label if let childModel = viewModel.childModel { secondaryLabel.set(with: childModel) secondaryLabel.isHidden = false } else { secondaryLabel.isHidden = true } radioButtonStackView.spacing = 12 radioButtonLabelStackView.spacing = 4 radioButtonLabelStackView.isHidden = false } else { radioButtonStackView.spacing = 0 radioButtonLabelStackView.spacing = 0 radioButtonLabelStackView.isHidden = true } //either add/remove the error from the main stack if let errorModel = model.errorModel, model.shouldShowError { errorLabel.set(with: errorModel) mainStackView.spacing = 8 errorLabel.isHidden = false } else { mainStackView.spacing = 0 errorLabel.isHidden = true } } public override func reset() { super.reset() updateRadioButton(model) setAccessibilityLabel() onChange = nil } //-------------------------------------------------- // MARK: - RadioButton View //-------------------------------------------------- /// Manages the appearance of the radioButton. private var shapeLayer: CAShapeLayer? private func updateRadioButton(_ viewModel: ModelType) { //get the colors let backgroundColor = radioButtonBackgroundColorConfiguration.getColor(viewModel) let borderColor = radioButtonBorderColorConfiguration.getColor(viewModel) let radioSelectedColor = radioButtonCheckColorConfiguration.getColor(viewModel) if let shapeLayer = shapeLayer, let sublayers = layer.sublayers, sublayers.contains(shapeLayer) { shapeLayer.removeFromSuperlayer() self.shapeLayer = nil } radioButtonView.backgroundColor = backgroundColor radioButtonView.layer.borderColor = borderColor.cgColor radioButtonView.layer.cornerRadius = radioButtonView.bounds.width * 0.5 radioButtonView.layer.borderWidth = 1.0 if shapeLayer == nil { let bounds = radioButtonView.bounds let selectedBounds = radioButtonSelectedSize let length = max(bounds.size.height, bounds.size.width) guard length > 0.0, shapeLayer == nil else { return } let bezierPath = UIBezierPath(ovalIn: CGRect(x: (bounds.width - selectedBounds.width) / 2, y: (bounds.height - selectedBounds.height) / 2, width: radioButtonSelectedSize.width, height: radioButtonSelectedSize.height)) let shapeLayer = CAShapeLayer() self.shapeLayer = shapeLayer shapeLayer.frame = bounds layer.addSublayer(shapeLayer) shapeLayer.fillColor = radioSelectedColor.cgColor shapeLayer.path = bezierPath.cgPath } } //-------------------------------------------------- // MARK: - Actions //-------------------------------------------------- open override func sendAction(_ action: Selector, to target: Any?, for event: UIEvent?) { super.sendAction(action, to: target, for: event) toggleAndAction() } open override func sendActions(for controlEvents: UIControl.Event) { super.sendActions(for: controlEvents) toggleAndAction() } /// This will radioButton the state of the RadioButton and execute the actionBlock if provided. @objc public func toggleAndAction() { isOn.toggle() onChange?() } override open func accessibilityActivate() -> Bool { // Hold state in case User wanted isAnimated to remain off. guard isUserInteractionEnabled else { return false } sendActions(for: .touchUpInside) return true } //-------------------------------------------------- // MARK: - UIResponder //-------------------------------------------------- open override func touchesEnded(_ touches: Set, with event: UIEvent?) { sendActions(for: .touchUpInside) } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- /// Follow the SwiftUI View paradigm /// - Parameter viewModel: state open override func onStateChange(viewModel: ModelType) { let enabled = !viewModel.disabled updateLabels(viewModel) updateRadioButton(viewModel) setAccessibilityHint(enabled) setAccessibilityValue(viewModel.on) setAccessibilityLabel(viewModel.on) isUserInteractionEnabled = !viewModel.disabled setNeedsLayout() layoutIfNeeded() } //-------------------------------------------------- // MARK: - Color Class Configurations //-------------------------------------------------- private class RadioButtonErrorColorConfiguration: BinaryDisabledSurfaceColorConfiguration { public let error = BinarySurfaceColorConfiguration() override func getColor(_ viewModel: ModelType) -> UIColor { //only show error is enabled and showError == true let showErrorColor = !viewModel.disabled && viewModel.showError if showErrorColor { return error.getColor(viewModel) } else { return super.getColor(viewModel) } } } }