// // ToggleView.swift // VDS // // Created by Matt Bruce on 7/19/23. // import Foundation import UIKit import VDSCoreTokens import Combine /// A toggle is a control that lets customers instantly turn on /// or turn off a single option, setting or function. @objcMembers @objc(VDSToggleView) open class ToggleView: Control, Changeable, FormFieldable { //-------------------------------------------------- // 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 toggleView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.isUserInteractionEnabled = false } private var knobView = UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.backgroundColor = .white $0.isUserInteractionEnabled = false } private let shadowLayer1 = CALayer() private let shadowLayer2 = CALayer() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var onChangeSubscriber: AnyCancellable? /// State of the toggle view. open var isOn: Bool { get { isSelected } set { if isSelected != newValue { isSelected = newValue } setNeedsUpdate() } } /// If set to true, the toggle view will include animation between state changes. open var isAnimated: Bool = true { didSet { setNeedsUpdate() } } open var inputId: String? { didSet { setNeedsUpdate() } } open var value: AnyHashable? { isOn } /// The natural size for the receiving view, considering only properties of the view itself. open override var intrinsicContentSize: CGSize { toggleSize } //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. private let toggleSize = CGSize(width: 52, height: 28) private let knobSize = CGSize(width: 24, height: 24) private var toggleColorConfiguration = ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.paletteGray44, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) $0.setSurfaceColors(VDSColor.paletteGreen26, VDSColor.paletteGreen36, 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.paletteGray95, VDSColor.paletteGray44, forState: [.selected, .disabled]) $0.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOndark, forState: .selected) } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() isAccessibilityElement = true if #available(iOS 17.0, *) { accessibilityTraits = .toggleButton } else { accessibilityTraits = .button } addSubview(toggleView) toggleView.addSubview(knobView) toggleView.pinToSuperView() toggleView .width(toggleSize.width) .height(toggleSize.height) knobView .pinTopGreaterThanOrEqualTo() .width(knobSize.width) .height(knobSize.height) knobView.centerYAnchor.constraint(equalTo: toggleView.centerYAnchor).activate() // Set cornerRadius knobView.layer.cornerRadius = knobSize.height / 2.0 toggleView.layer.cornerRadius = toggleSize.height / 2.0 // Set content hugging priority toggleView.setContentHuggingPriority(.required, for: .horizontal) toggleView.setContentHuggingPriority(.required, for: .vertical) setContentHuggingPriority(.required, for: .horizontal) setContentHuggingPriority(.required, for: .vertical) //knobview dropshadow // Update shadow layers frames to match the view's bounds knobView.layer.insertSublayer(shadowLayer1, at: 0) knobView.layer.insertSublayer(shadowLayer2, at: 0) accessibilityLabel = "Toggle" } open override func setDefaults() { super.setDefaults() isOn = false isAnimated = true inputId = nil toggleView.backgroundColor = toggleColorConfiguration.getColor(self) knobView.backgroundColor = knobColorConfiguration.getColor(self) onChange = nil onClick = { [weak self] _ in guard let self else { return } toggle() } } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateToggle() } /// This will change the state of the Selector and execute the actionBlock if provided. open func toggle() { isOn.toggle() sendActions(for: .valueChanged) } open override func layoutSubviews() { super.layoutSubviews() shadowLayer1.frame = knobView.bounds shadowLayer2.frame = knobView.bounds let shadowColor = VDSColor.paletteBlack.cgColor shadowLayer1.cornerRadius = knobView.layer.cornerRadius shadowLayer1.shadowColor = shadowColor shadowLayer1.shadowRadius = 10.0 shadowLayer1.shadowOffset = .init(width: 0, height: 1) shadowLayer1.shadowOpacity = isEnabled ? 0.24 : 0.12 shadowLayer2.cornerRadius = knobView.layer.cornerRadius shadowLayer2.shadowColor = shadowColor shadowLayer2.shadowRadius = 2.0 shadowLayer2.shadowOffset = .init(width: 0, height: 2) shadowLayer2.shadowOpacity = isEnabled ? 0.08 : 0.04 } //-------------------------------------------------- // MARK: - Private Functions //-------------------------------------------------- private func constrainKnob(){ knobLeadingConstraint?.isActive = false 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 layoutIfNeeded() } private func updateToggle() { let toggleColor = toggleColorConfiguration.getColor(self) let knobColor = knobColorConfiguration.getColor(self) shadowLayer1.backgroundColor = knobColor.cgColor shadowLayer2.backgroundColor = knobColor.cgColor if !isEnabled || !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: AppleGuidelinesTouchable extension ToggleView: AppleGuidelinesTouchable { /// Overrides to ensure that the touch point meets a minimum of the minimumTappableArea. override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { Self.acceptablyOutsideBounds(point: point, bounds: bounds) } }