An alternative take on animating asynchronous Effects

Point-Free episode 136 solves the problem using a Scheduler like this:

return effect
  .receive(on: environment.mainQueue.animation())

It occurred to me that we could also solve it using a custom Publisher, eliminating the need to use any Scheduler:

return effect.animation()

We just need to make sure the call to the subscriber's receive(_:) method is wrapped in a withAnimation block. Here's an implementation:

extension Effect {
    public func animation(_ animation: Animation? = .default) -> Self {
        return BracketingPublisher(upstream: self) { action in
            withAnimation(animation, action)
        }.eraseToEffect()
    }
}

public struct BracketingPublisher<Upstream: Publisher>: Publisher {
    public typealias Output = Upstream.Output
    public typealias Failure = Upstream.Failure

    public var upstream: Upstream
    public var bracket: (() -> Void) -> Void

    public init(upstream: Upstream, bracket: @escaping (() -> Void) -> Void) {
        self.upstream = upstream
        self.bracket = bracket
    }

    public func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
        let conduit = Conduit(downstream: subscriber, bracket: bracket)
        upstream.receive(subscriber: conduit)
    }

    fileprivate class Conduit<Downstream: Subscriber>: Subscriber {
        typealias Input = Downstream.Input
        typealias Failure = Downstream.Failure

        let downstream: Downstream
        let bracket: (() -> Void) -> Void

        init(downstream: Downstream, bracket: @escaping (() -> Void) -> Void) {
            self.downstream = downstream
            self.bracket = bracket
        }

        func receive(subscription: Subscription) {
            downstream.receive(subscription: subscription)
        }

        func receive(_ input: Input) -> Subscribers.Demand {
            var demand: Subscribers.Demand = .none
            bracket {
                demand = downstream.receive(input)
            }
            return demand
        }

        func receive(completion: Subscribers.Completion<Failure>) {
            downstream.receive(completion: completion)
        }
    }
}
2 Likes

Hey @mayoff, great find! It does seem like pretty much anything you can do with the scheduler .animation operator you can also do with the publisher .animation operator you have defined here.

We have found a few small ergonomic differences between the two styles that may interest you. By tying the animation to the scheduler we can make the call site more obvious when the operator is used incorrectly. For example, this:

apiClient.fetch()
  .receive(on: DispatchQueue(label: "background.queue").animation())
  .map(AppAction.response)

Is obviously incorrect because you would never want to call withAnimation on a background queue. Doing so doesn't seem to error in any obvious ways, but it doesn't cause things to animate.

On the other hand, doing this with publisher animations:

apiClient.fetch()
  .animation()
  .receive(on: DispatchQueue.main)
  .map(AppAction.response)

Is a little more subtle in its wrongness. If apiClient.fetch() emits output on a background thread, then .animation() will silently stop working. The way to correct this would be to make sure .animation() is called after .receive(on:):

 apiClient.fetch()
-   .animation()
   .receive(on: DispatchQueue.main)
+   .animation()
   .map(AppAction.response)

It also seems that calling withAnimation on a background thread may secretly cause problems down the road, and so it may be very difficult to track it back to this one small subtlety.

Another tiny ergonomic difference is that passing an argument can be a little more flexible at the call site than method chaining. For example, if you wanted some logic around specifying an animation versus using whatever the current animation context is:

apiClient.fetch()
  .receive(
    on: someCondition 
      ? DispatchQueue.main.animation() 
      : DispatchQueue.main
  )
  .map(AppAction.response)

To do this with the publisher operator you would need something like:

var effect = apiClient.fetch()
if someCondition {
  effect = effect.animation()
}
return effect
  .receive(on: DispatchQueue.main)
  .map(AppAction.response)

Again, these are pretty small ergonomics differences, and there may be others. We're not entirely sure one is definitively better than the other. Maybe both have their use cases. We're going to continue thinking about it, and please let us know if you discover anything new!

Thanks again for bringing this up!

It's odd that withAnimation doesn't call the main thread checker. It seems like checking for the main thread would be a good addition to both the Scheduler and Effect animation methods:

extension Effect {
    public func animation(_ animation: Animation? = .default) -> Self {
        return BracketingPublisher(upstream: self) { action in
            precondition(Thread.isMainThread)
            withAnimation(animation, action)
        }.eraseToEffect()
    }
}

As for the other difference, my taste would be to call animation either way, passing nil if I want no animation:

apiClient.fetch()
    .receive(on: DispatchQueue.main.animation(someCondition ? .default : nil))
    .map(AppAction.response)

Yeah good point, it may be worth filing a feedback to have withAnimation integrated with the main thread checker. For your code you may also want to use dispatchPrecondition with DispatchPredicate instead of Thread.isMainThread. I believe isMainThread is not reliable.

Unfortunately that code is slightly different from what I wrote. Your code says do not animate when someCondition is false, whereas mine says use whatever the current animation context is.

For example this:

apiClient.fetch()
  .receive(on: DispatchQueue.main.animation(.spring())
  .receive(
    on: someCondition 
      ? DispatchQueue.main.animation() 
      : DispatchQueue.main
  )
  .map(AppAction.response)

Would animate with spring when someCondition is false whereas yours would not animate at all.

We mentioned this in episodes, but it's because the optional in .animation's signature operates a little differently from using optionals in other SwiftUI modifiers, such as .foregroundColor, .accentColor et al. Pass nil to the latter modifiers means "use whatever was previously set" whereas passing nil to .animation means "do not animate at all".

1 Like

Any alternative, it could be possible?

Terms of Service

Privacy Policy

Cookie Policy