// // 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 getCheckboxBackgroundColor(for disabled: Bool, surface: Surface) -> (on: UIColor, off: UIColor) { if disabled { if surface == .light { return ( on: VDSColor.interactiveDisabledOnlight, off: .clear) } else { return ( on: VDSColor.interactiveDisabledOndark, off: .clear ) } } else { if surface == .light { return ( on: VDSColor.elementsPrimaryOnlight, off: .clear ) } else { return ( on: VDSColor.elementsPrimaryOndark, off: .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.interactiveDisabledOndark, off: VDSColor.interactiveDisabledOnlight ) } } else { if surface == .light { return ( on: VDSColor.elementsPrimaryOnlight, 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.interactiveDisabledOndark } else { return VDSColor.interactiveDisabledOnlight } } else { if surface == .light { return VDSColor.elementsPrimaryOndark } else { return VDSColor.elementsPrimaryOnlight } } } private var mainStackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.distribution = .fill 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: CheckBoxView = { let view = CheckBoxView() 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: 20, height: 20) //-------------------------------------------------- // 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.on) 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 } /// Creates the check mark layer. private func setCheckboxColor(viewModel: ModelType) { let background = getCheckboxBackgroundColor(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.on ? background.on : background.off checkboxView.borderColor = viewModel.on ? border.on : border.off checkboxView.checkColor = checkColor checkboxView.isSelected = viewModel.on } 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 checkboxLabelStackView.subviews.contains(primaryLabel) == false { checkboxLabelStackView.insertArrangedSubview(primaryLabel, at: 0) } } else { primaryLabel.removeFromSuperview() } //bottom label if let childModel = viewModel.childModel { secondaryLabel.set(with: childModel) if checkboxLabelStackView.subviews.contains(secondaryLabel) == false { checkboxLabelStackView.addArrangedSubview(secondaryLabel) } } else { secondaryLabel.removeFromSuperview() } } else { checkboxLabelStackView.removeFromSuperview() } //either add/remove the error from the main stack if let errorModel = model.errorModel, model.shouldShowError { errorLabel.set(with: errorModel) if mainStackView.subviews.contains(errorLabel) == false { 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) } //-------------------------------------------------- // 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.on) setAccessibilityLabel(viewModel.on) isUserInteractionEnabled = !viewModel.disabled setNeedsLayout() layoutIfNeeded() } private class CheckBoxView: UIView { public var borderColor: UIColor = .black public var checkColor: UIColor = .white public var isSelected: Bool = false { didSet { drawShapeLayer() } } /// Manages the appearance of the checkbox. private var shapeLayer: CAShapeLayer? /// Creates the check mark layer. private func drawShapeLayer() { layer.borderColor = borderColor.cgColor layer.cornerRadius = 2.0 shapeLayer?.strokeColor = checkColor.cgColor guard let path = try? checkMarkPath() else { return } if shapeLayer == nil { let shapeLayer = CAShapeLayer() self.shapeLayer = shapeLayer shapeLayer.frame = bounds layer.addSublayer(shapeLayer) shapeLayer.strokeColor = checkColor.cgColor shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.path = path shapeLayer.lineJoin = .miter shapeLayer.lineWidth = 2 CATransaction.withDisabledAnimations { shapeLayer.strokeEnd = isSelected ? 1 : 0 } } } override func layoutSubviews() { super.layoutSubviews() drawShapeLayer() } /// - returns: The CGPath of a UIBezierPath detailing the path of a checkmark private func checkMarkPath() throws -> CGPath { let length = max(bounds.size.height, bounds.size.width) guard length > 0.0 else { throw Error.boundsNotSet } 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 } enum Error: Swift.Error { case boundsNotSet } } }