Skip to content

Instantly share code, notes, and snippets.

@maximkrouk
Last active June 15, 2020 00:50
Show Gist options
  • Save maximkrouk/4933b2e870b6cb42b9a157a77ed91da1 to your computer and use it in GitHub Desktop.
Save maximkrouk/4933b2e870b6cb42b9a157a77ed91da1 to your computer and use it in GitHub Desktop.
State-Machine based SwiftUI keyframe Animations
import Combine
import Foundation
struct Reducer<State, Event> {
typealias Reduce = (inout State, Event) -> Void
var reduce: Reduce
}
protocol VoidObservable {
var publisher: AnyPublisher<Void, Never> { get }
}
protocol AsyncRunnable {
func run(delay: DispatchTimeInterval, completion: (() -> Void)?)
}
extension VoidObservable where Self: ObservableObject {
var publisher: AnyPublisher<Void, Never> { objectWillChange.map { _ in }.eraseToAnyPublisher() }
}
protocol AnyAnimationDriver: AsyncRunnable, VoidObservable {}
@dynamicMemberLookup
final class AnimationDriver<Animation: StatefulAnimation>: AnyAnimationDriver, ObservableObject {
@Published
private var animation: Animation
var reducer: Reducer<Animation, Animation.Status>
init(_ animation: Animation, reduce: @escaping Reducer<Animation, Animation.Status>.Reduce) {
self.animation = animation
self.reducer = Reducer(reduce: reduce)
}
func run(delay: DispatchTimeInterval = .none, completion: (() -> Void)? = .none) {
DispatchQueue.main.asyncAfter(delay) {
self.run(Array(Animation.Status.allCases), completion: completion)
}
}
private func run(_ statusCollection: [Animation.Status], completion: (() -> Void)? = .none) {
guard let status = statusCollection.first else { return completion?() ?? () }
DispatchQueue.main.asyncAfter(animation.partialDuration) {
self.reducer.reduce(&self.animation, status)
self.run(Array(statusCollection.dropFirst()), completion: completion)
}
}
subscript<T>(dynamicMember keyPath: WritableKeyPath<Animation, T>) -> T {
get { animation[keyPath: keyPath] }
set { animation[keyPath: keyPath] = newValue }
}
}
final class AnimationDriverGroup: AnyAnimationDriver, ObservableObject {
private var subscriptions: Set<AnyCancellable> = []
private(set) var drivers: [AnyAnimationDriver] = []
init(_ drivers: [AnyAnimationDriver] = []) {
overrideDrivers(with: drivers)
}
func overrideDrivers(with drivers: [AnyAnimationDriver]) {
self.drivers = drivers
self.subscriptions.removeAll()
drivers.map(\.publisher)
.forEach { publisher in
publisher
.sink(receiveValue: objectWillChange.send)
.store(in: &subscriptions)
}
}
func run(delay: DispatchTimeInterval = .none, completion: (() -> Void)? = .none) {
DispatchQueue.main.asyncAfter(delay) {
self.run(self.drivers, completion: completion)
}
}
func runInParallel(delay: DispatchTimeInterval = .none, completion: (() -> Void)? = .none) {
DispatchQueue.main.asyncAfter(delay) {
let group = DispatchGroup()
self.drivers.forEach { driver in
group.enter()
driver.run(delay: .none, completion: { group.leave() })
}
group.notify(queue: .main) { completion?() }
}
}
private func run(_ drivers: [AnyAnimationDriver], completion: (() -> Void)?) {
guard let driver = drivers.first else { return completion?() ?? () }
driver.run(delay: .none) {
self.run(Array(drivers.dropFirst()), completion: completion)
}
}
}
import Foundation
extension DispatchTimeInterval: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral {
static var none: Self { .seconds(0) }
public init(integerLiteral value: Int) {
self.init(floatLiteral: Double(value))
}
public init(floatLiteral value: Double) {
self = .microseconds(Int(value * pow(10, 6)))
}
static func seconds(_ value: Double) -> Self { .init(floatLiteral: value) }
}
extension DispatchQueue {
func asyncAfter(_ intervalFromNow: DispatchTimeInterval, execute: @escaping () -> Void) {
asyncAfter(deadline: .now() + intervalFromNow, execute: execute)
}
}
import SwiftUI
extension _ViewModifier {
static func animated<T: View, Animation: StatefulAnimation>(by driver: AnimationDriver<Animation>)
-> _ViewModifier<T, AnyView> {
.init { content in
driver.model.apply(to: content.animation(.none))
.animation(driver.template)
.eraseToAnyView()
}
}
}
import SwiftUI
extension View {
func apply(_ applicable: ViewApplicable) -> some View {
applicable.apply(to: self)
}
}
protocol ViewApplicable {
func apply<T: View>(to view: T) -> AnyView
}
protocol AnimationPartialDispatchDurationProvider {
var partialDuration: DispatchTimeInterval { get }
}
extension AnimationPartialDispatchDurationProvider {
var partialDuration: DispatchTimeInterval { .none }
}
protocol StatefulAnimation: ViewApplicable, AnimationPartialDispatchDurationProvider {
associatedtype Status: CaseIterable
associatedtype Model: ViewApplicable
var model: Model { get set }
var template: SwiftUI.Animation { get set }
}
extension StatefulAnimation {
typealias Tempalte = SwiftUI.Animation
}
extension StatefulAnimation {
subscript<T>(dynamicMember keyPath: WritableKeyPath<Model, T>) -> T {
get { model[keyPath: keyPath] }
set { model[keyPath: keyPath] = newValue }
}
func apply<T>(to view: T) -> AnyView where T : View {
model.apply(to: view)
}
}
protocol StatefulAnimationState {
associatedtype Model: ViewApplicable
var model: Model { get set }
var animation: SwiftUI.Animation { get set }
}
import SwiftUI
extension View {
func eraseToAnyView() -> AnyView { .init(self) }
}
@maximkrouk
Copy link
Author

maximkrouk commented May 30, 2020

Usage

Depends on _ViewModifier

import SwiftUI

enum Animations {
    @dynamicMemberLookup
    struct MyAnimation: StatefulAnimation {
        var model: Model = .init(opacity: 0)
        var template: Tempalte = .linear
        var partialDuration: DispatchTimeInterval = .none
        
        enum Status: CaseIterable {
            case initial
            case middle
            case finished
        }
        
        struct Model: ViewApplicable {
            var opacity: Double
            
            func apply<T: View>(to view: T) -> AnyView {
                view.opacity(opacity).eraseToAnyView()
            }
        }
        
        static func makeDefaultDriver() -> AnimationDriver<MyAnimation> {
            AnimationDriver(MyAnimation(template: .linear)) { animation, status in
                switch status {
                case .initial:
                    animation.model.opacity = 0
                    animation.template = .easeIn(duration: 0.35)
                    animation.partialDuration = 0.35
                case .middle:
                    animation.model.opacity = 0.3
                    animation.template = .easeOut(duration: 0.35)
                    animation.partialDuration = 0.35
                case .finished:
                    animation.model.opacity = 1
                }
            }
        }
    }
}

struct AnimationViewExample: View {
    @ObservedObject
    var animationDriver = Animations.MyAnimation.makeDefaultDriver()
    
    var body: some View {
        ZStack {
            VStack {
                Text("Opacity: \(animationDriver.model.opacity)")
                Text(String(reflecting: Self.self))
                    .modifier(.animated(by: animationDriver))
            }
        }
        .foregroundColor(.white)
        .padding(100)
        .background(Color.red)
        .onTapGesture {
            self.animationDriver.run()
        }
    }
}

Back to index

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment