Search code examples
swiftuiswiftui-listswiftui-animation

Hero animation not working in List when setting row's id dynamically


Recently ran into an issue trying to perform Hero animation using matchedGeometryEffect in SwiftUI. My issue is that setting id for matchedGeometryEffect effect dynamically isn't working as expected.

This is what I have so far:

import SwiftUI

struct HeroAnimationTest: View {
    let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]

    @State var selectedItemInRowIndex: Int? = nil
    @Namespace var namespace

    var body: some View {
        List {
            ForEach(items, id: \.id) { item in
                ItemListRow(namespace: namespace, item: item) { tappedItem in
                    withAnimation {
                        selectedItemInRowIndex = tappedItem.id
                    }
                }
            }
            .listRowSeparator(.hidden)
            .listRowBackground(Color.clear)
            .listRowInsets(EdgeInsets(h: 16, v: 8))
        }
        .animation(.spring(), value: selectedItemInRowIndex)
        .scrollIndicators(.never)
        .listStyle(.plain)
        .overlay {
            if selectedItemInRowIndex != nil {
                largeGreen
            }
        }
    }

    var largeGreen: some View {
        ZStack {
            Color.black
                .onTapGesture {
                    withAnimation {
                        selectedItemInRowIndex = nil
                    }
                }
            Color.green
                .frame(width: 200, height: 400)
                .matchedGeometryEffect(id: selectedItemInRowIndex, in: namespace)
            Text("ID -> \(selectedItemInRowIndex ?? 0)")
        }
    }
}

struct HeroAnimationTest_Previews: PreviewProvider {
    static var previews: some View {
        HeroAnimationTest()
    }
}

struct Item {
    let id: Int
}

struct ItemListRow: View {
    @State var enlargeElement = false
    let namespace: Namespace.ID

    let item: Item
    let onGreenTap: (Item) -> Void

    var body: some View {
        HStack {
            Text("ID -> \(item.id)")
            VStack {
                Color.green
            }
            .frame(width: 100, height: 40)
            .matchedGeometryEffect(id: item.id, in: namespace)
            .onTapGesture {
                onGreenTap(item)
            }
            VStack {
                Color.yellow
            }
            .frame(width: 100, height: 40)
        }
    }
}

Current result:

enter image description here

I tried to hard-code id for largeGreen inside .matchedGeometryEffect(id: 3, in: namespace) to check if animation would work, and it does:

enter image description here

Animation with hard-coded id is the expected result, but obviously it's only working for the 3rd row. Is it even possible to achieve this effect for a green container in every row?

I'd really appreciate if anyone could take a look and give me some hint of what I'm missing here. I've been looking at this for a few hours now, but still can't figure out what went wrong.


Solution

  • Okay, I finally figured it out. It turns out that in order for matchedGeometryEffect to work, its id needs to be unwrapped, assigning optional just won't work.

    So, what I did was:

    1. Extracted largeGreen to a ExpandedLargeGreen with selectedItemIdx & show parameters with @Binding property wrapper, and namespace.
    2. And then inside .overlay closure within HeroAnimationTest unwrapped optional Binding selectedItemInRowIndex. In case it's not nil and show is set to true ExpandedLargeGreen will be displayed with proper animation.
    3. Also set animation value to show instead of selectedItemInRowIndex.

    Full code:

    struct HeroAnimationTest: View {
        let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
    
        @State var selectedItemInRowIndex: Int? = nil
        @State var show = false
        @Namespace var namespace
    
        var body: some View {
            ZStack {
                List {
                    ForEach(items, id: \.id) { item in
                        ItemListRow(namespace: namespace, item: item) { tappedItem in
                            withAnimation {
                                selectedItemInRowIndex = tappedItem.id
                                show.toggle()
                            }
                        }
                    }
                    .listRowSeparator(.hidden)
                    .listRowBackground(Color.clear)
                    .listRowInsets(EdgeInsets(h: 16, v: 8))
                }
                .animation(.spring(), value: show)
                .scrollIndicators(.never)
            .listStyle(.plain)
            }
            .overlay {
                if let selectedIdx = Binding($selectedItemInRowIndex), show {
                    ExpandedLargeGreen(selectedItemIdx: selectedIdx, show: $show, namespace: namespace)
                }
            }
        }
    }
    
    struct ExpandedLargeGreen: View {
        @Binding var selectedItemIdx: Int
        @Binding var show: Bool
        var namespace: Namespace.ID
    
        var body: some View {
            ZStack {
                Color.black
                    .onTapGesture {
                        withAnimation {
                            show.toggle()
                        }
                    }
                Color.green
                    .frame(width: 200, height: 400)
                    .matchedGeometryEffect(id: selectedItemIdx, in: namespace)
                Text("ID -> \(selectedItemIdx)")
            }
        }
    }
    
    struct HeroAnimationTest_Previews: PreviewProvider {
        static var previews: some View {
            HeroAnimationTest()
        }
    }
    
    struct Item {
        let id: Int
    }
    
    struct ItemListRow: View {
        @State var enlargeElement = false
        let namespace: Namespace.ID
    
        let item: Item
        let onGreenTap: (Item) -> Void
    
        var body: some View {
            HStack {
                Text("ID -> \(item.id)")
                VStack {
                    Color.green
                }
                .frame(width: 100, height: 40)
                .matchedGeometryEffect(id: item.id, in: namespace)
                .onTapGesture {
                    onGreenTap(item)
                }
                VStack {
                    Color.yellow
                }
                .frame(width: 100, height: 40)
            }
        }
    }
    

    Final result: