// // Toggle.swift // VDS // // Created by Matt Bruce on 7/22/22. // import Foundation import UIKit import VDSColorTokens /** 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. */ @objcMembers open class VDSToggle: Control, Changable { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- /// Holds the on and off colors for the container. public var containerTintColor: (on: UIColor, off: UIColor) = (on: .green, off: .black) /// Holds the on and off colors for the knob. public var knobTintColor: (on: UIColor, off: UIColor) = (on: .white, off: .white) /// Holds the on and off colors for the disabled state.. public var disabledTintColor: (container: UIColor, knob: UIColor) = (container: .gray, knob: .white) /// Set this flag to false if you do not want to animate state changes. public var isAnimated = true public var onChange: Blocks.ActionBlock? // Sizes are from InVision design specs. public static var containerSize = CGSize(width: 51, height: 31) open class func getContainerScaledSize() -> CGSize { return Self.containerSize } public static var knobSize = CGSize(width: 28, height: 28) open class func getKnobScaledSize() -> CGSize { return Self.knobSize } private var knobView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .white return view }() //-------------------------------------------------- // MARK: - Computed Properties //-------------------------------------------------- open override var isEnabled: Bool { didSet { isUserInteractionEnabled = isEnabled changeStateNoAnimation(isEnabled ? isOn : false) setToggleAppearanceFromState() setAccessibilityHint(isEnabled) } } /// 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 = false { didSet { if isAnimated { UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseIn, animations: { if self.isOn { self.knobView.backgroundColor = self.knobTintColor.on self.backgroundColor = self.containerTintColor.on } else { self.knobView.backgroundColor = self.knobTintColor.off self.backgroundColor = self.containerTintColor.off } }, completion: nil) UIView.animate(withDuration: 0.33, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.2, options: [], animations: { self.constrainKnob() self.knobWidthConstraint?.constant = Self.getKnobScaledSize().width self.layoutIfNeeded() }, completion: nil) } else { setToggleAppearanceFromState() self.constrainKnob() } model?.on = isOn setAccessibilityValue(isOn) setNeedsLayout() layoutIfNeeded() } } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? private var knobHeightConstraint: NSLayoutConstraint? private var knobWidthConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? private var widthConstraint: NSLayoutConstraint? private func constrainKnob() { knobLeadingConstraint?.isActive = false knobTrailingConstraint?.isActive = false _ = isOn ? constrainKnobOn() : constrainKnobOff() knobTrailingConstraint?.isActive = true knobLeadingConstraint?.isActive = true } private func constrainKnobOn() { knobTrailingConstraint = trailingAnchor.constraint(equalTo: knobView.trailingAnchor, constant: 2) knobLeadingConstraint = knobView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor) } private func constrainKnobOff() { knobTrailingConstraint = trailingAnchor.constraint(greaterThanOrEqualTo: knobView.trailingAnchor) knobLeadingConstraint = knobView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2) } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override init(frame: CGRect) { super.init(frame: frame) } public convenience override init() { self.init(frame: .zero) } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- public override func updateView(_ size: CGFloat) { super.updateView(size) let containerSize = Self.getContainerScaledSize() let knobSize = Self.getKnobScaledSize() heightConstraint?.constant = containerSize.height widthConstraint?.constant = containerSize.width knobHeightConstraint?.constant = knobSize.height knobWidthConstraint?.constant = knobSize.width layer.cornerRadius = containerSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 changeStateNoAnimation(isOn) } public override func setupView() { super.setupView() let containerSize = Self.getContainerScaledSize() let knobSize = Self.getKnobScaledSize() isAccessibilityElement = true setAccessibilityHint() setAccessibilityLabel() accessibilityTraits = .button heightConstraint = heightAnchor.constraint(equalToConstant: containerSize.height) heightConstraint?.isActive = true widthConstraint = widthAnchor.constraint(equalToConstant: containerSize.width) widthConstraint?.isActive = true layer.cornerRadius = containerSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 backgroundColor = containerTintColor.off 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: centerYAnchor).isActive = true knobView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor).isActive = true bottomAnchor.constraint(greaterThanOrEqualTo: knobView.bottomAnchor).isActive = true constrainKnobOff() } public override func reset() { super.reset() backgroundColor = containerTintColor.off knobView.backgroundColor = knobTintColor.off setAccessibilityLabel() isAnimated = true 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?() } private func changeStateNoAnimation(_ state: Bool) { // Hold state in case User wanted isAnimated to remain off. let isAnimatedState = isAnimated isAnimated = false isOn = state isAnimated = isAnimatedState } override open func accessibilityActivate() -> Bool { // Hold state in case User wanted isAnimated to remain off. guard isUserInteractionEnabled else { return false } let isAnimatedState = isAnimated isAnimated = false sendActions(for: .touchUpInside) isAnimated = isAnimatedState 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 setToggleAppearanceFromState() { backgroundColor = isEnabled ? isOn ? containerTintColor.on : containerTintColor.off : disabledTintColor.container knobView.backgroundColor = isEnabled ? isOn ? knobTintColor.on : knobTintColor.off : disabledTintColor.knob } public func knobReformAnimation() { let knobWidth = Self.getKnobScaledSize().width if isAnimated { UIView.animate(withDuration: 0.1, animations: { self.knobWidthConstraint?.constant = knobWidth self.layoutIfNeeded() }, completion: nil) } else { knobWidthConstraint?.constant = knobWidth layoutIfNeeded() } } // MARK:- MoleculeViewProtocol open override func set(with model: ModelType) { self.model = model isOn = model.on changeStateNoAnimation(isOn) isAnimated = true isEnabled = !model.disabled } }