Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI matchedGeometryEffect only animates one way

Tags:

ios

swift

swiftui

I have a ZStack for a file browser grid that adds layers when you navigate into folders. The folder icons show a preview of the folder contents. I have a matchedGeometryEffect on the preview items so they can animate into the opened folder. The problem is that the animation only happens when closing a folder, not when opening.

(I understand that this code is not yet generalizeable to folders with more content yet. I'll get to that if I can get these animations working.

Sorry to dump a lot of code at once, but everything should be here for it to work dropped-in to a project to try out.

struct File: Hashable {
    var symbol: String?
    var name: String
    var contents: [File]? = nil
}

struct GridNavNew: View {
    
    @Namespace private var ns
    
    static var files: [File] = [
        File(name: "Folder", contents: [
            File(symbol: "cloud", name: "Cloudy"),
            File(symbol: "cloud.hail", name: "Hail"),
            File(symbol: "cloud.snow", name: "Snow"),
            File(symbol: "cloud.fog", name: "Fog")
        ]),
        File(symbol: "cube", name: "Cube"),
        File(symbol: "books.vertical", name: "Books")
    ]
    
    static let root = File(symbol: nil, name: "/", contents: files)
    
    @State private var path: [File] = [root]
    
    var body: some View {
        ZStack {
            // This exists because otherwise the transition doesn't play on the way out
            // See https://sarunw.com/posts/how-to-fix-zstack-transition-animation-in-swiftui/
            Text("A")
                .opacity(0)
                .zIndex(1)
            
            ForEach(Array(path.enumerated()), id: \.offset) { index, dir in
                NavigationView {
                    if let content = dir.contents {
                        ScrollView {
                            LazyVGrid(columns: [GridItem(), GridItem()]) {
                                ForEach(content, id: \.self) { file in
                                    Button {
                                        withAnimation {
                                            path.append(file)
                                        }
                                    } label: {
                                        FileCell(file: file,
                                                    onTop: path.last == dir,
                                                    ns: ns)
//                                            .animation(.default)
                                    }
                                }
                            }
                            .padding()
                        }
                        .navigationTitle(dir.name)
                        .toolbar {
                            ToolbarItem(placement: .navigation) {
                                if path.first != dir {
                                    Button("Back") {
                                        withAnimation {
                                            path.removeLast()
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
                .transition(.move(edge: .trailing))
            }
        }
        .listStyle(PlainListStyle())
    }
}

struct FileCell: View {
    
    var file: File
    var compact = false
    var onTop: Bool
    
    var ns: Namespace.ID
    
    private func previewGrid(_ contents: [File]) -> some View {
        LazyVGrid(columns: [GridItem(), GridItem()]) {
            ForEach(contents, id: \.self) { file in
                FileCell(file: file, compact: true, onTop: onTop, ns: ns)
            }
        }
    }
    
    private var icon: some View {
        RoundedRectangle(cornerRadius: compact ? 10 : 20)
            .aspectRatio(1, contentMode: .fill)
            .foregroundColor(.accentColor)
            .opacity(file.contents == nil ? 1 : 0)
            .overlay(
                Group {
                    if let symbol = file.symbol {
                        Image(systemName: symbol)
                            .resizable()
                            .scaledToFit()
                            .padding()
                            .foregroundColor(.primary)
                    } else if onTop,
                              let contents = file.contents {
                        previewGrid(contents)
                    }
                }
            )
    }
    
    var body: some View {
        VStack {
            icon
                .matchedGeometryEffect(id: file, in: ns)
            if !compact {
                Text(file.name)
            }
        }
    }
}

The matchedGeometryEffect is at the bottom of FileCell in its body.

One thing I thought might be causing the issue was the matched geometry being off-screen during the transition because of the .transition(.move(edge: .trailing)), but using any other transition, or none at all, has the same issue.

I also thought the ZStack might be causing issues, seeing as it already was with the transition (see https://sarunw.com/posts/how-to-fix-zstack-transition-animation-in-swiftui/), but changing the ZStack to a VStack for the sake of testing didn't resolve the issue.

You might also notice the commented-out .animation(.default) on the FileCell. This did allow the transition to animate when opening the folder, but the animation effect does not match that of closing the folder when the line isn't there, and it also causes the file cells to duplicate when the folder closes, leading to a buggy-looking animation.

Edit: I'd also like to mention that I'm not getting the Multiple inserted views in matched geometry group error, so it's not an issue with that either.

like image 422
Isvvc Avatar asked Oct 18 '25 02:10

Isvvc


2 Answers

I had the same problem. You need to add an animation modifier AFTER the .matchedGeometryEffect() modifier.

This animates both ways:

Text("TEST")
    .matchedGeometryEffect(id: "test", in: namespace)
    .animation(.easeInOut)

This only animates the first half

Text("TEST")
    .animation(.easeInOut)
    .matchedGeometryEffect(id: "test", in: namespace)
like image 54
AlphaWulf Avatar answered Oct 19 '25 17:10

AlphaWulf


Sometimes the problem is using if-else.

I found out that when I have this problem, I better use .opacity() to show or hide and this way the .matchedGeometry works both ways.

like image 23
Joel Avatar answered Oct 19 '25 16:10

Joel



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!