SwiftUI .matchGeometryEffect not working smoothly

134 Views Asked by At

I've been trying for sometime now to figure out why the .matchGeometryEffect is not transitioning smoothly in my use case. The state change speed is not consistent, growing quicker and going back slower. Similarly, the transition is also broken for going back and clipping. Also I would like to avoid the fading effect in the end.

I set up an example that represents the issue. Any advice would be much appreciated.

issue showcase example

struct PeopleView: View {
    
    struct Person: Identifiable {
        let id: UUID = UUID()
        let first: String
        let last: String
    }
    
    @Namespace var animationNamespace
    
    @State private var isDetailPresented = false
    @State private var selectedPerson: Person? = nil
    
    let people: [Person] = [
        Person(first: "John", last: "Doe"),
        Person(first: "Jane", last: "Doe")
    ]
    
    var body: some View {
        homeView
            .overlay {
                if isDetailPresented, let selectedPerson {
                    detailView(person: selectedPerson)
                        .transition(.asymmetric(insertion: .identity, removal: .offset(y: 5)))
                }
            }
    }
    
    var homeView: some View {
        ScrollView {
            VStack {
                cardScrollView
            }
        }
    }
    
    var cardScrollView: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(people) { person in
                    if !isDetailPresented {
                        personView(person: person, size: 100)
                            .onTapGesture {
                                withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)){
                                    self.selectedPerson = person
                                    self.isDetailPresented = true
                                }
                            }
                    }
                    else {
                        Rectangle()
                            .frame(width: 50, height: 100)
                    }
                }
            }
        }
    }
    
    func personView(person: Person, size: CGFloat) -> some View {
        Group {
            Text(person.first)
                .padding()
                .frame(height: size)
                .background(Color.gray)
                .cornerRadius(5)
                .shadow(radius: 5)
        }
        .matchedGeometryEffect(id: person.id, in: animationNamespace)
    }
    
    func detailView(person: Person) -> some View {
        VStack {
            personView(person: person, size: 300)
            Text(person.first + " " + person.last)
        }
        .onTapGesture {
            withAnimation {
                self.isDetailPresented = false
                self.selectedPerson = nil
            }
        }
    }
}
2

There are 2 best solutions below

4
On BEST ANSWER

The animation works a lot better if you make two small tweaks:

  1. Comment out the .transition modifier on the detailView. This is what is causing the sudden "chop off" at the end of your animation.
detailView(person: selectedPerson)
//    .transition(.asymmetric(insertion: .identity, removal: .offset(y: 5)))
  1. Set an anchor of .top for the matchedGeometryEffect:
.matchedGeometryEffect(id: person.id, in: animationNamespace, anchor: .top)

Animation

Making it better still

When a card is selected, you can see how the card fades out and the detail fades in. Then when the detail is de-selected, the detail fades out and the card fades back in. This is because the views are distinct separate views, so an opactiy transition is happening as one view is replaced by the other.

The animation can be made much smoother by having one single view moving from position A to position B. To do it this way, you could have a base position for the card and a base position for the detail and the person view could move between them.

The key to getting this to work is for the moving view to be outside of the ScrollViews, otherwise these perform clipping and the moving view disappears out of sight.

So here is a re-factored example that works this way:

struct PeopleView: View {

    struct Person: Identifiable {
        let id: UUID = UUID()
        let first: String
        let last: String
    }

    @Namespace private var animationNamespace
    @State private var selectedPerson: Person? = nil
    private let detailId = UUID()
    private let cardWidth: CGFloat = 70

    let people: [Person] = [
        Person(first: "John", last: "Doe"),
        Person(first: "Jane", last: "Doe"),
        Person(first: "Fred", last: "Doe"),
        Person(first: "Bill", last: "Doe"),
        Person(first: "Jack", last: "Doe"),
        Person(first: "Mary", last: "Doe"),
        Person(first: "Peter", last: "Doe"),
        Person(first: "Anne", last: "Doe"),
        Person(first: "Tina", last: "Doe"),
        Person(first: "Tom", last: "Doe")
    ]

    private func personView(person: Person) -> some View {
        RoundedRectangle(cornerRadius: 5)
            .foregroundStyle(.gray)
            .shadow(radius: 5)
            .overlay {
                Text(person.first)
            }
//            .opacity(selectedPerson == nil || selectedPerson?.id == person.id ? 1 : 0)
            .matchedGeometryEffect(
                id: selectedPerson?.id == person.id ? detailId : person.id,
                in: animationNamespace,
                isSource: false
            )
    }

    private var floatingPersonViews: some View {
        ForEach(people) { person in
            personView(person: person)
                .allowsHitTesting(false)
        }
    }

