// // Checkbox.swift // VDS // // Created by Matt Bruce on 7/22/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine /// Checkboxes are a multi-select component through which a customer indicates a choice. If a binary choice, the component is a checkbox. If the choice has multiple options, the component is a ``CheckboxGroup``. @objc(VDSCheckbox) public class Checkbox: CheckboxBase{} @objc(VDSSoloCheckbox) public class SoloCheckbox: CheckboxBase{ public override func initialSetup() { super.initialSetup() publisher(for: .touchUpInside) .sink { control in control.toggle() }.store(in: &subscribers) } } @objc(VDSCheckboxBase) open class CheckboxBase: Control, Accessable, DataTrackable, BinaryColorable, Errorable { //-------------------------------------------------- // 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 shouldShowError: Bool { guard showError && !disabled && errorText?.isEmpty == false else { return false } return true } private var shouldShowLabels: Bool { guard labelText?.isEmpty == false || childText?.isEmpty == false || labelAttributedText?.string.isEmpty == false || childAttributedText?.string.isEmpty == false else { return false } return true } private var mainStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .vertical } private var selectorStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.alignment = .top $0.axis = .horizontal } private var selectorLabelStackView = UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .vertical } private var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.typograpicalStyle = .BoldBodyLarge } private var childLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.typograpicalStyle = .BodyLarge } private var errorLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.typograpicalStyle = .BodyMedium } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var selectorView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } //can't bind to @Proxy open override var isSelected: Bool { didSet { didChange() }} open var labelText: String? { didSet { didChange() }} open var labelTextAttributes: [any LabelAttributeModel]? { didSet { didChange() }} open var labelAttributedText: NSAttributedString? { didSet { label.useAttributedText = !(labelAttributedText?.string.isEmpty ?? true) label.attributedText = labelAttributedText didChange() } } open var childText: String? { didSet { didChange() }} open var childTextAttributes: [any LabelAttributeModel]? { didSet { didChange() }} open var childAttributedText: NSAttributedString? { didSet { childLabel.useAttributedText = !(childAttributedText?.string.isEmpty ?? true) childLabel.attributedText = childAttributedText didChange() } } open var showError: Bool = false { didSet { if showError && isSelected { isSelected.toggle() } didChange() } } open override var state: UIControl.State { get { var state = super.state if showError { state.insert(.error) } return state } } open var errorText: String? { didSet { didChange() }} open var inputId: String? { didSet { didChange() }} open var value: AnyHashable? { didSet { didChange() }} open var dataAnalyticsTrack: String? { didSet { didChange() }} open var dataClickStream: String? { didSet { didChange() }} open var dataTrack: String? { didSet { didChange() }} open var accessibilityHintEnabled: String? { didSet { didChange() }} open var accessibilityHintDisabled: String? { didSet { didChange() }} open var accessibilityValueEnabled: String? { didSet { didChange() }} open var accessibilityValueDisabled: String? { didSet { didChange() }} open var accessibilityLabelEnabled: String? { didSet { didChange() }} open var accessibilityLabelDisabled: String? { didSet { didChange() }} //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var selectorHeightConstraint: NSLayoutConstraint? private var selectorWidthConstraint: NSLayoutConstraint? //functions //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() isAccessibilityElement = true accessibilityTraits = .button addSubview(mainStackView) mainStackView.isUserInteractionEnabled = false mainStackView.addArrangedSubview(selectorStackView) mainStackView.addArrangedSubview(errorLabel) selectorStackView.addArrangedSubview(selectorView) selectorStackView.addArrangedSubview(selectorLabelStackView) selectorLabelStackView.addArrangedSubview(label) selectorLabelStackView.addArrangedSubview(childLabel) let selectorSize = getSelectorSize() selectorHeightConstraint = selectorView.heightAnchor.constraint(equalToConstant: selectorSize.height) selectorHeightConstraint?.isActive = true selectorWidthConstraint = selectorView.widthAnchor.constraint(equalToConstant: selectorSize.width) selectorWidthConstraint?.isActive = true updateSelector() mainStackView.pinToSuperView() } func updateLabels() { //deal with labels if shouldShowLabels { //add the stackview to hold the 2 labels //top label if let labelText { label.surface = surface label.disabled = disabled label.attributes = labelTextAttributes label.text = labelText label.isHidden = false } else if labelAttributedText != nil { label.isHidden = false } else { label.isHidden = true } //bottom label if let childText { childLabel.text = childText childLabel.surface = surface childLabel.disabled = disabled childLabel.attributes = childTextAttributes childLabel.isHidden = false } else if childAttributedText != nil { childLabel.isHidden = false } else { childLabel.isHidden = true } selectorStackView.spacing = 12 selectorLabelStackView.spacing = 4 selectorLabelStackView.isHidden = false } else { selectorStackView.spacing = 0 selectorLabelStackView.spacing = 0 selectorLabelStackView.isHidden = true } //either add/remove the error from the main stack if let errorText, shouldShowError { errorLabel.text = errorText errorLabel.surface = surface errorLabel.disabled = disabled mainStackView.spacing = 8 errorLabel.isHidden = false } else { mainStackView.spacing = 0 errorLabel.isHidden = true } } open override func reset() { super.reset() label.reset() childLabel.reset() errorLabel.reset() label.typograpicalStyle = .BoldBodyLarge childLabel.typograpicalStyle = .BodyLarge errorLabel.typograpicalStyle = .BodyMedium labelText = nil labelTextAttributes = nil labelAttributedText = nil childText = nil childTextAttributes = nil childAttributedText = nil showError = false errorText = nil inputId = nil value = nil dataAnalyticsTrack = nil dataClickStream = nil dataTrack = nil accessibilityHintEnabled = nil accessibilityHintDisabled = nil accessibilityValueEnabled = nil accessibilityValueDisabled = nil accessibilityLabelEnabled = nil accessibilityLabelDisabled = nil isSelected = false updateSelector() setAccessibilityLabel() } /// This will checkbox the state of the Selector and execute the actionBlock if provided. open func toggle() { //removed error if showError && isSelected == false { showError.toggle() } isSelected.toggle() sendActions(for: .valueChanged) } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- open override func updateView() { updateLabels() updateSelector() setAccessibilityHint() setAccessibilityValue(isSelected) setAccessibilityLabel(isSelected) } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- public let checkboxSize = CGSize(width: 20, height: 20) private var checkboxBackgroundColorConfig: ControlColorConfiguration = { var config = ControlColorConfiguration() config.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.selected]) config.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) config.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: [.error]) return config }() private var checkboxBorderColorConfig: ControlColorConfiguration = { var config = ControlColorConfiguration() config.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.selected]) config.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.highlighted]) config.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.error, .highlighted]) config.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: [.normal]) config.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) config.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.normal, .disabled]) config.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.error, .disabled]) config.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: [.error]) return config }() private var checkboxCheckColorConfig: ControlColorConfiguration = { var config = ControlColorConfiguration() config.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .selected) config.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: [.selected, .disabled]) return config }() //-------------------------------------------------- // MARK: - Checkbox View //-------------------------------------------------- /// Manages the appearance of the checkbox. private var shapeLayer: CAShapeLayer? open func getSelectorSize() -> CGSize { return checkboxSize } open func updateSelector() { //get the colors let backgroundColor = checkboxBackgroundColorConfig.getColor(self) let borderColor = checkboxBorderColorConfig.getColor(self) let checkColor = checkboxCheckColorConfig.getColor(self) if let shapeLayer = shapeLayer, let sublayers = layer.sublayers, sublayers.contains(shapeLayer) { shapeLayer.removeFromSuperlayer() self.shapeLayer = nil } selectorView.backgroundColor = backgroundColor selectorView.layer.borderColor = borderColor.cgColor selectorView.layer.cornerRadius = 2.0 selectorView.layer.borderWidth = 1.0 if shapeLayer == nil { let bounds = selectorView.bounds let length = max(bounds.size.height, bounds.size.width) guard length > 0.0, shapeLayer == nil else { return } //draw the checkmark layer 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) let shapeLayer = CAShapeLayer() self.shapeLayer = shapeLayer shapeLayer.frame = bounds layer.addSublayer(shapeLayer) shapeLayer.strokeColor = checkColor.cgColor shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.path = bezierPath.cgPath shapeLayer.lineJoin = .miter shapeLayer.lineWidth = 2 CATransaction.withDisabledAnimations { shapeLayer.strokeEnd = isSelected ? 1 : 0 } } } }