diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index f585a31e..3d297723 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ EAF7F0FA289DB1AC00B287F5 /* VDSLayoutTokens.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAF7F0EB289DB0DA00B287F5 /* VDSLayoutTokens.xcframework */; }; EAF7F0FB289DB1AC00B287F5 /* VDSColorTokens.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAF7F0E9289DB0DA00B287F5 /* VDSColorTokens.xcframework */; }; EAF7F0FC289DB1AC00B287F5 /* VDSAccessibilityTokens.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAF7F0EA289DB0DA00B287F5 /* VDSAccessibilityTokens.xcframework */; }; + EAF7F11728A1475A00B287F5 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F11528A1475A00B287F5 /* RadioButton.swift */; }; + EAF7F11828A1475A00B287F5 /* RadioButtonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F11628A1475A00B287F5 /* RadioButtonModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -133,6 +135,8 @@ EAF7F0E9289DB0DA00B287F5 /* VDSColorTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSColorTokens.xcframework; path = ../SharedFrameworks/VDSColorTokens.xcframework; sourceTree = ""; }; EAF7F0EA289DB0DA00B287F5 /* VDSAccessibilityTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSAccessibilityTokens.xcframework; path = ../SharedFrameworks/VDSAccessibilityTokens.xcframework; sourceTree = ""; }; EAF7F0EB289DB0DA00B287F5 /* VDSLayoutTokens.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VDSLayoutTokens.xcframework; path = ../SharedFrameworks/VDSLayoutTokens.xcframework; sourceTree = ""; }; + EAF7F11528A1475A00B287F5 /* RadioButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; + EAF7F11628A1475A00B287F5 /* RadioButtonModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioButtonModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -219,6 +223,7 @@ EA33619D288B1E330071C351 /* Components */ = { isa = PBXGroup; children = ( + EAF7F11428A1470D00B287F5 /* RadioButton */, EAF7F092289985E200B287F5 /* Checkbox */, EA3361A0288B1E6F0071C351 /* Toggle */, EA3362412892EF700071C351 /* Label */, @@ -351,6 +356,15 @@ path = Attributes; sourceTree = ""; }; + EAF7F11428A1470D00B287F5 /* RadioButton */ = { + isa = PBXGroup; + children = ( + EAF7F11528A1475A00B287F5 /* RadioButton.swift */, + EAF7F11628A1475A00B287F5 /* RadioButtonModel.swift */, + ); + path = RadioButton; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -469,12 +483,14 @@ EAF7F0A6289B0CE000B287F5 /* Resetable.swift in Sources */, EA3361C328902D960071C351 /* Toggle.swift in Sources */, EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */, + EAF7F11828A1475A00B287F5 /* RadioButtonModel.swift in Sources */, EA3362402892EF6C0071C351 /* Label.swift in Sources */, EAF7F0B3289B1ADC00B287F5 /* LabelAttributeAction.swift in Sources */, EA33622E2891EA3C0071C351 /* DispatchQueue+Once.swift in Sources */, EAF7F0AF289B144C00B287F5 /* LabelAttributeUnderline.swift in Sources */, EA3361C5289030FC0071C351 /* Accessable.swift in Sources */, EA33622C2891E73B0071C351 /* FontProtocol.swift in Sources */, + EAF7F11728A1475A00B287F5 /* RadioButton.swift in Sources */, EAF7F0952899861000B287F5 /* Checkbox.swift in Sources */, EA3361C9289054C50071C351 /* Surfaceable.swift in Sources */, EA3361A2288B1E840071C351 /* ToggleModel.swift in Sources */, diff --git a/VDS/Components/Checkbox/Checkbox.swift b/VDS/Components/Checkbox/Checkbox.swift index 9999fa95..df42e39d 100644 --- a/VDS/Components/Checkbox/Checkbox.swift +++ b/VDS/Components/Checkbox/Checkbox.swift @@ -15,8 +15,6 @@ import Combine By default this class begins in the off state. - Container: The background of the checkbox control. - Knob: The circular indicator that slides on the container. */ public class Checkbox: CheckboxBase{} diff --git a/VDS/Components/RadioButton/RadioButton.swift b/VDS/Components/RadioButton/RadioButton.swift new file mode 100644 index 00000000..dc2c67cb --- /dev/null +++ b/VDS/Components/RadioButton/RadioButton.swift @@ -0,0 +1,405 @@ +// +// 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) + } + } + } +} + diff --git a/VDS/Components/RadioButton/RadioButtonModel.swift b/VDS/Components/RadioButton/RadioButtonModel.swift new file mode 100644 index 00000000..e79f048d --- /dev/null +++ b/VDS/Components/RadioButton/RadioButtonModel.swift @@ -0,0 +1,109 @@ +// +// ToggleModel.swift +// VDS +// +// Created by Matt Bruce on 7/22/22. +// + +import Foundation +import UIKit + +public protocol RadioButtonModel: Modelable, FormFieldable, Errorable, DataTrackable, Accessable, BinaryColorable { + var id: String? { get set } + var on: Bool { get set } + var labelText: String? { get set } + var labelTextAttributes: [LabelAttributeModel]? { get set } + var childText: String? { get set } + var childTextAttributes: [LabelAttributeModel]? { get set } +} + +extension RadioButtonModel { + public var userTrueColor: Bool { return on } + + public var fontCategory: FontCategory { + get { return .body } + set { return } + } + + public var shouldShowError: Bool { + guard showError && !disabled && errorText?.isEmpty == false else { return false } + return true + } + + public var shouldShowLabels: Bool { + guard labelText?.isEmpty == false || childText?.isEmpty == false else { return false } + return true + } + + public var labelModel: DefaultLabelModel? { + guard let labelText = labelText else { return nil } + var model = DefaultLabelModel() + model.fontSize = .large + model.textPosition = .left + model.fontWeight = .bold + model.fontCategory = .body + model.text = labelText + model.surface = surface + model.disabled = disabled + model.attributes = labelTextAttributes + return model + } + + public var childModel: DefaultLabelModel? { + guard let childText = childText else { return nil } + var model = DefaultLabelModel() + model.fontSize = .large + model.textPosition = .left + model.fontWeight = .regular + model.fontCategory = .body + model.text = childText + model.surface = surface + model.disabled = disabled + model.attributes = childTextAttributes + return model + } + + public var errorModel: DefaultLabelModel? { + guard let errorText = errorText, showError else { return nil } + var model = DefaultLabelModel() + model.fontSize = .medium + model.textPosition = .left + model.fontWeight = .regular + model.fontCategory = .body + model.text = errorText + model.surface = surface + model.disabled = disabled + return model + } +} + +public struct DefaultRadioButtonModel: RadioButtonModel { + public var id: String? + public var on: Bool = false + + public var labelText: String? + public var labelTextAttributes: [LabelAttributeModel]? + public var childText: String? + public var childTextAttributes: [LabelAttributeModel]? + + public var showError: Bool = false + public var errorText: String? + + public var inputId: String? + public var value: AnyHashable? + + public var surface: Surface = .light + public var disabled: Bool = false + + public var dataAnalyticsTrack: String? + public var dataClickStream: String? + public var dataTrack: String? + public var accessibilityHintEnabled: String? + public var accessibilityHintDisabled: String? + public var accessibilityValueEnabled: String? + public var accessibilityValueDisabled: String? + public var accessibilityLabelEnabled: String? + public var accessibilityLabelDisabled: String? + + public init() {} +}