// // VDSToggleVM.swift // JSONCreator // // Created by Matt Bruce on 10/12/22. // Copyright © 2022 Verizon Wireless. All rights reserved. // import Foundation import UIKit import VDSColorTokens import Combine import VDS import MVMCore import MVMCoreUI ///----------------------------------------------------------------------------- ///MARK: -- VDSVMMoleculeViewProtocol (Contract between VDS -> Atomic ///----------------------------------------------------------------------------- public protocol VDSVMMoleculeViewProtocol: MoleculeViewProtocol, MVMCoreViewProtocol, ViewModelHandlerable { var delegateObject: MVMCoreUIDelegateObject? { get set } var additionalData: [AnyHashable: Any]? { get set } func viewModelDidSet() } extension VDSVMMoleculeViewProtocol { public func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { guard let castedModel = model as? ModelType else { return } self.delegateObject = delegateObject viewModel.set(with: castedModel) viewModelDidSet() } } ///----------------------------------------------------------------------------- ///MARK: -- ViewModelHandlerable Protocol (replaces existing ModelHandler ///----------------------------------------------------------------------------- public protocol ViewModelHandlerable: AnyObject, Initable { associatedtype ModelType: Modelable associatedtype ViewModelType: ViewModel var viewModel: ViewModelType { get set } var subscribers: Set { get set } init(with model: ModelType) func set(with model: ModelType) func shouldUpdateView(model: ModelType) -> Bool func updateView() } extension ViewModelHandlerable { public init() { self.init(with: ModelType()) } public func set(with model: ModelType) { if shouldUpdateView(model: model){ viewModel.set(with: model) } } public func shouldUpdateView(model: ModelType) -> Bool { self.viewModel.model != model } public func setupUpdateView() { handlerPublisher() .subscribe(on: RunLoop.main) .sink { [weak self] _ in self?.updateView() } .store(in: &subscribers) } public func handlerPublisher() -> AnyPublisher { viewModel .publisher .debounce(for: .seconds(Constants.ModelStateDebounce), scheduler: RunLoop.main) .eraseToAnyPublisher() } } ///----------------------------------------------------------------------------- ///MARK: -- ViewModel Protocol ///----------------------------------------------------------------------------- public protocol ViewModel: AnyObject, Surfaceable, Disabling { associatedtype ModelType: Modelable var model: ModelType { get set } var modelSubject: PassthroughSubject { get set } var publisher: AnyPublisher { get } init(with model: ModelType) func set(with model: ModelType) } ///----------------------------------------------------------------------------- ///MARK: -- ViewModel Generic Base Class ///----------------------------------------------------------------------------- public class ViewModelBase: NSObject, ViewModel, ObservableObject { public var model: ModelType public var modelSubject = PassthroughSubject() public var publisher: AnyPublisher { modelSubject.eraseToAnyPublisher() } required public init(with model: ModelType) { self.model = model modelSubject.send() } public func set(with model: ModelType){ self.model = model modelSubject.send() } @Proxy(\.model.surface) open var surface: Surface { didSet { modelSubject.send() }} @Proxy(\.model.disabled) open var disabled: Bool { didSet { modelSubject.send() }} } ///----------------------------------------------------------------------------- ///MARK: -- ControlViewModelHandler Generic Base Class (Old Control) ///----------------------------------------------------------------------------- open class ControlViewModelHandler: UIControl, ViewModelHandlerable, ViewProtocol, Resettable { public typealias ModelType = ViewModelType.ModelType public var viewModel: ViewModelType = ViewModelType.init(with: ModelType()) //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- public var subscribers = Set() //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- private var initialSetupPerformed = false @Proxy(\.viewModel.surface) open var surface: Surface @Proxy(\.viewModel.disabled) open var disabled: Bool { didSet { self.isEnabled = !disabled } } open override var isEnabled: Bool { get { !viewModel.disabled } set { //create local vars for clear coding let disabled = !newValue if viewModel.disabled != disabled { viewModel.disabled = disabled } isUserInteractionEnabled = isEnabled } } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) initialSetup() } public required init(with model: ModelType) { super.init(frame: .zero) initialSetup() set(with: model) } public override init(frame: CGRect) { super.init(frame: .zero) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Setup //-------------------------------------------------- open func initialSetup() { if !initialSetupPerformed { initialSetupPerformed = true setupUpdateView() setup() } } override open func accessibilityActivate() -> Bool { // Hold state in case User wanted isAnimated to remain off. guard isUserInteractionEnabled else { return false } sendActions(for: .touchUpInside) return true } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open func updateView() { fatalError("Implement updateView") } open func reset() { backgroundColor = .clear // if let model = model as? Resettable { // model.reset() // } } // MARK: - ViewProtocol /// Will be called only once. open func setup() { translatesAutoresizingMaskIntoConstraints = false insetsLayoutMarginsFromSafeArea = false } } ///----------------------------------------------------------------------------- ///MARK: -- Toggle ///----------------------------------------------------------------------------- ///----------------------------------------------------------------------------- ///MARK: -- ToggleViewModel Protocol ///----------------------------------------------------------------------------- public protocol ToggleViewModel: ViewModel where ModelType: VDS.ToggleModel { var isOn: Bool { get set } var showText: Bool { get set } var onText: String { get set } var offText: String { get set } var textSize: ToggleTextSize { get set } var textWeight: ToggleTextWeight { get set } var textPosition: ToggleTextPosition { get set } var knobColor: UIColor { get } var toggleColor: UIColor { get } var labelModel: DefaultLabelModel { get } } ///----------------------------------------------------------------------------- ///MARK: -- ToggleViewModel Generic Base Class (for extending?) ///----------------------------------------------------------------------------- public class ToggleViewModelBase: ViewModelBase, ToggleViewModel { @Proxy(\.model.on) open var isOn: Bool { didSet { modelSubject.send() }} @Proxy(\.model.showText) public var showText: Bool { didSet { modelSubject.send() }} @Proxy(\.model.onText) public var onText: String { didSet { modelSubject.send() }} @Proxy(\.model.offText) public var offText: String { didSet { modelSubject.send() }} @Proxy(\.model.textSize) public var textSize: ToggleTextSize { didSet { modelSubject.send() }} @Proxy(\.model.textWeight) public var textWeight: ToggleTextWeight { didSet { modelSubject.send() }} @Proxy(\.model.textPosition) public var textPosition: ToggleTextPosition { didSet { modelSubject.send() }} public var toggleColor: UIColor { return toggleColorConfiguration.getColor(model) } public var knobColor: UIColor { return toggleColorConfiguration.getColor(model) } public var labelModel: DefaultLabelModel { model.labelModel } private var toggleColorConfiguration = BinaryDisabledSurfaceColorConfiguration().with { $0.forTrue.enabled.lightColor = VDSColor.paletteGreen26 $0.forTrue.enabled.darkColor = VDSColor.paletteGreen34 $0.forTrue.disabled.lightColor = VDSColor.interactiveDisabledOnlight $0.forTrue.disabled.darkColor = VDSColor.interactiveDisabledOndark $0.forFalse.enabled.lightColor = VDSColor.elementsSecondaryOnlight $0.forFalse.enabled.darkColor = VDSColor.paletteGray44 $0.forFalse.disabled.lightColor = VDSColor.interactiveDisabledOnlight $0.forFalse.disabled.darkColor = VDSColor.interactiveDisabledOndark } private var knobColorConfiguration = BinaryDisabledSurfaceColorConfiguration().with { $0.forTrue.enabled.lightColor = VDSColor.elementsPrimaryOndark $0.forTrue.enabled.darkColor = VDSColor.elementsPrimaryOndark $0.forTrue.disabled.lightColor = VDSColor.paletteGray95 $0.forTrue.disabled.darkColor = VDSColor.paletteGray44 $0.forFalse.enabled.lightColor = VDSColor.elementsPrimaryOndark $0.forFalse.enabled.darkColor = VDSColor.elementsPrimaryOndark $0.forFalse.disabled.lightColor = VDSColor.paletteGray95 $0.forFalse.disabled.darkColor = VDSColor.paletteGray44 } } ///----------------------------------------------------------------------------- ///MARK: -- ToggleViewModelHandler Generic Base Class (for extending?) ///----------------------------------------------------------------------------- open class ToggleViewModelHandlerBase: ControlViewModelHandler { //Toggle //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var stackView: UIStackView = { return UIStackView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal $0.distribution = .fill } }() private var label = VDS.Label() private var toggleView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() private var knobView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.backgroundColor = .white } }() //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- // Sizes are from InVision design specs. public let toggleSize = CGSize(width: 52, height: 24) public let toggleContainerSize = CGSize(width: 52, height: 44) public let knobSize = CGSize(width: 20, height: 20) //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- @Proxy(\.viewModel.isOn) open var isOn: Bool @Proxy(\.viewModel.showText) public var showText: Bool @Proxy(\.viewModel.onText) public var onText: String @Proxy(\.viewModel.offText) public var offText: String @Proxy(\.viewModel.textSize) public var textSize: ToggleTextSize @Proxy(\.viewModel.textWeight) public var textWeight: ToggleTextWeight @Proxy(\.viewModel.textPosition) public var textPosition: ToggleTextPosition //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private var knobLeadingConstraint: NSLayoutConstraint? private var knobTrailingConstraint: NSLayoutConstraint? private var knobHeightConstraint: NSLayoutConstraint? private var knobWidthConstraint: NSLayoutConstraint? private var toggleHeightConstraint: NSLayoutConstraint? private var toggleWidthConstraint: NSLayoutConstraint? //functions //-------------------------------------------------- // MARK: - Toggle //-------------------------------------------------- private func updateToggle() { //private func func constrainKnob(){ self.knobLeadingConstraint?.isActive = false self.knobTrailingConstraint?.isActive = false if viewModel.isOn { self.knobTrailingConstraint = self.toggleView.trailingAnchor.constraint(equalTo: self.knobView.trailingAnchor, constant: 2) self.knobLeadingConstraint = self.knobView.leadingAnchor.constraint(greaterThanOrEqualTo: self.toggleView.leadingAnchor) } else { self.knobTrailingConstraint = self.toggleView.trailingAnchor.constraint(greaterThanOrEqualTo: self.knobView.trailingAnchor) self.knobLeadingConstraint = self.knobView.leadingAnchor.constraint(equalTo: self.toggleView.leadingAnchor, constant: 2) } self.knobTrailingConstraint?.isActive = true self.knobLeadingConstraint?.isActive = true self.knobWidthConstraint?.constant = self.knobSize.width self.layoutIfNeeded() } let toggleColor = viewModel.toggleColor let knobColor = viewModel.knobColor if viewModel.disabled { 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: { constrainKnob() }, completion: nil) } } //-------------------------------------------------- // MARK: - Labels //-------------------------------------------------- private func updateLabel() { let showText = viewModel.showText stackView.spacing = showText ? 12 : 0 label.set(with: viewModel.labelModel) if stackView.subviews.contains(label) { label.removeFromSuperview() } if showText { if textPosition == .left { stackView.insertArrangedSubview(label, at: 0) } else { stackView.addArrangedSubview(label) } } } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() translatesAutoresizingMaskIntoConstraints = false insetsLayoutMarginsFromSafeArea = false //add tapGesture to self publisher(for: UITapGestureRecognizer()).sink { [weak self] _ in self?.sendActions(for: .touchUpInside) }.store(in: &subscribers) isAccessibilityElement = true accessibilityTraits = .button addSubview(stackView) //create the wrapping view let toggleContainerView = UIView() toggleContainerView.translatesAutoresizingMaskIntoConstraints = false toggleContainerView.backgroundColor = .clear toggleContainerView.widthAnchor.constraint(equalToConstant: toggleContainerSize.width).isActive = true toggleContainerView.heightAnchor.constraint(equalToConstant: toggleContainerSize.height).isActive = true toggleHeightConstraint = toggleView.heightAnchor.constraint(equalToConstant: toggleSize.height) toggleHeightConstraint?.isActive = true toggleWidthConstraint = toggleView.widthAnchor.constraint(equalToConstant: toggleSize.width) toggleWidthConstraint?.isActive = true toggleView.layer.cornerRadius = toggleSize.height / 2.0 knobView.layer.cornerRadius = knobSize.height / 2.0 toggleView.backgroundColor = viewModel.toggleColor toggleContainerView.addSubview(toggleView) toggleView.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: toggleView.centerYAnchor).isActive = true knobView.topAnchor.constraint(greaterThanOrEqualTo: toggleView.topAnchor).isActive = true toggleView.bottomAnchor.constraint(greaterThanOrEqualTo: knobView.bottomAnchor).isActive = true updateLabel() stackView.addArrangedSubview(toggleContainerView) stackView.topAnchor.constraint(equalTo: topAnchor).isActive = true stackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true stackView.widthAnchor.constraint(greaterThanOrEqualToConstant: toggleContainerSize.width).isActive = true stackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true toggleView.centerXAnchor.constraint(equalTo: toggleContainerView.centerXAnchor).isActive = true toggleView.centerYAnchor.constraint(equalTo: toggleContainerView.centerYAnchor).isActive = true } public override func reset() { super.reset() toggleView.backgroundColor = viewModel.toggleColor knobView.backgroundColor = viewModel.knobColor } /// This will toggle the state of the Toggle and execute the actionBlock if provided. open func toggle() { isOn.toggle() sendActions(for: .valueChanged) } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- open override func updateView() { updateLabel() updateToggle() backgroundColor = viewModel.surface.color setNeedsLayout() layoutIfNeeded() } }