// // Toggle.swift // VDS // // Created by Matt Bruce on 7/22/22. // 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. @objc(VDSToggle) open class Toggle: Control, Changeable, FormFieldable, ParentViewProtocol { //-------------------------------------------------- // 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: - Enums //-------------------------------------------------- /// Enum for the size of text for the label. public enum TextSize: String, CaseIterable { case small, large } /// Enum to determine of the weight for the text style used for the label. public enum TextWeight: String, CaseIterable { case regular, bold } /// Enum for the text alignment in relation to the toggle view. public enum TextPosition: String, CaseIterable { case left, right } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var leftConstraints: [NSLayoutConstraint] = [] private var rightConstraints: [NSLayoutConstraint] = [] private var labelConstraints: [NSLayoutConstraint] = [] private var toggleConstraints: [NSLayoutConstraint] = [] //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private let toggleContainerSize = CGSize(width: 52, height: 44) private let spacingBetween = VDSLayout.space3X /// TextStyle used to render the label. 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: - Private Properties //-------------------------------------------------- private var showLabel: Bool { showText && !statusText.isEmpty } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var children: [any ViewProtocol] { [toggleView, label] } open var onChangeSubscriber: AnyCancellable? /// Actual toggle used in this component. open var toggleView = ToggleView().with { $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) $0.isUserInteractionEnabled = false $0.isAccessibilityElement = false } /// Used in showing the on/off text. open var label = Label().with { $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) $0.textColorConfiguration = ViewColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark, forDisabled: true) $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forDisabled: false) }.eraseToAnyColorable() } /// State of the toggle view. open var isOn: Bool { get { isSelected } set { if isSelected != newValue { toggleView.isOn = newValue isSelected = newValue } } } /// If set to true, the toggle view will include animation between state changes. open var isAnimated: Bool = true { didSet { setNeedsUpdate() } } /// If set to true, displays text either to the right or left of the toggle. open var showText: Bool = false { didSet { setNeedsUpdate() } } /// Text that will be shown in the status text when isOn is true open var onText: String = "On" { didSet { setNeedsUpdate() } } /// Text that will be shown in the status text when isOn is false open var offText: String = "Off" { didSet { setNeedsUpdate() } } /// Returns the correct text status based on the isOn state and correlates with onText or offText. open var statusText: String { isOn ? onText : offText } /// Changes the font size of the status text. open var textSize: TextSize = .small { didSet { setNeedsUpdate() } } /// Changes the font weight of the status text. open var textWeight: TextWeight = .regular { didSet { setNeedsUpdate() } } /// Positions status text to either the right or left of toggle. open var textPosition: TextPosition = .left { didSet { setNeedsUpdate() } } open var inputId: String? { didSet { setNeedsUpdate() } } open var value: AnyHashable? { isOn } open override var shouldHighlight: Bool { false } //-------------------------------------------------- // 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(label) addSubview(toggleView) // Set up initial constraints for label and switch toggleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true labelConstraints = [ height(constant: toggleContainerSize.height, priority: .defaultLow), heightGreaterThanEqualTo(constant: toggleContainerSize.height, priority: .defaultHigh), label.topAnchor.constraint(equalTo: topAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor), ] leftConstraints = [ toggleView.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: spacingBetween), label.leadingAnchor.constraint(equalTo: leadingAnchor), toggleView.trailingAnchor.constraint(equalTo: trailingAnchor) ] rightConstraints = [ toggleView.leadingAnchor.constraint(equalTo: leadingAnchor), label.leadingAnchor.constraint(equalTo: toggleView.trailingAnchor, constant: spacingBetween), label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor) ] // Set content hugging priority setContentHuggingPriority(.required, for: .horizontal) isAccessibilityElement = true if #available(iOS 17.0, *) { accessibilityTraits = .toggleButton } else { accessibilityTraits = .button } addSubview(label) addSubview(toggleView) // Set up initial constraints for label and switch toggleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true //toggle toggleConstraints = [ toggleView.leadingAnchor.constraint(equalTo: leadingAnchor), toggleView.trailingAnchor.constraint(equalTo: trailingAnchor) ] //toggle and label variants labelConstraints = [ height(constant: toggleContainerSize.height, priority: .defaultLow), heightGreaterThanEqualTo(constant: toggleContainerSize.height, priority: .defaultHigh), label.topAnchor.constraint(equalTo: topAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor), ] //label-toggle leftConstraints = [ label.leadingAnchor.constraint(equalTo: leadingAnchor), toggleView.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: spacingBetween), toggleView.trailingAnchor.constraint(equalTo: trailingAnchor) ] //toggle-label rightConstraints = [ toggleView.leadingAnchor.constraint(equalTo: leadingAnchor), label.leadingAnchor.constraint(equalTo: toggleView.trailingAnchor, constant: spacingBetween), label.trailingAnchor.constraint(equalTo: trailingAnchor) ] } open override func setDefaults() { super.setDefaults() onClick = { [weak self] _ in guard let self else { return } toggle() } bridge_accessibilityValueBlock = { [weak self] in guard let self else { return "" } if showText { return isSelected ? onText : offText } else { return isSelected ? "On" : "Off" } } isEnabled = true isOn = false isAnimated = true showText = false onText = "On" offText = "Off" textSize = .small textWeight = .regular textPosition = .left inputId = nil onChange = nil } /// Resets to default settings. open override func reset() { label.reset() super.reset() } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateLabel() toggleView.surface = surface toggleView.isEnabled = isEnabled toggleView.isOn = isOn } /// This will change the state of the Selector and execute the actionBlock if provided. open func toggle() { isOn.toggle() sendActions(for: .valueChanged) } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateLabel() { label.isHidden = !showLabel if showLabel { NSLayoutConstraint.deactivate(toggleConstraints) label.textAlignment = textPosition == .left ? .right : .left label.textStyle = textStyle label.text = statusText label.surface = surface label.isEnabled = isEnabled switch textPosition { case .left: NSLayoutConstraint.deactivate(rightConstraints) NSLayoutConstraint.activate(leftConstraints) case .right: NSLayoutConstraint.deactivate(leftConstraints) NSLayoutConstraint.activate(rightConstraints) } NSLayoutConstraint.activate(labelConstraints) } else { NSLayoutConstraint.deactivate(leftConstraints) NSLayoutConstraint.deactivate(rightConstraints) NSLayoutConstraint.deactivate(labelConstraints) NSLayoutConstraint.activate(toggleConstraints) } } }