// // 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, 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 } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.textStyle = .boldBodyLarge } open var childLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.textStyle = .bodyLarge } open var errorLabel = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.textPosition = .left $0.textStyle = .bodyMedium } open var selectorView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } open var isAnimated: Bool = true { didSet { didChange() }} 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() } } var _showError: Bool = false open var showError: Bool { get { _showError } set { if !isSelected && _showError != newValue { _showError = newValue 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() }} //-------------------------------------------------- // 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.textStyle = .boldBodyLarge childLabel.textStyle = .bodyLarge errorLabel.textStyle = .bodyMedium labelText = nil labelTextAttributes = nil labelAttributedText = nil childText = nil childTextAttributes = nil childAttributedText = nil showError = false errorText = nil inputId = nil value = nil isSelected = false updateSelector() } /// 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() } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- public let checkboxSize = CGSize(width: 20, height: 20) private var backgroundColorConfig = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .selected) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: [.selected,.highlighted]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) $0.setSurfaceColors(VDSColor.feedbackErrorBackgroundOnlight, VDSColor.feedbackErrorBackgroundOndark, forState: .error) } private var borderColorConfig = ControlColorConfiguration().with { $0.setSurfaceColors(VDSFormControlsColor.borderOnlight, VDSFormControlsColor.borderOndark, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .selected) $0.setSurfaceColors(VDSColor.feedbackErrorOnlight, VDSColor.feedbackErrorOndark, forState: .error) } private var checkColorConfig = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: .selected) $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: [.selected, .disabled]) $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forState: [.selected, .highlighted]) } //-------------------------------------------------- // 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 = backgroundColorConfig.getColor(self) let borderColor = borderColorConfig.getColor(self) let checkColor = checkColorConfig.getColor(self) if let shapeLayer = shapeLayer, let sublayers = layer.sublayers, sublayers.contains(shapeLayer) { shapeLayer.removeFromSuperlayer() self.shapeLayer = nil } selectorView.layer.cornerRadius = VDSFormControls.borderradius selectorView.layer.borderWidth = VDSFormControls.widthBorder 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 } } shapeLayer?.removeAllAnimations() if isAnimated { let animateStrokeEnd = CABasicAnimation(keyPath: "strokeEnd") animateStrokeEnd.timingFunction = CAMediaTimingFunction(name: .linear) animateStrokeEnd.duration = 0.3 animateStrokeEnd.fillMode = .both animateStrokeEnd.isRemovedOnCompletion = false animateStrokeEnd.fromValue = !isSelected ? 1 : 0 animateStrokeEnd.toValue = isSelected ? 1 : 0 self.shapeLayer?.add(animateStrokeEnd, forKey: "strokeEnd") UIView.animate(withDuration: 0.2, delay: 0.1, options: .curveEaseOut, animations: { self.selectorView.backgroundColor = backgroundColor self.selectorView.layer.borderColor = borderColor.cgColor }) } else { CATransaction.withDisabledAnimations { self.shapeLayer?.strokeEnd = isSelected ? 1 : 0 } selectorView.backgroundColor = backgroundColor selectorView.layer.borderColor = borderColor.cgColor } } }