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

Effects and animation #207

Closed
technicated opened this issue Jul 2, 2020 · 4 comments
Closed

Effects and animation #207

technicated opened this issue Jul 2, 2020 · 4 comments

Comments

@technicated
Copy link

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

  • ComposableArchitecture: does not work on both 0.3.0 and 0.6.0
  • Xcode: 11.5 (11E608c)
  • Swift: 5.2
  • OS: macOS 10.15.4
@stephencelis
Copy link
Member

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
Copy link
Author

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
Copy link

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
Copy link

@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:

  • The initial appearance of the List animates in a way that is undesirable.
  • We still haven't solved for .animation(_:) being deprecated. If we were to use the new .animated(_:value:) modifier, we need something for value. viewStore.todos makes a poor choice here because we're really just interested in watching the order of the elements in the todos array not the todos themselves.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants