Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

debounce ignores animation parameter of send #3586

Open
2 of 3 tasks
nkristek opened this issue Feb 6, 2025 · 1 comment
Open
2 of 3 tasks

debounce ignores animation parameter of send #3586

nkristek opened this issue Feb 6, 2025 · 1 comment
Labels
bug Something isn't working due to a bug in the library.

Comments

@nkristek
Copy link

nkristek commented Feb 6, 2025

Description

When adding debounce to an effect any actions that are emitted from that effect are not animated even though the animation parameter is set when calling send.

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

In the sample code, all buttons should animate the square.

Actual behavior

Only the first and last buttons animate the square.

Reproducing project

import ComposableArchitecture
import SwiftUI

@Reducer
struct DebounceAnimationBugFeature {
    @ObservableState
    struct State {
        var toggle = false
    }

    enum Action {
        case toggleWithoutDebounceTapped
        case toggleWithDebounceTapped
        case toggleWithDebounceAndAnimationModifierTapped
        case toggleState
    }

    private enum TaskId {
        case debounce
    }

    @Dependency(\.mainRunLoop) private var mainRunLoop

    var body: some ReducerOf<Self> {
        Reduce<State, Action> { state, action in
            switch action {
            case .toggleWithoutDebounceTapped:
                return .run { send in
                    await send(.toggleState, animation: .default)
                }
            case .toggleWithDebounceTapped:
                return .run { send in
                    await send(.toggleState, animation: .default)
                }
                .debounce(id: TaskId.debounce, for: 0.1, scheduler: mainRunLoop)
            case .toggleWithDebounceAndAnimationModifierTapped:
                return .run { send in
                    await send(.toggleState)
                }
                .debounce(id: TaskId.debounce, for: 0.1, scheduler: mainRunLoop)
                .animation(.default)
            case .toggleState:
                state.toggle.toggle()
                return .none
            }
        }
    }
}

struct DebounceAnimationBugView: View {
    @Bindable var store: StoreOf<DebounceAnimationBugFeature>

    var body: some View {
        VStack {
            Button("Toggle without debounce") {
                store.send(.toggleWithoutDebounceTapped)
            }

            Button("Toggle with debounce") {
                store.send(.toggleWithDebounceTapped)
            }

            Button("Toggle with debounce and animation modifier") {
                store.send(.toggleWithDebounceAndAnimationModifierTapped)
            }

            Rectangle()
                .fill(Color.blue)
                .frame(width: 50, height: 50)
                .frame(maxWidth: .infinity, alignment: store.toggle ? .leading : .trailing)
        }
    }
}

#Preview {
    DebounceAnimationBugView(store: Store(initialState: .init(), reducer: {
        DebounceAnimationBugFeature()
    }))
}

The Composable Architecture version information

1.17.1

Destination operating system

iOS 18.2

Xcode version information

16.2

Swift Compiler version information

@nkristek nkristek added the bug Something isn't working due to a bug in the library. label Feb 6, 2025
@iampatbrown
Copy link
Contributor

iampatbrown commented Mar 2, 2025

Hey @nkristek,

Is there a specific reason the animation modifier isn't suitable?

Another option is to debounce using the scheduler's sleep function:

return .run { send in
  try await mainRunLoop.sleep(for: .seconds(0.1))
  await send(.toggleState, animation: .default)
}
.cancellable(id: TaskId.debounce, cancelInFlight: true)

That being said, after looking at the example from #2372, this also doesn't animate:

return .send(Action.toggleState, animation: .default)
  .debounce(id: TaskId.debounce, for: 0.1, scheduler: mainRunLoop)

If this example is expected to work, removing the receive(on:) operator on Line 45 would probably fix things:

Just(())
.delay(for: dueTime, scheduler: scheduler, options: options)
.flatMap { _EffectPublisher(self).receive(on: scheduler) }
.eraseToAnyPublisher()

I have a feeling _EffectPublisher might receive its value on the same scheduler as the delayed publisher by default. So adding receive(on:) might just introduce a thread hop.

I probably won't get a chance to dig into this much more, but if you're up for exploring it a bit further and possibly creating a PR, that would be awesome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working due to a bug in the library.
Projects
None yet
Development

No branches or pull requests

2 participants