Reputation: 71
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
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
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
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