swiftuimatchedgeometryeffect

.matchedGeometryEffect doesn't work with ForEach


I am trying to display a ForEach of items, that could transition into like a fake sheet view, but I don't know what I am doing wrong, because the ForEach just displays every item on top of each other.

Here is a picture how it should look (which it does, if I remove the .matchedGeometryEffect): how it should look like

But this is how it looks, when I want to use .matchedGeometryEffect: enter image description here

The effect does work, but basically only for this item on top, because it's the only one clickable.

And this is my code:

import SwiftUI

struct Item: Identifiable {
    let id = UUID().uuidString
    let title: String
    let subtitle: String
    let image: ImageResource
}

struct ContentView: View {
    
    @Namespace var sheet
    private var rectangleId = "Rectangle"
    private var titleId = "Title"
    private var subtitleId = "Subtitle"
    
    @State var isExpanded: Bool = false
    
    @State private var selected: Item?
    
    let items: [Item] = [
        .init(title: "Test 1", subtitle: "testing stuff 1", image: .test),
        .init(title: "Test 2", subtitle: "testing stuff 2", image: .test1),
        .init(title: "Test 3", subtitle: "testing stuff 3", image: .test2),
        .init(title: "Test 4", subtitle: "testing stuff 4", image: .test3),
        .init(title: "Test 5", subtitle: "testing stuff 5", image: .test4),
    ]
    
    var body: some View {
        NavigationStack {
            ZStack {
                if isExpanded {
                    sheetView(item: selected!)
                } else {
                    ScrollView {
                        ForEach(items, id: \.id) { item in
                            normalView(item: item)
                        }
                    }
                    .padding()
                }
            }
        }
    }
    
    @ViewBuilder
    func normalView(item: Item) -> some View {
        Image(item.image)
            .resizable()
            .scaledToFit()
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .matchedGeometryEffect(id: rectangleId, in: sheet)
            .onTapGesture {
                withAnimation {
                    self.selected = item
                    isExpanded.toggle()
                }
            }
            .overlay(alignment: .bottomLeading) {
                VStack(alignment: .leading) {
                    Text(item.title)
                        .font(.title)
                        .matchedGeometryEffect(id: titleId, in: sheet)
                    Text(item.subtitle)
                        .matchedGeometryEffect(id: subtitleId, in: sheet)
                }
                .foregroundStyle(.white)
                .padding()
            }
    }
    
    @ViewBuilder
    func sheetView(item: Item) -> some View {
        ScrollView {
            Image(item.image)
                .resizable()
                .scaledToFit()
                .matchedGeometryEffect(id: rectangleId, in: sheet)
                .overlay(alignment: .bottomLeading) {
                    VStack(alignment: .leading) {
                        Text(item.title)
                            .font(.title)
                            .matchedGeometryEffect(id: titleId, in: sheet)
                        Text(item.subtitle)
                            .matchedGeometryEffect(id: subtitleId, in: sheet)
                    }
                    .foregroundStyle(.white)
                    .padding()
                }
            
            ForEach(0..<50) { item in
                Text("New test text lol \(item)")
            }
        }
        .ignoresSafeArea(edges: .top)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    withAnimation {
                        isExpanded.toggle()
                    }
                } label: {
                    Image(systemName: "plus.circle.fill")
                }
            }
        }
    }
}

#Preview {
    ContentView()
}


Solution

  • This does not work because you are always showing the same matchedGeometryEffect id, something like this should fix your problem:

    import SwiftUI
    
    struct Item: Identifiable {
        let id = UUID().uuidString
        let title: String
        let subtitle: String
        let image: ImageResource
    }
    
    struct ContentView: View {
        
        @Namespace var sheet
        private var rectangleId = "Rectangle"
        private var titleId = "Title"
        private var subtitleId = "Subtitle"
        
        @State var isExpanded: Bool = false
        
        @State private var selected: Item?
        
        let items: [Item] = [
            .init(title: "Test 1", subtitle: "testing stuff 1", image: .test),
            .init(title: "Test 2", subtitle: "testing stuff 2", image: .test1),
            .init(title: "Test 3", subtitle: "testing stuff 3", image: .test2),
            .init(title: "Test 4", subtitle: "testing stuff 4", image: .test3),
            .init(title: "Test 5", subtitle: "testing stuff 5", image: .test4),
        ]
        
        var body: some View {
            NavigationStack {
                ZStack {
                    if isExpanded {
                        sheetView(item: selected!)
                    } else {
                        ScrollView {
                            ForEach(items, id: \.id) { item in
                                normalView(item: item)
                            }
                        }
                        .padding()
                    }
                }
            }
        }
        
        @ViewBuilder
        func normalView(item: Item) -> some View {
            Image(item.image)
                .resizable()
                .scaledToFit()
                .clipShape(RoundedRectangle(cornerRadius: 12))
                // Differentiate items
                .matchedGeometryEffect(id: item.id + "background", in: sheet)
                .onTapGesture {
                    withAnimation {
                        self.selected = item
                        isExpanded.toggle()
                    }
                }
                .overlay(alignment: .bottomLeading) {
                    VStack(alignment: .leading) {
                        Text(item.title)
                            .font(.title)
                            .matchedGeometryEffect(id: item.id + "title", in: sheet)
                        Text(item.subtitle)
                            .matchedGeometryEffect(id: item.id + "subtitle", in: sheet)
                    }
                    .foregroundStyle(.white)
                    .padding()
                }
        }
        
        @ViewBuilder
        func sheetView(item: Item) -> some View {
            ScrollView {
                Image(item.image)
                    .resizable()
                    .scaledToFit()
                    .matchedGeometryEffect(id: item.id + "background", in: sheet)
                    .overlay(alignment: .bottomLeading) {
                        VStack(alignment: .leading) {
                            Text(item.title)
                                .font(.title)
                                .matchedGeometryEffect(id: item.id + "title", in: sheet)
                            Text(item.subtitle)
                                .matchedGeometryEffect(id: item.id + "subtitle", in: sheet)
                        }
                        .foregroundStyle(.white)
                        .padding()
                    }
                
                ForEach(0..<50) { item in
                    Text("New test text lol \(item)")
                }
            }
            .ignoresSafeArea(edges: .top)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        withAnimation {
                            isExpanded.toggle()
                        }
                    } label: {
                        Image(systemName: "plus.circle.fill")
                    }
                }
            }
        }
    }
    
    #Preview {
        ContentView()
    }