// // 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 DefaultToggleModel: DefaultLabelModel, VDSToggleModel, ObservableObject { public var id: String? public var inputId: String? public var disabled: Bool = false public var showText: Bool = false public var on: Bool = false public var offText: String = "Off" public var onText: String = "On" public var value: AnyHashable? = true public var dataAnalyticsTrack: String? public var dataClickStream: String? public var dataTrack: String? public var accessibilityHintEnabled: String? public var accessibilityHintDisabled: String? public var accessibilityValueEnabled: String? public var accessibilityValueDisabled: String? public var accessibilityLabelEnabled: String? public var accessibilityLabelDisabled: String? public required init() { super.init() } } @objcMembers open class VDSToggle: VDSControl, Modelable, Changable { public typealias ModelType = VDSToggleModel @Published public var model: ModelType = DefaultToggleModel() private var cancellable: AnyCancellable? //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- /// Holds the on and off colors for the container. private var containerTintColor: (on: UIColor, off: UIColor) = (on: VDSColor.paletteGreen26, off: VDSColor.paletteGray44) /// Holds the on and off colors for the knob. private var knobTintColor: (on: UIColor, off: UIColor) = (on: VDSColor.paletteWhite, off: VDSColor.paletteWhite) /// Holds the on and off colors for the disabled state.. private var disabledTintColor: (container: UIColor, knob: UIColor) = (container: VDSColor.paletteGray11, knob: VDSColor.paletteWhite) private var showTextSpacing: CGFloat { showText ? 12 : 0 } private var stackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .horizontal stackView.distribution = .fillProportionally return stackView }() private var label: VDSLabel = { let label = VDSLabel() 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: - Public Properties //-------------------------------------------------- public var onChange: Blocks.ActionBlock? //-------------------------------------------------- // MARK: - Static Properties //-------------------------------------------------- // Sizes are from InVision design specs. public static var toggleSize = CGSize(width: 52, height: 24) open class func getToggleScaledSize() -> CGSize { return Self.toggleSize } public static var knobSize = CGSize(width: 20, height: 20) open class func getKnobScaledSize() -> CGSize { return Self.knobSize } //-------------------------------------------------- // MARK: - Computed Properties //-------------------------------------------------- public var showText: Bool { get { model.showText } set { if model.showText != newValue { model.showText = newValue } } } public var onText: String { get { model.onText } set { if model.onText != newValue { model.onText = newValue } } } public var offText: String { get { model.offText } set { if model.offText != newValue { model.offText = newValue } } } public var textPosition: VDSTextPosition { get { model.textPosition } set { if model.textPosition != newValue { model.textPosition = newValue } } } public var fontSize: VDSFontSize { get { model.fontSize } set { if model.fontSize != newValue { model.fontSize = newValue } } } public var fontWeight: VDSFontWeight { get { model.fontWeight } set { if model.fontWeight != newValue { model.fontWeight = newValue } } } public var surface: Surface { get { model.surface } set { if model.surface != newValue { model.surface = newValue } } } 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 } } } /// Simple means to prevent user interaction with the toggle. public var isLocked: Bool = false { didSet { isUserInteractionEnabled = !isLocked } } /// The state on the toggle. Default value: false. open var isOn: Bool { get { model.on } set { if model.on != newValue { model.on = newValue } } } //-------------------------------------------------- // 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? //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override init(frame: CGRect) { super.init(frame: frame) setup() } public convenience override init() { self.init(frame: .zero) setup() } func setup() { cancellable = $model.sink { [weak self] viewModel in self?.onStateChange(viewModel: viewModel) } } //functions //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- public override func updateView(_ size: CGFloat) { super.updateView(size) let containerSize = Self.getToggleScaledSize() let knobSize = Self.getKnobScaledSize() toggleHeightConstraint?.constant = containerSize.height toggleWidthConstraint?.constant = containerSize.width knobHeightConstraint?.constant = knobSize.height knobWidthConstraint?.constant = knobSize.width toggleView.layer.cornerRadius = containerSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 ensureLabel() } public override func setupView() { super.setupView() isAccessibilityElement = true setAccessibilityHint() setAccessibilityLabel() accessibilityTraits = .button addSubview(stackView) let containerSize = Self.getToggleScaledSize() let knobSize = Self.getKnobScaledSize() toggleHeightConstraint = toggleView.heightAnchor.constraint(equalToConstant: containerSize.height) toggleHeightConstraint?.isActive = true toggleWidthConstraint = toggleView.widthAnchor.constraint(equalToConstant: containerSize.width) toggleWidthConstraint?.isActive = true toggleView.layer.cornerRadius = containerSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 toggleView.backgroundColor = containerTintColor.off 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 //setup stackview if showText { stackView.addArrangedSubview(label) } ensureLabel() stackView.addArrangedSubview(toggleView) 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 } func ensureLabel() { stackView.spacing = showTextSpacing if showText { if textPosition == .left { stackView.insertArrangedSubview(label, at: 0) } else { stackView.addArrangedSubview(label) } } else if stackView.subviews.contains(label) { label.removeFromSuperview() } } public override func reset() { super.reset() toggleView.backgroundColor = containerTintColor.off knobView.backgroundColor = knobTintColor.off 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. guard let coordinates = touches.first?.location(in: self), coordinates.x > -20, coordinates.x < bounds.width + 20, coordinates.y > -20, coordinates.y < bounds.height + 20 else { return } sendActions(for: .touchUpInside) } public 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.getKnobScaledSize().width self.layoutIfNeeded() }, completion: nil) } /// Follow the SwiftUI View paradigm /// - Parameter viewModel: state private func onStateChange(viewModel: ModelType) { label.set(with: viewModel) label.text = viewModel.on ? viewModel.onText : viewModel.offText setAccessibilityHint(!viewModel.disabled) setAccessibilityValue(viewModel.on) UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseIn, animations: { if viewModel.on { self.knobView.backgroundColor = self.knobTintColor.on self.toggleView.backgroundColor = self.containerTintColor.on } else { self.knobView.backgroundColor = self.knobTintColor.off self.toggleView.backgroundColor = self.containerTintColor.off } }, completion: nil) UIView.animate(withDuration: 0.33, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.2, options: [], animations: { 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.getKnobScaledSize().width self.layoutIfNeeded() }, completion: nil) backgroundColor = viewModel.surface == .dark ? VDSColor.backgroundPrimaryDark : .clear isUserInteractionEnabled = !viewModel.disabled setNeedsLayout() layoutIfNeeded() } // MARK:- Modable open func set(with model: ModelType) { self.model = model } }