    private var cardBases: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(people) { person in
                    RoundedRectangle(cornerRadius: 5)
                        .frame(width: cardWidth, height: 100)
                        .onTapGesture {
                            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                                selectedPerson = person
                            }
                        }
                        .matchedGeometryEffect(
                            id: person.id,
                            in: animationNamespace,
                            isSource: true
                        )
                }
            }
            .padding()
        }
    }

    private var homeView: some View {
        ScrollView {
            VStack {
                cardBases
            }
        }
    }

    private var detailBase: some View {
        Rectangle()
            .frame(width: cardWidth, height: 300)
            .opacity(0)
            .matchedGeometryEffect(
                id: detailId,
                in: animationNamespace,
                isSource: true
            )
    }

    private var detailView: some View {
        VStack {
            detailBase
            if let selectedPerson {
                Text(selectedPerson.first + " " + selectedPerson.last)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                selectedPerson = nil
            }
        }
    }

    var body: some View {
        ZStack {
            homeView
            detailView
            floatingPersonViews
        }
    }
}

BetterAnimation

You will see that matchedGeometryEffect is now applied in several places. The card bases and the detail base all have isSource set to true. The person views have isSource set to false, so their positions are determined by the source views with matching ids. Opacity is used to hide content that is not moving but shouldn't be visible.

You will also notice that I am using computed properties and functions to build views, just as you were doing. I find this a good way to break down a big view into small views, so that you don't end up with one huge body. It seems that the author of another answer doesn't agree, but we all have our own personal preferences and opinions.

2
On

ETA: I've updated my post to reflect the changes selected in the comments below (removing the image, adding the missing code).

You've got a lot going on here! The solution is to clean it up a bit.

My cleaned up version of the code is below. I've also put your original code and my solution on GitHub for you here.

First, you shouldn't define new views as variables or functions. Make a whole new struct that conforms to View and has its own body instead, and pass in any values needed from the parent. This is going to allow SwiftUI to keep track of everything better, and it will automatically redraw all subviews if any state changes in the parent view.

Once you've done that, you want to put the .matchedGeometryEffect modifier in two spots that you know are going to be for the same view. In your original code, you only have it once on the personView, which is sometimes rendered inside of a detailView and sometimes not. Just bring it down to one spot where the personView is small and another where it's large.

import SwiftUI

struct Person: Identifiable, Equatable {   // Added Equatable. You could compare `.id`s below instead
    let id: UUID = UUID()
    let first: String
    let last: String
}

struct PeopleView2: View {
    @Namespace var animationNamespace
    
    @State private var isDetailPresented = false
    @State private var selectedPerson: Person? = nil
    
    let people: [Person] = [
        Person(first: "John", last: "Doe"),
        Person(first: "Jane", last: "Doe")
    ]
    
    var body: some View {
        ScrollView {
            VStack {
                ScrollView(.horizontal) {
                    HStack {
                        ForEach(people) { person in
                            if person != selectedPerson {
                                PersonView(person: person, size: 100)
                                    .matchedGeometryEffect(id: person.id, in: animationNamespace, anchor: .top)
                                    .onTapGesture {
                                        withAnimation(.interactiveSpring(
                                            response: 0.3,
                                            dampingFraction: 0.8,
                                            blendDuration: 0.8)){
                                                self.selectedPerson = person
                                                self.isDetailPresented = true
                                            }
                                    }
                            }
                        }
                    }
                }
            }
        }
        .overlay {
            if isDetailPresented, let selectedPerson {
                VStack {
                    PersonView(person: selectedPerson, size: 300)
                        .matchedGeometryEffect(id: selectedPerson.id, in: animationNamespace, anchor: .top)
                        .onTapGesture {
                            withAnimation(.interactiveSpring(
                                response: 0.3,
                                dampingFraction: 0.8,
                                blendDuration: 0.8)) {
                                    self.selectedPerson = nil
                                    self.isDetailPresented = false
                                }
                        }
                    
                    Text(selectedPerson.first + " " + selectedPerson.last)
                }
            }
        }
    }
}

struct PersonView: View {
    let person: Person
    let size: CGFloat
    
    var body: some View {
        Text(person.first)
            .padding()
            .frame(height: size)
            .background(Color.gray)
            .cornerRadius(5)
            .shadow(radius: 5)
    }
}

#Preview {
    PeopleView2()
}

As you can see in the code sample and in the repo I've attached, I've used it once inside the conditional and again inside the overlay's conditional. Both of them are directly on PersonView.

I took out the .transition as well, but that's not reflected in the screenshot, which removes the "jump" from 300 to 100 height at the end of the animation.

You've made a really cool effect! Just keep working with SwiftUI and try to learn "the Apple way" to think about how views are composed, and... well, you'll still run into these problems lol. But hopefully they'll be a little easier to diagnose.