// // Toggle.swift // VDS // // Created by Matt Bruce on 7/22/22. // import Foundation import UIKit import VDSColorTokens import Combine /** A custom implementation of Apple's UISwitch. By default this class begins in the off state. Container: The background of the toggle control. Knob: The circular indicator that slides on the container. */ public class Toggle: ToggleBase{} open class ToggleBase: Control, Changable { //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var stackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .horizontal stackView.distribution = .fill return stackView }() private var label: Label = { let label = Label() label.translatesAutoresizingMaskIntoConstraints = false return label }() private var toggleView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false return view }() private var knobView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .white return view }() //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. public let toggleSize = CGSize(width: 52, height: 24) public let toggleContainerSize = CGSize(width: 52, height: 44) public let knobSize = CGSize(width: 20, height: 20) private var toggleColorConfiguration: BinaryDisabledSurfaceColorConfiguration = { let config = BinaryDisabledSurfaceColorConfiguration() config.forTrue.enabled.lightColor = VDSColor.paletteGreen26 config.forTrue.enabled.darkColor = VDSColor.paletteGreen34 config.forTrue.disabled.lightColor = VDSColor.interactiveDisabledOnlight config.forTrue.disabled.darkColor = VDSColor.interactiveDisabledOndark config.forFalse.enabled.lightColor = VDSColor.elementsSecondaryOnlight config.forFalse.enabled.darkColor = VDSColor.paletteGray44 config.forFalse.disabled.lightColor = VDSColor.interactiveDisabledOnlight config.forFalse.disabled.darkColor = VDSColor.interactiveDisabledOndark return config } () private var knobColorConfiguration: BinaryDisabledSurfaceColorConfiguration = { let config = BinaryDisabledSurfaceColorConfiguration() config.forTrue.enabled.lightColor = VDSColor.elementsPrimaryOndark config.forTrue.enabled.darkColor = VDSColor.elementsPrimaryOndark config.forTrue.disabled.lightColor = VDSColor.paletteGray95 config.forTrue.disabled.darkColor = VDSColor.paletteGray44 config.forFalse.enabled.lightColor = VDSColor.elementsPrimaryOndark config.forFalse.enabled.darkColor = VDSColor.elementsPrimaryOndark config.forFalse.disabled.lightColor = VDSColor.paletteGray95 config.forFalse.disabled.darkColor = VDSColor.paletteGray44 return config } () //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var onChange: Blocks.ActionBlock? @Proxy(\.model.on) open var isOn: Bool @Proxy(\.model.showText) public var showText: Bool { didSet { if oldValue != showText { updateLabel(model) } } } @Proxy(\.model.onText) public var onText: String @Proxy(\.model.offText) public var offText: String @Proxy(\.model.fontSize) public var fontSize: FontSize @Proxy(\.model.fontWeight) public var fontWeight: FontWeight @Proxy(\.model.textPosition) public var textPosition: TextPosition { didSet { if oldValue != textPosition { updateLabel(model) } } } @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: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? private var knobHeightConstraint: NSLayoutConstraint? private var knobWidthConstraint: NSLayoutConstraint? private var toggleHeightConstraint: NSLayoutConstraint? private var toggleWidthConstraint: NSLayoutConstraint? //functions //-------------------------------------------------- // MARK: - Toggle //-------------------------------------------------- private func updateToggle(_ viewModel: ModelType) { //private func func constrainKnob(){ self.knobLeadingConstraint?.isActive = false self.knobTrailingConstraint?.isActive = false if viewModel.on { self.knobTrailingConstraint = self.toggleView.trailingAnchor.constraint(equalTo: self.knobView.trailingAnchor, constant: 2) self.knobLeadingConstraint = self.knobView.leadingAnchor.constraint(greaterThanOrEqualTo: self.toggleView.leadingAnchor) } else { self.knobTrailingConstraint = self.toggleView.trailingAnchor.constraint(greaterThanOrEqualTo: self.knobView.trailingAnchor) self.knobLeadingConstraint = self.knobView.leadingAnchor.constraint(equalTo: self.toggleView.leadingAnchor, constant: 2) } self.knobTrailingConstraint?.isActive = true self.knobLeadingConstraint?.isActive = true self.knobWidthConstraint?.constant = self.knobSize.width self.layoutIfNeeded() } let toggleColor = toggleColorConfiguration.getColor(viewModel) let knobColor = knobColorConfiguration.getColor(viewModel) if viewModel.disabled { toggleView.backgroundColor = toggleColor knobView.backgroundColor = knobColor constrainKnob() } else { UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseIn, animations: { self.toggleView.backgroundColor = toggleColor self.knobView.backgroundColor = knobColor }, completion: nil) UIView.animate(withDuration: 0.33, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5, options: [], animations: { constrainKnob() }, completion: nil) } } //-------------------------------------------------- // MARK: - Labels //-------------------------------------------------- private func updateLabel(_ viewModel: ModelType) { let showText = viewModel.showText stackView.spacing = showText ? 12 : 0 if stackView.subviews.contains(label) { label.removeFromSuperview() } if showText { if textPosition == .left { stackView.insertArrangedSubview(label, at: 0) } else { stackView.addArrangedSubview(label) } } } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() isAccessibilityElement = true accessibilityTraits = .button addSubview(stackView) //create the wrapping view let toggleContainerView = UIView() toggleContainerView.translatesAutoresizingMaskIntoConstraints = false toggleContainerView.backgroundColor = .clear toggleContainerView.widthAnchor.constraint(equalToConstant: toggleContainerSize.width).isActive = true toggleContainerView.heightAnchor.constraint(equalToConstant: toggleContainerSize.height).isActive = true toggleHeightConstraint = toggleView.heightAnchor.constraint(equalToConstant: toggleSize.height) toggleHeightConstraint?.isActive = true toggleWidthConstraint = toggleView.widthAnchor.constraint(equalToConstant: toggleSize.width) toggleWidthConstraint?.isActive = true toggleView.layer.cornerRadius = toggleSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 toggleView.backgroundColor = toggleColorConfiguration.getColor(model) toggleContainerView.addSubview(toggleView) toggleView.addSubview(knobView) knobHeightConstraint = knobView.heightAnchor.constraint(equalToConstant: knobSize.height) knobHeightConstraint?.isActive = true knobWidthConstraint = knobView.widthAnchor.constraint(equalToConstant: knobSize.width) knobWidthConstraint?.isActive = true knobView.centerYAnchor.constraint(equalTo: toggleView.centerYAnchor).isActive = true knobView.topAnchor.constraint(greaterThanOrEqualTo: toggleView.topAnchor).isActive = true toggleView.bottomAnchor.constraint(greaterThanOrEqualTo: knobView.bottomAnchor).isActive = true updateLabel(model) stackView.addArrangedSubview(toggleContainerView) stackView.topAnchor.constraint(equalTo: topAnchor).isActive = true stackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true stackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true stackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true toggleView.centerXAnchor.constraint(equalTo: toggleContainerView.centerXAnchor).isActive = true toggleView.centerYAnchor.constraint(equalTo: toggleContainerView.centerYAnchor).isActive = true } public override func reset() { super.reset() toggleView.backgroundColor = toggleColorConfiguration.getColor(model) knobView.backgroundColor = knobColorConfiguration.getColor(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 toggle the state of the Toggle 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 //-------------------------------------------------- open override func touchesBegan(_ touches: Set, with event: UIEvent?) { UIView.animate(withDuration: 0.1, animations: { self.knobWidthConstraint?.constant += Constants.PaddingOne self.layoutIfNeeded() }) } public override func touchesEnded(_ touches: Set, with event: UIEvent?) { knobReformAnimation() // Action only occurs of the user lifts up from withing acceptable region of the toggle. let value = 20.0 guard let coordinates = touches.first?.location(in: self) else { return } guard coordinates.x > -value else { return } sendActions(for: .touchUpInside) } open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { knobReformAnimation() sendActions(for: .touchCancel) } //-------------------------------------------------- // MARK: - Animations //-------------------------------------------------- public func knobReformAnimation() { UIView.animate(withDuration: 0.1, animations: { self.knobWidthConstraint?.constant = self.knobSize.width self.layoutIfNeeded() }, completion: nil) } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- /// Follow the SwiftUI View paradigm /// - Parameter viewModel: state open override func shouldUpdateView(viewModel: ModelType) -> Bool { return true } open override func updateView(viewModel: ModelType) { label.set(with: viewModel.label) updateLabel(viewModel) updateToggle(viewModel) setAccessibilityHint(!viewModel.disabled) setAccessibilityValue(viewModel.on) setAccessibilityLabel(viewModel.on) backgroundColor = viewModel.surface.color isUserInteractionEnabled = !viewModel.disabled setNeedsLayout() layoutIfNeeded() } }