// // 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. */ @objc(VDSToggle) open class Toggle: Control, Changeable { //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- public enum TextSize: String, CaseIterable { case small, large } public enum TextWeight: String, CaseIterable { case regular, bold } public enum TextPosition: String, CaseIterable { case left, right } //-------------------------------------------------- // 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 stackView = UIStackView().with { $0.isUserInteractionEnabled = false $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill } private var toggleView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } private var knobView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.backgroundColor = .white } //-------------------------------------------------- // 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 = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.paletteGray44, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.paletteGreen26, VDSColor.paletteGreen34, forState: .selected) } private var knobColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOndark, forState: .normal) $0.setSurfaceColors(VDSColor.paletteGray95, VDSColor.paletteGray44, forState: .disabled) $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOndark, forState: .selected) } private var textStyle: TextStyle { if textSize == .small { if textWeight == .bold { return .boldBodySmall } else { return .bodySmall } } else { if textWeight == .bold { return .boldBodyLarge } else { return .bodyLarge } } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var onChangeSubscriber: AnyCancellable? { willSet { if let onChangeSubscriber { onChangeSubscriber.cancel() } } } open var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) } open var isOn: Bool { get { isSelected } set { if isSelected != newValue { isSelected = newValue } didChange() } } open var isAnimated: Bool = true { didSet { didChange() }} open var showText: Bool = false { didSet { didChange() }} open var onText: String = "On" { didSet { didChange() }} open var offText: String = "Off" { didSet { didChange() }} open var textSize: TextSize = .small { didSet { didChange() }} open var textWeight: TextWeight = .regular { didSet { didChange() }} open var textPosition: TextPosition = .left { didSet { didChange() }} open var inputId: String? { didSet { didChange() }} open var value: AnyHashable? { didSet { didChange() }} //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? //functions //-------------------------------------------------- // MARK: - Toggle //-------------------------------------------------- private func constrainKnob(){ self.knobLeadingConstraint?.isActive = false self.knobTrailingConstraint?.isActive = false if isOn { knobTrailingConstraint = toggleView.trailingAnchor.constraint(equalTo: knobView.trailingAnchor, constant: 2) knobLeadingConstraint = knobView.leadingAnchor.constraint(greaterThanOrEqualTo: toggleView.leadingAnchor) } else { knobTrailingConstraint = toggleView.trailingAnchor.constraint(greaterThanOrEqualTo: knobView.trailingAnchor) knobLeadingConstraint = knobView.leadingAnchor.constraint(equalTo: toggleView.leadingAnchor, constant: 2) } knobTrailingConstraint?.isActive = true knobLeadingConstraint?.isActive = true self.layoutIfNeeded() } private func updateToggle() { let toggleColor = toggleColorConfiguration.getColor(self) let knobColor = knobColorConfiguration.getColor(self) if disabled || !isAnimated { 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: { [weak self] in self?.constrainKnob() }, completion: nil) } } //-------------------------------------------------- // MARK: - Labels //-------------------------------------------------- private func updateLabel() { stackView.spacing = showText ? 12 : 0 if stackView.subviews.contains(label) { label.removeFromSuperview() } if showText { label.textPosition = textPosition == .left ? .left : .right label.textStyle = textStyle label.text = isOn ? onText : offText label.surface = surface label.disabled = disabled if textPosition == .left { stackView.insertArrangedSubview(label, at: 0) } else { stackView.addArrangedSubview(label) } } } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func initialSetup() { super.initialSetup() onClick = { control in control.toggle() } } open override func setup() { super.setup() isAccessibilityElement = true accessibilityTraits = .button addSubview(stackView) //set the h/w to container size, since the width "can" grow if text is there //allow this to be greaterThanEqualTo heightAnchor.constraint(equalToConstant: toggleContainerSize.height).isActive = true widthAnchor.constraint(greaterThanOrEqualToConstant: toggleContainerSize.width).isActive = true //create the container for the toggle/knob 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 //adding views toggleContainerView.addSubview(toggleView) toggleView.addSubview(knobView) stackView.addArrangedSubview(toggleContainerView) //adding constraints toggleView.heightAnchor.constraint(equalToConstant: toggleSize.height).isActive = true toggleView.widthAnchor.constraint(equalToConstant: toggleSize.width).isActive = true toggleView.layer.cornerRadius = toggleSize.height / 2.0 toggleView.bottomAnchor.constraint(greaterThanOrEqualTo: knobView.bottomAnchor).isActive = true toggleView.centerXAnchor.constraint(equalTo: toggleContainerView.centerXAnchor).isActive = true toggleView.centerYAnchor.constraint(equalTo: toggleContainerView.centerYAnchor).isActive = true knobView.layer.cornerRadius = knobSize.height / 2.0 knobView.heightAnchor.constraint(equalToConstant: knobSize.height).isActive = true knobView.widthAnchor.constraint(equalToConstant: knobSize.width).isActive = true knobView.centerYAnchor.constraint(equalTo: toggleView.centerYAnchor).isActive = true knobView.topAnchor.constraint(greaterThanOrEqualTo: toggleView.topAnchor).isActive = true //pin stackview to edges stackView.pinToSuperView() } open override func reset() { super.reset() label.reset() isOn = false isAnimated = true showText = false onText = "On" offText = "Off" textSize = .small textWeight = .regular textPosition = .left inputId = nil value = nil toggleView.backgroundColor = toggleColorConfiguration.getColor(self) knobView.backgroundColor = knobColorConfiguration.getColor(self) } /// This will toggle the state of the Toggle and execute the actionBlock if provided. open func toggle() { isOn.toggle() sendActions(for: .valueChanged) } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- open override func updateView() { updateLabel() updateToggle() updateAccessibilityLabel() } open override func updateAccessibilityLabel() { setAccessibilityLabel(for: [label]) } }