// // 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, Modelable, Changable { public typealias ModelType = ToggleModelProtocol //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- public var model: ModelType? /// 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. static let containerSize = CGSize(width: 51, height: 31) static let knobSize = CGSize(width: 28, height: 28) private var knobView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .white view.layer.cornerRadius = VDSToggle.getKnobHeight() / 2.0 return view }() //-------------------------------------------------- // MARK: - Computed Properties //-------------------------------------------------- open override var isEnabled: Bool { didSet { isUserInteractionEnabled = isEnabled changeStateNoAnimation(isEnabled ? isOn : false) setToggleAppearanceFromState() accessibilityHint = VDSHelper.localizedString?(isEnabled ? "AccToggleHint" : "AccDisabled") } } /// 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.getKnobWidth() self.layoutIfNeeded() }, completion: nil) } else { setToggleAppearanceFromState() self.constrainKnob() } model?.on = isOn accessibilityValue = VDSHelper.localizedString?(isOn ? "AccOn" : "AccOff") 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(model: ToggleModelProtocol) { self.model = model super.init() } 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) heightConstraint?.constant = Self.getContainerHeight() widthConstraint?.constant = Self.getContainerWidth() knobHeightConstraint?.constant = Self.getKnobHeight() knobWidthConstraint?.constant = Self.getKnobWidth() layer.cornerRadius = Self.getContainerHeight() / 2.0 knobView.layer.cornerRadius = Self.getKnobHeight() / 2.0 changeStateNoAnimation(isOn) } public override func setupView() { super.setupView() isAccessibilityElement = true accessibilityHint = VDSHelper.localizedString?( "AccToggleHint") accessibilityLabel = VDSHelper.localizedString?( "Toggle_buttonlabel") accessibilityTraits = .button heightConstraint = heightAnchor.constraint(equalToConstant: Self.containerSize.height) heightConstraint?.isActive = true widthConstraint = widthAnchor.constraint(equalToConstant: Self.containerSize.width) widthConstraint?.isActive = true layer.cornerRadius = Self.getContainerHeight() / 2.0 backgroundColor = containerTintColor.off addSubview(knobView) knobHeightConstraint = knobView.heightAnchor.constraint(equalToConstant: Self.knobSize.height) knobHeightConstraint?.isActive = true knobWidthConstraint = knobView.widthAnchor.constraint(equalToConstant: Self.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 accessibilityLabel = VDSHelper.localizedString?( "Toggle_buttonlabel") isAnimated = true onChange = nil } public static func getContainerWidth() -> CGFloat { let size = Self.containerSize.width guard let block = VDSHelper.sizeForDevice else { return size } return block(size, .iPadPortrait, CGFloat(size * 1.5)) } public static func getContainerHeight() -> CGFloat { let size = Self.containerSize.height guard let block = VDSHelper.sizeForDevice else { return size } return block(size, .iPadPortrait, CGFloat(size * 1.5)) } public static func getKnobWidth() -> CGFloat { let size = Self.knobSize.width guard let block = VDSHelper.sizeForDevice else { return size } return block(size, .iPadPortrait, CGFloat(size * 1.5)) } public static func getKnobHeight() -> CGFloat { let size = Self.knobSize.width guard let block = VDSHelper.sizeForDevice else { return size } return block(size, .iPadPortrait, CGFloat(size * 1.5)) } //-------------------------------------------------- // 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() { if isAnimated { UIView.animate(withDuration: 0.1, animations: { self.knobWidthConstraint?.constant = Self.getKnobWidth() self.layoutIfNeeded() }, completion: nil) } else { knobWidthConstraint?.constant = Self.getKnobWidth() layoutIfNeeded() } } // MARK:- MoleculeViewProtocol open func set(with model: ModelType) { self.model = model isOn = model.on changeStateNoAnimation(isOn) isAnimated = true isEnabled = !model.disabled } }