pointfreeco / swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
https://www.pointfree.co/collections/composable-architecture
MIT License
12.57k stars 1.46k forks source link

Effects and animation #207

Closed technicated closed 4 years ago

technicated commented 4 years ago

Describe the bug On macOS, a ForEach inside a List driven by an Array does not animate a sort if the operation is performed by an Effect. The animation correctly shows if the sort operation is performed using send.

To Reproduce Just use this code as your ContentView. On macOS this will not work, on iOS it will instead animate.

struct ContentView: View {
    struct Item: Equatable, Identifiable {
        var id: UUID
        let date: Date
        let name: String
    }

    struct ViewState: Equatable {
        var items: [Item]
    }

    enum ViewAction: Equatable {
        case addItem
        case randomSort
    }

    struct ViewEnvironment {
        var mainQueue: AnySchedulerOf<DispatchQueue>

        var makeUUID: () -> UUID
        var randomDate: () -> Date
        var randomName: () -> String
    }

    static let viewReducer = Reducer<ViewState, ViewAction, ViewEnvironment> { (s, a, e) in
        switch a {
        case .addItem:
            s.items.append(
                Item(
                    id: e.makeUUID(),
                    date: e.randomDate(),
                    name: e.randomName()
                )
            )

            return Effect(value: .randomSort)
                .delay(for: 1, scheduler: e.mainQueue)
                .eraseToEffect()
        case .randomSort:
            s.items.sort { _,_ in Bool.random() }
            return .none
        }
    }

    struct Header: View {
        let store: Store<ViewState, ViewAction>

        var body: some View {
            WithViewStore(store) { viewStore in
                HStack {
                    Button("Add Item") {
                        withAnimation { viewStore.send(.addItem) }
                    }

                    Spacer()

                    Button("Manual Random Sort") {
                        withAnimation { viewStore.send(.randomSort) }
                    }
                }
            }
        }
    }

    let store: Store<ViewState, ViewAction> 

    var body: some View {
        List {
            Section(header: Header(store: store)) {
                WithViewStore(store) { viewStore in
                    ForEach(viewStore.items) { item in
                        HStack {
                            Text(item.date.description)
                            Spacer()
                            Text(item.name)
                        }
                    }
                }
            }
        }
    }
}

Expected behavior On macOS as on iOS, the List rearranges with an animation even after the Effect-ful sort.

Environment

stephencelis commented 4 years ago

Hi @technicated. The reason you're not seeing an animation is because withAnimation blocks are performed synchronously, but the sorting is happening later. iOS animates sorting by default, but it appears that macOS does not.

If you want the result of an effect to be animated you must animate from state. One way to do so is by adding animation view modifiers to the view hierarchy. In this particular case you can animate the List and then reset this modifier on the list's content so that not all changes in each row animates:

List {
    Section(header: Header(store: store)) {
        ...
    }
    .animation(nil) // don't animate every list item
}
.animation(.default) // animate the state of the list, though, including ordering
technicated commented 4 years ago

Yes, it works! I didn't know about this difference, I guess every day we learn something new 😊

I'll close the issue adding that using .animation it works on both platforms and even without using withAnimation in the action closure.

mycroftcanner commented 2 years ago

Hi @technicated. The reason you're not seeing an animation is because withAnimation blocks are performed synchronously, but the sorting is happening later. iOS animates sorting by default, but it appears that macOS does not.

If you want the result of an effect to be animated you must animate from state. One way to do so is by adding animation view modifiers to the view hierarchy. In this particular case you can animate the List and then reset this modifier on the list's content so that not all changes in each row animates:

List {
    Section(header: Header(store: store)) {
        ...
    }
    .animation(nil) // don't animate every list item
}
.animation(.default) // animate the state of the list, though, including ordering

'animation' was deprecated in iOS 15.0: Use withAnimation or animation(_:value:) instead.

abardallis commented 2 years ago

@stephencelis Going through the Tour of TCA now in 2022, it seems as SwiftUI has evolved enough since this original issue that we're in need of some new solutions for this sorting animation problem.

Xcode 13.3 iOS 15.4 simulator

The Problem

  1. It was previously mentioned above...

    iOS animates sorting by default, but it appears that macOS does not.

It appears now that iOS does not either. That's not a huge problem, provided your suggestion above to add .animation(_:) still works.

  1. As @mycroftcanner mentioned, .animation(_:) modifier is deprecated in iOS 15.0 in favor of .animation(_:value:).

Here's what I found:

I downloaded the 0102-swift-composable-architecture-tour-pt3 example code, made no code changes, and am seeing that the list items are not animating their sorting:

Simulator Screen Recording - iPhone 13 Pro - 2022-04-16 at 13 16 44

In an attempt to solve the first problem, I then made the following updates to these lines within ContentView based on your previous reply above:

List {
  ForEachStore(
    self.store.scope(state: \.todos, action: AppAction.todo(index:action:)),
    content: TodoView.init(store:)
  )
  .animation(nil)
}
.animation(.default)

Which leads to this: Simulator Screen Recording - iPhone 13 Pro - 2022-04-16 at 13 19 33

As you can see, the animation of sorting now occurs, however, we're still faced with problems:

A Somewhat Messy Solution

I was able to solve both of these problems by introducing a new computed property to the AppState that holds onto an array of just the Todo.IDs:

struct AppState: Equatable {
  var todos: [Todo] = []
  var todoIDs: [Todo.ID] {
      self.todos.map(\.id)
  }
}

This should only change when a new todo is added or when the todos are sorted. With this in place, I then did this:

List {
  ForEachStore(
    self.store.scope(state: \.todos, action: AppAction.todo(index:action:)),
    content: TodoView.init(store:)
  )
  .animation(nil, value: viewStore.todoIDs)
}
.animation(.default, value: viewStore.todoIDs)

As you can see, this works as I'd hoped: Simulator Screen Recording - iPhone 13 Pro - 2022-04-16 at 13 47 56

The Point

Introducing a new bit of state just to get animations behaving in this way works, but feels a bit messy. Is there some other solution that I'm missing?