animationswiftuiscrollview

Animating a position change in a ScrollView


I have a ScrollView that contains pinned items in the top and unpinned items in the bottom.

 ______________
|    Item 1    |
|    Item 2    |
|    Item 3    |
|--------------|
|    Item 4    |
|    Item 5    |
|    Item 6    |
|    Item 7    |
|    Item 8    |
|______________|

The Scroll view is populated by two collections (pinnedItems and unpinnedItems) using two ForEach-clauses in the ScrollView.

var pinnedItems = ["Item 1", "Item 2", "Item 3"]
var unpinnedItems = ["Item 4", "Item 5", "Item 6", "Item 7", "Item 8"]
ScrollView {
    ForEach(pinnedTasks) { task in
        NavigationLink(value: NavigationPath.task(task)) {
            TaskCardView(task: task, onPinning: {
                withAnimation {
                    togglePinnedTask(task: task)
                }
            })
            .padding(.bottom)
        }.buttonStyle(.plain)
    }


    Divider()
        .frame(minHeight: 3)
        .background(.gray)
        .padding(.bottom)


    ForEach(unPinnedTasks) { task in
        NavigationLink(value: NavigationPath.task(task)) {
            TaskCardView(task: task, onPinning: {
                withAnimation {
                    togglePinnedTask(task: task)
                }
            })
            .padding(.bottom)
        }.buttonStyle(.plain)
    }
}
                

The togglePinnedTask() function removes the task from one collection and inserts it into the other.

The automatic animation just fades the task cards. I would like to make them "fly" to their new position. What it the approach to do this?


Solution

  • You can use matchedGeometryEffect. Here is a simple example:

    struct ContentView: View {
        @State private var pinnedItems = ["Item 1", "Item 2", "Item 3"]
        @State private var unpinnedItems = ["Item 4", "Item 5", "Item 6", "Item 7", "Item 8"]
        @Namespace var ns
        
        var body: some View {
            VStack {
                ScrollView {
                    VStack {
                        ForEach(pinnedItems, id: \.self) { item in
                            Text(item)
                                .matchedGeometryEffect(id: item, in: ns)
                        }
                        ForEach(unpinnedItems, id: \.self) { item in
                            Text(item)
                                .matchedGeometryEffect(id: item, in: ns)
                        }
                    }
                }
    
                // as an example to trigger the animation
                Button("Change Pins") {
                    withAnimation {
                        pinnedItems.append(unpinnedItems.removeLast())
                    }
                }
            }
        }
    }
    

    Note that in both matchedGeometryEffects and the ForEaches, I have used the item itself as the id. In your real code, your items probably conform to Identifiable and you should use their id property instead.