234 lines
6.7 KiB
Swift
234 lines
6.7 KiB
Swift
//
|
|
// AnimatedSwitch.swift
|
|
// lottie-swift
|
|
//
|
|
// Created by Brandon Withrow on 2/4/19.
|
|
//
|
|
|
|
import Foundation
|
|
#if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst)
|
|
import UIKit
|
|
|
|
/// An interactive switch with an 'On' and 'Off' state. When the user taps on the
|
|
/// switch the state is toggled and the appropriate animation is played.
|
|
///
|
|
/// Both the 'On' and 'Off' have an animation play range associated with their state.
|
|
open class AnimatedSwitch: AnimatedControl {
|
|
|
|
// MARK: Lifecycle
|
|
|
|
public override init(
|
|
animation: Animation,
|
|
configuration: LottieConfiguration = .shared)
|
|
{
|
|
/// Generate a haptic generator if available.
|
|
#if os(iOS)
|
|
if #available(iOS 10.0, *) {
|
|
self.hapticGenerator = HapticGenerator()
|
|
} else {
|
|
hapticGenerator = NullHapticGenerator()
|
|
}
|
|
#else
|
|
hapticGenerator = NullHapticGenerator()
|
|
#endif
|
|
super.init(animation: animation, configuration: configuration)
|
|
isAccessibilityElement = true
|
|
updateOnState(isOn: _isOn, animated: false, shouldFireHaptics: false)
|
|
}
|
|
|
|
public override init() {
|
|
/// Generate a haptic generator if available.
|
|
#if os(iOS)
|
|
if #available(iOS 10.0, *) {
|
|
self.hapticGenerator = HapticGenerator()
|
|
} else {
|
|
hapticGenerator = NullHapticGenerator()
|
|
}
|
|
#else
|
|
hapticGenerator = NullHapticGenerator()
|
|
#endif
|
|
super.init()
|
|
isAccessibilityElement = true
|
|
updateOnState(isOn: _isOn, animated: false, shouldFireHaptics: false)
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
/// Generate a haptic generator if available.
|
|
#if os(iOS)
|
|
if #available(iOS 10.0, *) {
|
|
self.hapticGenerator = HapticGenerator()
|
|
} else {
|
|
hapticGenerator = NullHapticGenerator()
|
|
}
|
|
#else
|
|
hapticGenerator = NullHapticGenerator()
|
|
#endif
|
|
super.init(coder: aDecoder)
|
|
isAccessibilityElement = true
|
|
}
|
|
|
|
// MARK: Open
|
|
|
|
open override func animationDidSet() {
|
|
updateOnState(isOn: _isOn, animated: animateUpdateWhenChangingAnimation, shouldFireHaptics: false)
|
|
}
|
|
|
|
open override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
|
super.endTracking(touch, with: event)
|
|
updateOnState(isOn: !_isOn, animated: true, shouldFireHaptics: true)
|
|
sendActions(for: .valueChanged)
|
|
}
|
|
|
|
// MARK: Public
|
|
|
|
/// Defines what happens when the user taps the switch while an
|
|
/// animation is still in flight
|
|
public enum CancelBehavior {
|
|
case reverse // default - plays the current animation in reverse
|
|
case none // does not update the animation when canceled
|
|
}
|
|
|
|
/// The cancel behavior for the switch. See CancelBehavior for options
|
|
public var cancelBehavior: CancelBehavior = .reverse
|
|
|
|
/// If `false` the switch will not play the animation when changing between animations.
|
|
public var animateUpdateWhenChangingAnimation = true
|
|
|
|
public override var accessibilityTraits: UIAccessibilityTraits {
|
|
set { super.accessibilityTraits = newValue }
|
|
get { super.accessibilityTraits.union(.button) }
|
|
}
|
|
|
|
/// The current state of the switch.
|
|
public var isOn: Bool {
|
|
set {
|
|
/// This is forwarded to a private variable because the animation needs to be updated without animation when set externally and with animation when set internally.
|
|
guard _isOn != newValue else { return }
|
|
updateOnState(isOn: newValue, animated: false, shouldFireHaptics: false)
|
|
}
|
|
get {
|
|
_isOn
|
|
}
|
|
}
|
|
|
|
/// Set the state of the switch and specify animation and haptics
|
|
public func setIsOn(_ isOn: Bool, animated: Bool, shouldFireHaptics: Bool = true) {
|
|
guard isOn != _isOn else { return }
|
|
updateOnState(isOn: isOn, animated: animated, shouldFireHaptics: shouldFireHaptics)
|
|
}
|
|
|
|
/// Sets the play range for the given state. When the switch is toggled, the animation range is played.
|
|
public func setProgressForState(
|
|
fromProgress: AnimationProgressTime,
|
|
toProgress: AnimationProgressTime,
|
|
forOnState: Bool)
|
|
{
|
|
if forOnState {
|
|
onStartProgress = fromProgress
|
|
onEndProgress = toProgress
|
|
} else {
|
|
offStartProgress = fromProgress
|
|
offEndProgress = toProgress
|
|
}
|
|
|
|
updateOnState(isOn: _isOn, animated: false, shouldFireHaptics: false)
|
|
}
|
|
|
|
// MARK: Internal
|
|
|
|
// MARK: Animation State
|
|
|
|
func updateOnState(isOn: Bool, animated: Bool, shouldFireHaptics: Bool) {
|
|
_isOn = isOn
|
|
var startProgress = isOn ? onStartProgress : offStartProgress
|
|
var endProgress = isOn ? onEndProgress : offEndProgress
|
|
let finalProgress = endProgress
|
|
|
|
if cancelBehavior == .reverse {
|
|
let realtimeProgress = animationView.realtimeAnimationProgress
|
|
|
|
let previousStateStart = isOn ? offStartProgress : onStartProgress
|
|
let previousStateEnd = isOn ? offEndProgress : onEndProgress
|
|
if
|
|
realtimeProgress.isInRange(
|
|
min(previousStateStart, previousStateEnd),
|
|
max(previousStateStart, previousStateEnd))
|
|
{
|
|
/// Animation is currently in the previous time range. Reverse the previous play.
|
|
startProgress = previousStateEnd
|
|
endProgress = previousStateStart
|
|
}
|
|
}
|
|
|
|
updateAccessibilityLabel()
|
|
|
|
guard animated == true else {
|
|
animationView.currentProgress = finalProgress
|
|
return
|
|
}
|
|
|
|
if shouldFireHaptics {
|
|
hapticGenerator.generateImpact()
|
|
}
|
|
|
|
animationView.play(
|
|
fromProgress: startProgress,
|
|
toProgress: endProgress,
|
|
loopMode: LottieLoopMode.playOnce,
|
|
completion: { [weak self] finished in
|
|
guard let self = self else { return }
|
|
|
|
// For the Main Thread rendering engine, we freeze the animation at the expected final progress
|
|
// once the animation is complete. This isn't necessary on the Core Animation engine.
|
|
if finished, !(self.animationView.animationLayer is CoreAnimationLayer) {
|
|
self.animationView.currentProgress = finalProgress
|
|
}
|
|
})
|
|
}
|
|
|
|
// MARK: Fileprivate
|
|
|
|
fileprivate var onStartProgress: CGFloat = 0
|
|
fileprivate var onEndProgress: CGFloat = 1
|
|
fileprivate var offStartProgress: CGFloat = 1
|
|
fileprivate var offEndProgress: CGFloat = 0
|
|
fileprivate var _isOn = false
|
|
fileprivate var hapticGenerator: ImpactGenerator
|
|
|
|
// MARK: Private
|
|
|
|
private func updateAccessibilityLabel() {
|
|
accessibilityValue = _isOn ? NSLocalizedString("On", comment: "On") : NSLocalizedString("Off", comment: "Off")
|
|
}
|
|
|
|
}
|
|
#endif
|
|
|
|
// MARK: - ImpactGenerator
|
|
|
|
protocol ImpactGenerator {
|
|
func generateImpact()
|
|
}
|
|
|
|
// MARK: - NullHapticGenerator
|
|
|
|
class NullHapticGenerator: ImpactGenerator {
|
|
func generateImpact() { }
|
|
}
|
|
|
|
#if os(iOS)
|
|
@available(iOS 10.0, *)
|
|
class HapticGenerator: ImpactGenerator {
|
|
|
|
// MARK: Internal
|
|
|
|
func generateImpact() {
|
|
impact.impactOccurred()
|
|
}
|
|
|
|
// MARK: Fileprivate
|
|
|
|
fileprivate let impact = UIImpactFeedbackGenerator(style: .light)
|
|
}
|
|
#endif
|