diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index bcc73209..23e062f4 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -41,6 +41,9 @@ EA3362452892F9130071C351 /* Labelable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3362442892F9130071C351 /* Labelable.swift */; }; EA33624728931B050071C351 /* Initable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA33624628931B050071C351 /* Initable.swift */; }; EA3C3B4C2894823E000CA526 /* ProxPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA3C3B4B2894823E000CA526 /* ProxPropertyWrapper.swift */; }; + EAF7F0952899861000B287F5 /* VDSCheckbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0932899861000B287F5 /* VDSCheckbox.swift */; }; + EAF7F0962899861000B287F5 /* VDSCheckboxModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0942899861000B287F5 /* VDSCheckboxModel.swift */; }; + EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0992899B17200B287F5 /* CATransaction.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -89,6 +92,9 @@ EA3362442892F9130071C351 /* Labelable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Labelable.swift; sourceTree = ""; }; EA33624628931B050071C351 /* Initable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Initable.swift; sourceTree = ""; }; EA3C3B4B2894823E000CA526 /* ProxPropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxPropertyWrapper.swift; sourceTree = ""; }; + EAF7F0932899861000B287F5 /* VDSCheckbox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VDSCheckbox.swift; sourceTree = ""; }; + EAF7F0942899861000B287F5 /* VDSCheckboxModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VDSCheckboxModel.swift; sourceTree = ""; }; + EAF7F0992899B17200B287F5 /* CATransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATransaction.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -169,6 +175,7 @@ EA33619D288B1E330071C351 /* Components */ = { isa = PBXGroup; children = ( + EAF7F092289985E200B287F5 /* Checkbox */, EA3361A0288B1E6F0071C351 /* Toggle */, EA3362412892EF700071C351 /* Label */, ); @@ -190,6 +197,7 @@ EA3361A7288B23300071C351 /* UIColor.swift */, EA33622D2891EA3C0071C351 /* DispatchQueue+Once.swift */, EA33623D2892EE950071C351 /* UIDevice.swift */, + EAF7F0992899B17200B287F5 /* CATransaction.swift */, ); path = Extensions; sourceTree = ""; @@ -266,6 +274,15 @@ path = Label; sourceTree = ""; }; + EAF7F092289985E200B287F5 /* Checkbox */ = { + isa = PBXGroup; + children = ( + EAF7F0932899861000B287F5 /* VDSCheckbox.swift */, + EAF7F0942899861000B287F5 /* VDSCheckboxModel.swift */, + ); + path = Checkbox; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -385,15 +402,18 @@ EA33622E2891EA3C0071C351 /* DispatchQueue+Once.swift in Sources */, EA3361C5289030FC0071C351 /* Accessable.swift in Sources */, EA33622C2891E73B0071C351 /* FontProtocol.swift in Sources */, + EAF7F0952899861000B287F5 /* VDSCheckbox.swift in Sources */, EA3361C9289054C50071C351 /* Surfaceable.swift in Sources */, EA3361A2288B1E840071C351 /* VDSToggleModel.swift in Sources */, EA3362432892EFF20071C351 /* VDSLabelModel.swift in Sources */, EA33624728931B050071C351 /* Initable.swift in Sources */, EA3361BD288B2C760071C351 /* TypeAlias.swift in Sources */, + EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */, EA3C3B4C2894823E000CA526 /* ProxPropertyWrapper.swift in Sources */, EA3361AF288B26310071C351 /* FormFieldable.swift in Sources */, EA3361B3288B265D0071C351 /* Changable.swift in Sources */, EA336171288B19200071C351 /* VDS.docc in Sources */, + EAF7F0962899861000B287F5 /* VDSCheckboxModel.swift in Sources */, EA3361AA288B25E40071C351 /* Disabling.swift in Sources */, EA3361B6288B2A410071C351 /* VDSControl.swift in Sources */, EA3362452892F9130071C351 /* Labelable.swift in Sources */, diff --git a/VDS/Components/Checkbox/VDSCheckbox.swift b/VDS/Components/Checkbox/VDSCheckbox.swift new file mode 100644 index 00000000..f34105da --- /dev/null +++ b/VDS/Components/Checkbox/VDSCheckbox.swift @@ -0,0 +1,406 @@ +// +// Checkbox.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. + + Container: The background of the checkbox control. + Knob: The circular indicator that slides on the container. + */ + +@objcMembers public class VDSCheckbox: VDSCheckboxBase{} + +@objcMembers open class VDSCheckboxBase: VDSControl, Changable { + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private func getCheckboxTintColor(for disabled: Bool, surface: Surface) -> (on: UIColor, off: UIColor) { + if disabled { + if surface == .light { + return (on: VDSColor.interactiveDisabledOnlight, off: UIColor.clear) + } else { + return (on: VDSColor.interactiveDisabledOndark, off: UIColor.clear) + } + } else { + if surface == .light { + return (on: VDSColor.elementsPrimaryOnlight, off: UIColor.clear) + } else { + return (on: VDSColor.elementsPrimaryOndark, off: UIColor.clear) + } + } + } + + private func getCheckboxBorderColor(for disabled: Bool, surface: Surface) -> (on: UIColor, off: UIColor) { + if disabled { + if surface == .light { + return (on: VDSColor.interactiveDisabledOnlight, off: VDSColor.interactiveDisabledOnlight) + } else { + return (on: VDSColor.interactiveDisabledOnlight, off: VDSColor.interactiveDisabledOnlight) + } + } else { + if surface == .light { + return (on: VDSColor.elementsPrimaryOndark, off: VDSFormControlsColor.borderOnlight) + } else { + return (on: VDSColor.elementsPrimaryOndark, off: VDSFormControlsColor.borderOndark) + } + } + } + + private func getCheckboxCheckColor(for disabled: Bool, surface: Surface) -> UIColor { + if disabled { + if surface == .light { + return VDSColor.interactiveDisabledOnlight + } else { + return VDSColor.interactiveDisabledOnlight + } + } else { + if surface == .light { + return VDSColor.elementsPrimaryOndark + } else { + return VDSColor.elementsPrimaryOndark + } + } + } + + private var mainStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.distribution = .fillProportionally + return stackView + }() + + private var checkBoxStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.distribution = .fillProportionally + return stackView + }() + + private var checkboxLabelStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.distribution = .fillProportionally + stackView.spacing = 5 + return stackView + }() + + private var primaryLabel: VDSLabel = { + let label = VDSLabel() + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var secondaryLabel: VDSLabel = { + let label = VDSLabel() + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var errorLabel: VDSLabel = { + let label = VDSLabel() + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var checkboxView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + public var onChange: Blocks.ActionBlock? + + //-------------------------------------------------- + // MARK: - Static Properties + //-------------------------------------------------- + // Sizes are from InVision design specs. + public let checkboxSize = CGSize(width: 52, height: 24) + + //-------------------------------------------------- + // MARK: - Computed Properties + //-------------------------------------------------- + @Proxy(\.model.id) + public var id: String? + + @Proxy(\.model.inputId) + public var inputId: String? + + @Proxy(\.model.value) + public var value: AnyHashable? + + @Proxy(\.model.showError) + public var showError: Bool + + @Proxy(\.model.errorText) + public var errorText: String? + + @Proxy(\.model.labelText) + public var labelText: String? + + @Proxy(\.model.childText) + public var childText: String? + + @Proxy(\.model.surface) + public var surface: Surface + + @Proxy(\.model.selected) + open var isOn: Bool + + @Proxy(\.model.disabled) + open var disabled: Bool + + 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 checkboxHeightConstraint: NSLayoutConstraint? + private var checkboxWidthConstraint: NSLayoutConstraint? + + //functions + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + + public override func updateView(_ size: CGFloat) { + super.updateView(size) + + checkboxHeightConstraint?.constant = checkboxSize.height + checkboxWidthConstraint?.constant = checkboxSize.width + setCheckboxColor(viewModel: model) + } + + public override func setupView() { + super.setupView() + + isAccessibilityElement = true + accessibilityTraits = .button + addSubview(mainStackView) + + mainStackView.addArrangedSubview(checkBoxStackView) + checkBoxStackView.addArrangedSubview(checkboxView) + + checkboxHeightConstraint = checkboxView.heightAnchor.constraint(equalToConstant: checkboxSize.height) + checkboxHeightConstraint?.isActive = true + + checkboxWidthConstraint = checkboxView.widthAnchor.constraint(equalToConstant: checkboxSize.width) + checkboxWidthConstraint?.isActive = true + + checkboxView.layer.borderWidth = 1.0 + setCheckboxColor(viewModel: 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 + + } + + /// Manages the appearance of the checkbox. + private var shapeLayer: CAShapeLayer? + + /// Creates the check mark layer. + private func setCheckboxColor(viewModel: ModelType) { + + let background = getCheckboxTintColor(for: viewModel.disabled, surface: viewModel.surface) + let border = getCheckboxBorderColor(for: viewModel.disabled, surface: viewModel.surface) + let checkColor = getCheckboxCheckColor(for: viewModel.disabled, surface: viewModel.surface) + + checkboxView.backgroundColor = viewModel.disabled ? background.off : background.on + checkboxView.layer.borderColor = (viewModel.disabled ? border.off : border.on).cgColor + + if shapeLayer == nil { + + let shapeLayer = CAShapeLayer() + self.shapeLayer = shapeLayer + shapeLayer.frame = bounds + checkboxView.layer.addSublayer(shapeLayer) + shapeLayer.strokeColor = checkColor.cgColor + shapeLayer.fillColor = UIColor.clear.cgColor + shapeLayer.path = checkMarkPath() + shapeLayer.lineJoin = .miter + shapeLayer.lineWidth = 2 + + CATransaction.withDisabledAnimations { + shapeLayer.strokeEnd = viewModel.selected ? 1 : 0 + } + } else { + shapeLayer?.strokeColor = checkColor.cgColor + } + } + + /// - returns: The CGPath of a UIBezierPath detailing the path of a checkmark + func checkMarkPath() -> CGPath { + + let length = max(bounds.size.height, bounds.size.width) + let xInsetLeft = length * 0.25 + let yInsetTop = length * 0.3 + let innerWidth = length - (xInsetLeft + length * 0.25) // + Right X Inset + let innerHeight = length - (yInsetTop + length * 0.35) // + Bottom Y Inset + + let startPoint = CGPoint(x: xInsetLeft, y: yInsetTop + (innerHeight / 2)) + let pivotOffSet = CGPoint(x: xInsetLeft + (innerWidth * 0.33), y: yInsetTop + innerHeight) + let endOffset = CGPoint(x: xInsetLeft + innerWidth, y: yInsetTop) + + let bezierPath = UIBezierPath() + bezierPath.move(to: startPoint) + bezierPath.addLine(to: pivotOffSet) + bezierPath.addLine(to: endOffset) + + return bezierPath.cgPath + } + + func ensureLabel(viewModel: ModelType) { + + //deal with labels + if model.shouldShowLabels { + //add the stackview to hold the 2 labels + if checkBoxStackView.subviews.contains(checkboxLabelStackView) == false { + checkBoxStackView.addArrangedSubview(checkboxLabelStackView) + } + //top label + if let labelModel = viewModel.labelModel { + primaryLabel.set(with: labelModel) + if checkBoxStackView.subviews.contains(primaryLabel) == false { + checkBoxStackView.insertArrangedSubview(primaryLabel, at: 0) + } + } else { + primaryLabel.removeFromSuperview() + } + //bottom label + if let childModel = viewModel.childModel { + secondaryLabel.set(with: childModel) + if checkBoxStackView.subviews.contains(secondaryLabel) == false { + checkBoxStackView.addArrangedSubview(secondaryLabel) + } + } else { + secondaryLabel.removeFromSuperview() + } + } else { + checkboxLabelStackView.removeFromSuperview() + } + + //either add/remove the error from the main stack + if model.shouldShowError { + mainStackView.spacing = 12 + mainStackView.addArrangedSubview(errorLabel) + } else { + mainStackView.spacing = 0 + errorLabel.removeFromSuperview() + } + + } + + public override func reset() { + super.reset() + setCheckboxColor(viewModel: model) + setAccessibilityLabel() + onChange = nil + } + + //-------------------------------------------------- + // 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 checkbox the state of the Checkbox and execute the actionBlock if provided. + 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 + //-------------------------------------------------- + + public override func touchesEnded(_ touches: Set, with event: UIEvent?) { + sendActions(for: .touchUpInside) + } + + open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + sendActions(for: .touchCancel) + } + + //-------------------------------------------------- + // MARK: - Animations + //-------------------------------------------------- + + /// Follow the SwiftUI View paradigm + /// - Parameter viewModel: state + open override func onStateChange(viewModel: ModelType) { + let enabled = !viewModel.disabled + + ensureLabel(viewModel: viewModel) + setCheckboxColor(viewModel: viewModel) + setAccessibilityHint(enabled) + setAccessibilityValue(viewModel.selected) + setAccessibilityLabel(viewModel.selected) + isUserInteractionEnabled = !viewModel.disabled + setNeedsLayout() + layoutIfNeeded() + } +} + diff --git a/VDS/Components/Checkbox/VDSCheckboxModel.swift b/VDS/Components/Checkbox/VDSCheckboxModel.swift new file mode 100644 index 00000000..90e29e98 --- /dev/null +++ b/VDS/Components/Checkbox/VDSCheckboxModel.swift @@ -0,0 +1,106 @@ +// +// ToggleModel.swift +// VDS +// +// Created by Matt Bruce on 7/22/22. +// + +import Foundation +import UIKit + +public protocol Errorable { + var showError: Bool { get set } + var errorText: String? { get set } +} + +public protocol VDSCheckboxModel: FormFieldable, Errorable, DataTrackable, Accessable, Surfaceable, Disabling, Initable { + var id: String? { get set } + var selected: Bool { get set } + var labelText: String? { get set } + var childText: String? { get set } +} + +extension VDSCheckboxModel { + public var fontCategory: VDSFontCategory { + get { return .body } + set { return } + } + + public var shouldShowError: Bool { + guard showError && 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: VDSLabelModel? { + guard let labelText = labelText else { return nil } + let model = DefaultLabelModel() + model.fontSize = .large + model.textPosition = .left + model.fontWeight = .bold + model.fontCategory = .body + model.text = labelText + model.surface = surface + model.disabled = disabled + return model + } + + public var childModel: VDSLabelModel? { + guard let childText = childText else { return nil } + let model = DefaultLabelModel() + model.fontSize = .large + model.textPosition = .left + model.fontWeight = .regular + model.fontCategory = .body + model.text = childText + model.surface = surface + model.disabled = disabled + return model + } + + public var errorModel: VDSLabelModel? { + guard let errorText = errorText, showError else { return nil } + let 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 class DefaultCheckboxModel: VDSCheckboxModel { + public var id: String? + public var selected: Bool = false + + public var labelText: String? + public var childText: String? + + 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 required init() {} +} diff --git a/VDS/Extensions/CATransaction.swift b/VDS/Extensions/CATransaction.swift new file mode 100644 index 00000000..fe902ca3 --- /dev/null +++ b/VDS/Extensions/CATransaction.swift @@ -0,0 +1,20 @@ +// +// CATransaction.swift +// VDS +// +// Created by Matt Bruce on 8/2/22. +// + +import Foundation +import UIKit + +extension CATransaction { + + /// Performs changes without activating animation actions. + public static func withDisabledAnimations(_ actionBlock: Blocks.ActionBlock) { + CATransaction.begin() + CATransaction.setDisableActions(true) + actionBlock() + CATransaction.commit() + } +}