Isvvc
Isvvc

Reputation: 71

SwiftUI matchedGeometryEffect only animates one way

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.

Upvotes: 4

Views: 2423

Answers (3)

Joel
Joel

Reputation: 861

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.

Upvotes: 1

glassomoss
glassomoss

Reputation: 795

I've done some research and been able to prevent this behavior. So now as far as I understand matchedGeometryEffect is generally a modifier that makes one view to have the size/position of another, aside from animation purposes.

That way I concluded that animation could break if sizes are broken somehow (they could be zero). Setting explicit frames to both views was the solution.

someView
    .matchedGeometryEffect(id: "view", in: namespace)
    .animation(.easeInOut)
    .frame(width: 100, height: 100) // or .fixedSize()

Upvotes: 0

AlphaWulf
AlphaWulf

Reputation: 403

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)

Upvotes: 1

Related Questions