// // Toggle.swift // VDS // // Created by Matt Bruce on 7/22/22. // import Foundation import UIKit import VDSColorTokens 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 { //-------------------------------------------------- // 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] = [] //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private let toggleContainerSize = CGSize(width: 52, height: 44) private let spacingBetween = VDSLayout.Spacing.space3X.value /// 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 onChangeSubscriber: AnyCancellable? { willSet { if let onChangeSubscriber { onChangeSubscriber.cancel() } } } /// 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? { didSet { setNeedsUpdate() } } /// The natural size for the receiving view, considering only properties of the view itself. open override var intrinsicContentSize: CGSize { if showLabel { label.sizeToFit() let size = CGSize(width: label.frame.width + spacingBetween + toggleContainerSize.width, height: max(toggleContainerSize.height, label.frame.height)) return size } else { return toggleContainerSize } } open override var shouldHighlight: Bool { false } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() onClick = { control in control.toggle() } } /// 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 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) ] } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false label.reset() isEnabled = true isOn = false isAnimated = true showText = false onText = "On" offText = "Off" textSize = .small textWeight = .regular textPosition = .left inputId = nil value = nil shouldUpdateView = true setNeedsUpdate() } /// 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 } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() if showText { accessibilityValue = isSelected ? onText : offText } else { accessibilityValue = isSelected ? "On" : "Off" } } /// 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 { 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) } } }