refactored Toggle / ToggleModel for VDS

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
This commit is contained in:
Matt Bruce 2022-10-21 16:15:54 -05:00
parent b0d8a26688
commit 1073530ee9
2 changed files with 82 additions and 425 deletions

View File

@ -8,8 +8,7 @@
import MVMCore
import UIKit
public typealias ActionBlockConfirmation = () -> (Bool)
import VDS
/**
A custom implementation of Apple's UISwitch.
@ -19,402 +18,102 @@ public typealias ActionBlockConfirmation = () -> (Bool)
Container: The background of the toggle control.
Knob: The circular indicator that slides on the container.
*/
@objcMembers open class Toggle: Control, MVMCoreUIViewConstrainingProtocol {
open class Toggle: ToggleBase, VDSMoleculeViewProtocol {
//--------------------------------------------------
// MARK: - Properties
//--------------------------------------------------
/// Holds the on and off colors for the container.
public var containerTintColor: (on: UIColor, off: UIColor) = (on: .mvmGreen, off: .mvmBlack)
/// Holds the on and off colors for the knob.
public var knobTintColor: (on: UIColor, off: UIColor) = (on: .mvmWhite, off: .mvmWhite)
/// Holds the on and off colors for the disabled state..
public var disabledTintColor: (container: UIColor, knob: UIColor) = (container: .mvmCoolGray3, knob: .mvmWhite)
/// Set this flag to false if you do not want to animate state changes.
public var isAnimated = true
public var didToggleAction: ActionBlock?
/// Executes logic before state change. If false, then toggle state will not change and the didToggleAction will not execute.
public var shouldToggleAction: ActionBlockConfirmation? = {
return { true }
}()
// 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: View = {
let view = View()
view.backgroundColor = .white
view.layer.cornerRadius = Toggle.getKnobHeight() / 2.0
return view
}()
//--------------------------------------------------
// MARK: - Computed Properties
//--------------------------------------------------
open override var isEnabled: Bool {
didSet {
isUserInteractionEnabled = isEnabled
changeStateNoAnimation(isEnabled ? isOn : false)
setToggleAppearanceFromState()
accessibilityHint = MVMCoreUIUtility.hardcodedString(withKey: 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()
}
public var viewModel: ToggleModel!
public var delegateObject: MVMCoreUIDelegateObject?
public var additionalData: [AnyHashable: Any]?
toggleModel?.selected = isOn
_ = FormValidator.validate(delegate: delegateObject?.formHolderDelegate)
accessibilityValue = isOn ? MVMCoreUIUtility.hardcodedString(withKey: "AccOn") : MVMCoreUIUtility.hardcodedString(withKey: "AccOff")
setNeedsLayout()
layoutIfNeeded()
}
}
public var toggleModel: ToggleModel? {
model as? ToggleModel
}
//--------------------------------------------------
// MARK: - Delegate
//--------------------------------------------------
private var delegateObject: MVMCoreUIDelegateObject?
//--------------------------------------------------
// 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 override init(frame: CGRect) {
super.init(frame: frame)
}
public convenience override init() {
self.init(frame: .zero)
}
public convenience init(isOn: Bool) {
self.init(frame: .zero)
self.isOn = isOn
}
/// - parameter isOn: Bool to set the state of the toggle.
/// - parameter didToggleAction: A closure which is executed after the toggle changes states.
public convenience init(isOn: Bool = false, didToggleAction: ActionBlock?) {
self.init(frame: .zero)
changeStateNoAnimation(isOn)
self.didToggleAction = didToggleAction
}
/// - parameter shouldToggleAction: Takes a closure that returns a boolean.
/// - parameter didToggleAction: A closure which is executed after the toggle changes states.
public convenience init(shouldToggleAction: ActionBlockConfirmation?, didToggleAction: ActionBlock?) {
self.init(frame: .zero)
self.didToggleAction = didToggleAction
self.shouldToggleAction = shouldToggleAction
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
fatalError("Toggle does not support xib.")
}
//--------------------------------------------------
// 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 = MVMCoreUIUtility.hardcodedString(withKey: "AccToggleHint")
accessibilityLabel = MVMCoreUIUtility.hardcodedString(withKey: "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 = MVMCoreUIUtility.hardcodedString(withKey: "Toggle_buttonlabel")
isAnimated = true
didToggleAction = nil
shouldToggleAction = { return true }
}
class func getContainerWidth() -> CGFloat {
let containerWidth = Self.containerSize.width
return (MFSizeObject(standardSize: containerWidth, standardiPadPortraitSize: CGFloat(Self.containerSize.width * 1.5)))?.getValueBasedOnApplicationWidth() ?? containerWidth
}
class func getContainerHeight() -> CGFloat {
let containerHeight = Self.containerSize.height
return (MFSizeObject(standardSize: containerHeight, standardiPadPortraitSize: CGFloat(Self.containerSize.height * 1.5)))?.getValueBasedOnApplicationWidth() ?? containerHeight
}
class func getKnobWidth() -> CGFloat {
let knobWidth = Self.knobSize.width
return (MFSizeObject(standardSize: knobWidth, standardiPadPortraitSize: CGFloat(Self.knobSize.width * 1.5)))?.getValueBasedOnApplicationWidth() ?? knobWidth
}
class func getKnobHeight() -> CGFloat {
let knobHeight = Self.knobSize.width
return (MFSizeObject(standardSize: knobHeight, standardiPadPortraitSize: CGFloat(Self.knobSize.height * 1.5)))?.getValueBasedOnApplicationWidth() ?? knobHeight
}
//--------------------------------------------------
// 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() {
if let result = shouldToggleAction?(), result {
isOn.toggle()
didToggleAction?()
}
}
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<UITouch>, with event: UIEvent?) {
UIView.animate(withDuration: 0.1, animations: {
self.knobWidthConstraint?.constant += PaddingOne
self.layoutIfNeeded()
})
}
public override func touchesEnded(_ touches: Set<UITouch>, 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<UITouch>, 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()
}
}
open override func initialSetup() {
super.initialSetup()
// MARK:- MoleculeViewProtocol
public override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) {
super.set(with: model, delegateObject, additionalData)
self.delegateObject = delegateObject
guard let model = model as? ToggleModel else { return }
FormValidator.setupValidation(for: model, delegate: delegateObject?.formHolderDelegate)
containerTintColor.on = model.onTintColor.uiColor
containerTintColor.off = model.offTintColor.uiColor
knobTintColor.on = model.onKnobTintColor.uiColor
knobTintColor.off = model.offKnobTintColor.uiColor
isOn = model.selected
changeStateNoAnimation(isOn)
isAnimated = model.animated
isEnabled = model.enabled && !model.readOnly
if let accessibileString = model.accessibilityText {
accessibilityLabel = accessibileString
}
if model.action != nil || model.alternateAction != nil {
didToggleAction = { [weak self] in
publisher(for: .touchUpInside)
.sink {[weak self] toggle in
guard let self = self else { return }
if self.isOn {
if let action = model.action {
MVMCoreUIActionHandler.performActionUnstructured(with: action, sourceModel: model, additionalData: additionalData, delegateObject: delegateObject)
}
} else {
if let action = model.alternateAction ?? model.action {
MVMCoreUIActionHandler.performActionUnstructured(with: action, sourceModel: model, additionalData: additionalData, delegateObject: delegateObject)
}
}
self.toggle()
}.store(in: &subscribers)
publisher(for: .valueChanged)
.sink {[weak self] toggle in
guard let self = self else { return }
self.valueChanged(isOn: toggle.isOn)
}.store(in: &subscribers)
accessibilityLabelEnabled = MVMCoreUIUtility.hardcodedString(withKey: "Toggle_buttonlabel")
accessibilityLabelDisabled = MVMCoreUIUtility.hardcodedString(withKey: "Toggle_buttonlabel")
accessibilityHintEnabled = MVMCoreUIUtility.hardcodedString(withKey: "AccToggleHint")
accessibilityHintDisabled = MVMCoreUIUtility.hardcodedString(withKey: "AccDisabled")
accessibilityValueEnabled = MVMCoreUIUtility.hardcodedString(withKey: "AccOn")
accessibilityValueDisabled = MVMCoreUIUtility.hardcodedString(withKey: "AccOff")
}
open func updateView(_ size: CGFloat) {}
open override func updateView() {
super.updateView()
backgroundColor = .clear
}
open func viewModelDidUpdate() {
guard let viewModel else { return }
FormValidator.setupValidation(for: viewModel, delegate: delegateObject?.formHolderDelegate)
additionalData = additionalData.dictionaryAdding(key: KeySourceModel, value: viewModel)
}
private func valueChanged(isOn: Bool){
guard let viewModel else { return }
//sync the value on the viewModel
viewModel.selected = isOn
//tell the form you changed
_ = FormValidator.validate(delegate: self.delegateObject?.formHolderDelegate)
if viewModel.action != nil || viewModel.alternateAction != nil {
var action: ActionModelProtocol?
if isOn {
action = viewModel.action
} else {
action = viewModel.alternateAction ?? viewModel.action
}
if let action {
MVMCoreUIActionHandler.performActionUnstructured(with: action,
sourceModel: viewModel,
additionalData: additionalData,
delegateObject: delegateObject)
}
}
}
public override class func estimatedHeight(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?) -> CGFloat? {
Self.getContainerHeight()
public static func estimatedHeight(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?) -> CGFloat? {
return 44
}
private typealias ActionDefinition = (model: ActionModelProtocol,
sourceModel: MoleculeModelProtocol?)
private func performActionUnstructured(definition: ActionDefinition) {
MVMCoreUIActionHandler.performActionUnstructured(with: definition.model,
sourceModel: definition.sourceModel,
additionalData: additionalData,
delegateObject: delegateObject)
}
}
// MARK: - MVMCoreUIViewConstrainingProtocol
extension Toggle {
extension Toggle: MVMCoreUIViewConstrainingProtocol {
public func needsToBeConstrained() -> Bool { true }
public func horizontalAlignment() -> UIStackView.Alignment { .trailing }
}

View File

@ -13,20 +13,14 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol {
//--------------------------------------------------
public static var identifier: String = "toggle"
public var accessibilityIdentifier: String?
public var backgroundColor: Color?
public var backgroundColor: Color? //not used
public var selected: Bool = false
public var animated: Bool = true
public var enabled: Bool = true
public var readOnly: Bool = false
public var action: ActionModelProtocol?
public var alternateAction: ActionModelProtocol?
public var accessibilityText: String?
public var onTintColor: Color = Color(uiColor: .mvmGreen)
public var offTintColor: Color = Color(uiColor: .mvmBlack)
public var onKnobTintColor: Color = Color(uiColor: .mvmWhite)
public var offKnobTintColor: Color = Color(uiColor: .mvmWhite)
public var fieldKey: String?
public var groupName: String = FormValidator.defaultGroupName
public var baseValue: AnyHashable?
@ -34,22 +28,16 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol {
//--------------------------------------------------
// MARK: - Keys
//--------------------------------------------------
private enum CodingKeys: String, CodingKey {
case moleculeName
case state
case animated
case enabled
case readOnly
case action
case backgroundColor
case accessibilityIdentifier
case alternateAction
case accessibilityText
case onTintColor
case offTintColor
case onKnobTintColor
case offKnobTintColor
case fieldKey
case groupName
}
@ -75,7 +63,7 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol {
//--------------------------------------------------
public init(_ state: Bool) {
self.selected = state
selected = state
baseValue = state
}
@ -87,60 +75,30 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol {
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
if let state = try typeContainer.decodeIfPresent(Bool.self, forKey: .state) {
self.selected = state
selected = state
}
if let animated = try typeContainer.decodeIfPresent(Bool.self, forKey: .animated) {
self.animated = animated
}
action = try typeContainer.decodeModelIfPresent(codingKey: .action)
alternateAction = try typeContainer.decodeModelIfPresent(codingKey: .alternateAction)
backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor)
accessibilityIdentifier = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityIdentifier)
if let onTintColor = try typeContainer.decodeIfPresent(Color.self, forKey: .onTintColor) {
self.onTintColor = onTintColor
}
if let offTintColor = try typeContainer.decodeIfPresent(Color.self, forKey: .offTintColor) {
self.offTintColor = offTintColor
}
if let onKnobTintColor = try typeContainer.decodeIfPresent(Color.self, forKey: .onKnobTintColor) {
self.onKnobTintColor = onKnobTintColor
}
if let offKnobTintColor = try typeContainer.decodeIfPresent(Color.self, forKey: .offKnobTintColor) {
self.offKnobTintColor = offKnobTintColor
}
accessibilityText = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityText)
baseValue = selected
fieldKey = try typeContainer.decodeIfPresent(String.self, forKey: .fieldKey)
if let groupName = try typeContainer.decodeIfPresent(String.self, forKey: .groupName) {
self.groupName = groupName
if let gName = try typeContainer.decodeIfPresent(String.self, forKey: .groupName) {
groupName = gName
}
enabled = try typeContainer.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
readOnly = try typeContainer.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor)
try container.encodeIfPresent(accessibilityIdentifier, forKey: .accessibilityIdentifier)
try container.encodeModelIfPresent(action, forKey: .action)
try container.encodeModelIfPresent(alternateAction, forKey: .alternateAction)
try container.encode(moleculeName, forKey: .moleculeName)
try container.encode(selected, forKey: .state)
try container.encode(animated, forKey: .animated)
try container.encode(enabled, forKey: .enabled)
try container.encode(onTintColor, forKey: .onTintColor)
try container.encode(onKnobTintColor, forKey: .onKnobTintColor)
try container.encode(onKnobTintColor, forKey: .onKnobTintColor)
try container.encode(offKnobTintColor, forKey: .offKnobTintColor)
try container.encodeIfPresent(accessibilityText, forKey: .accessibilityText)
try container.encodeIfPresent(fieldKey, forKey: .fieldKey)
try container.encodeIfPresent(groupName, forKey: .groupName)
try container.encode(readOnly, forKey: .readOnly)