// // RadioBox.swift // VDS // // Created by Matt Bruce on 8/23/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine public class RadioBox: RadioBoxBase{} open class RadioBoxBase: Control, Changable { //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var mainStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .vertical } }() private var selectorStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .horizontal } }() private var selectorLeftLabelStackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical } }() private var textLabel = Label() private var subTextLabel = Label() private var subTextRightLabel = Label() private var errorLabel = Label() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var selectorView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() public var onChange: Blocks.ActionBlock? @Proxy(\.model.id) open var id: UUID //can't bind to @Proxy open override var isSelected: Bool { get { model.selected } set { if model.selected != newValue { model.selected = newValue } } } @Proxy(\.model.text) open var text: String @Proxy(\.model.subText) open var subText: String? @Proxy(\.model.subTextRight) open var subTextRight: String? @Proxy(\.model.hasError) open var hasError: Bool @Proxy(\.model.errorText) open var errorText: String? @Proxy(\.model.strikethrough) open var strikethrough: Bool @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? //functions //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(Self.tap))) isAccessibilityElement = true accessibilityTraits = .button addSubview(selectorView) selectorView.addSubview(mainStackView) //2 vertical rows mainStackView.addArrangedSubview(selectorStackView) mainStackView.addArrangedSubview(errorLabel) selectorStackView.addArrangedSubview(selectorLeftLabelStackView) selectorStackView.addArrangedSubview(subTextRightLabel) selectorLeftLabelStackView.addArrangedSubview(textLabel) selectorLeftLabelStackView.addArrangedSubview(subTextLabel) selectorStackView.spacing = 12 selectorLeftLabelStackView.spacing = 4 selectorLeftLabelStackView.isHidden = false updateSelector(model) selectorView.topAnchor.constraint(equalTo: topAnchor).isActive = true selectorView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true selectorView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true selectorView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true mainStackView.topAnchor.constraint(equalTo: selectorView.topAnchor, constant: 16).isActive = true mainStackView.leadingAnchor.constraint(equalTo: selectorView.leadingAnchor, constant: 16).isActive = true mainStackView.trailingAnchor.constraint(equalTo: selectorView.trailingAnchor, constant: -16).isActive = true mainStackView.bottomAnchor.constraint(equalTo: selectorView.bottomAnchor, constant: -16).isActive = true } func updateLabels(_ viewModel: ModelType) { //add the stackview to hold the 2 labels //text label textLabel.set(with: viewModel.textModel) //subText label if let subTextModel = viewModel.subTextModel { subTextLabel.set(with: subTextModel) subTextLabel.isHidden = false } else { subTextLabel.isHidden = true } //subTextRight label if let subTextRightModel = viewModel.subTextRightModel { subTextRightLabel.set(with: subTextRightModel) subTextRightLabel.isHidden = false } else { subTextRightLabel.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() updateSelector(model) setAccessibilityLabel() onChange = nil } //-------------------------------------------------- // MARK: - Actions //-------------------------------------------------- open override func sendActions(for controlEvents: UIControl.Event) { super.sendActions(for: controlEvents) if controlEvents.contains(.touchUpInside) { toggle() } } @objc func tap() { sendActions(for: .touchUpInside) } /// This will radioBox the state of the Selector and execute the actionBlock if provided. open func toggle() { //removed error if hasError && isSelected == false { hasError.toggle() } isSelected.toggle() sendActions(for: .valueChanged) 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: - State //-------------------------------------------------- /// Follow the SwiftUI View paradigm /// - Parameter viewModel: state open override func shouldUpdateView(viewModel: ModelType) -> Bool { let update = viewModel.selected != model.selected || viewModel.text != model.text || viewModel.subText != model.subText || viewModel.subTextRight != model.subTextRight || viewModel.hasError != model.hasError || viewModel.surface != model.surface || viewModel.disabled != model.disabled return update } open override func updateView(viewModel: ModelType) { let enabled = !viewModel.disabled updateLabels(viewModel) updateSelector(viewModel) setAccessibilityHint(enabled) setAccessibilityValue(viewModel.selected) setAccessibilityLabel(viewModel.selected) isUserInteractionEnabled = !viewModel.disabled setNeedsLayout() layoutIfNeeded() } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- private var strikeThroughLineThickness: CGFloat = 1.0 private var selectorCornerRadius: CGFloat = 4.0 private var selectorBorderWidthSelected: CGFloat = 2.0 private var selectorBorderWidth: CGFloat = 1.0 private var radioBoxBackgroundColorConfiguration: RadioBoxErrorColorConfiguration = { return RadioBoxErrorColorConfiguration().with { $0.forFalse.enabled.lightColor = VDSFormControlsColor.backgroundOnlight $0.forFalse.enabled.darkColor = VDSFormControlsColor.backgroundOndark $0.forFalse.disabled.lightColor = VDSFormControlsColor.backgroundOnlight $0.forFalse.disabled.darkColor = VDSFormControlsColor.backgroundOndark $0.forTrue.enabled.lightColor = VDSFormControlsColor.backgroundOnlight $0.forTrue.enabled.darkColor = VDSFormControlsColor.backgroundOndark $0.forTrue.disabled.lightColor = VDSFormControlsColor.backgroundOnlight $0.forTrue.disabled.darkColor = VDSFormControlsColor.backgroundOndark //error doesn't care enabled/disable $0.error.forTrue.lightColor = VDSColor.elementsPrimaryOnlight $0.error.forTrue.darkColor = VDSColor.elementsPrimaryOndark $0.error.forFalse.lightColor = VDSColor.feedbackErrorBackgroundOnlight $0.error.forFalse.darkColor = VDSColor.feedbackErrorBackgroundOndark } }() private var radioBoxBorderColorConfiguration: RadioBoxErrorColorConfiguration = { return RadioBoxErrorColorConfiguration().with { $0.forTrue.enabled.lightColor = VDSColor.elementsPrimaryOnlight $0.forTrue.enabled.darkColor = VDSColor.elementsPrimaryOndark $0.forFalse.enabled.lightColor = VDSFormControlsColor.borderOnlight $0.forFalse.enabled.darkColor = VDSFormControlsColor.borderOndark $0.forTrue.disabled.lightColor = VDSColor.interactiveDisabledOnlight $0.forTrue.disabled.darkColor = VDSColor.interactiveDisabledOndark $0.forFalse.disabled.lightColor = VDSColor.interactiveDisabledOnlight $0.forFalse.disabled.darkColor = VDSColor.interactiveDisabledOndark //error doesn't care enabled/disable $0.error.forTrue.lightColor = VDSColor.elementsPrimaryOnlight $0.error.forTrue.darkColor = VDSColor.elementsPrimaryOndark $0.error.forFalse.lightColor = VDSColor.feedbackErrorOnlight $0.error.forFalse.darkColor = VDSColor.feedbackErrorOndark } }() //-------------------------------------------------- // MARK: - RadioBox View Updates //-------------------------------------------------- /// Manages the appearance of the radioBox. private var shapeLayer: CAShapeLayer? open func updateSelector(_ viewModel: ModelType) { //get the colors let backgroundColor = radioBoxBackgroundColorConfiguration.getColor(viewModel) let borderColor = radioBoxBorderColorConfiguration.getColor(viewModel) let borderWidth = viewModel.selected ? selectorBorderWidthSelected : selectorBorderWidth selectorView.backgroundColor = backgroundColor selectorView.layer.borderColor = borderColor.cgColor selectorView.layer.cornerRadius = selectorCornerRadius selectorView.layer.borderWidth = borderWidth setNeedsDisplay() } open override func layoutSubviews() { super.layoutSubviews() // Accounts for any size changes layer.setNeedsDisplay() } open override func draw(_ layer: CALayer, in ctx: CGContext) { let borderColor = radioBoxBorderColorConfiguration.getColor(model) if let shapeLayer = shapeLayer, let sublayers = layer.sublayers, sublayers.contains(shapeLayer) { shapeLayer.removeFromSuperlayer() self.shapeLayer = nil } if shapeLayer == nil && model.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) } } //-------------------------------------------------- // MARK: - Color Class Configurations //-------------------------------------------------- private class RadioBoxErrorColorConfiguration: BinaryDisabledSurfaceColorConfiguration { public let error = BinarySurfaceColorConfiguration() override func getColor(_ viewModel: ModelType) -> UIColor { //only show error is enabled and showError == true let showErrorColor = !viewModel.disabled && viewModel.hasError if showErrorColor { return error.getColor(viewModel) } else { return super.getColor(viewModel) } } } }