0

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 Answers 2

4

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.

Sign up to request clarification or add additional context in comments.

5 Comments

Is there no way to create it have the same effect while within the ScrollView? The idea is to have a content which will be presented from a ScrollView list. This limits the functionality as you cannot scroll while dragging the elements.
@Astro The problem was that the tap handler on the detail was consuming the drag gesture. I think the solution is to move the tap gestures to the bases. Then .allowsHitTesting(false) can be applied to the floating views, so that tap gestures pass through to the underlying bases. Answer updated, hope it helps.
This is an interesting solution. It seems as the main road block towards having a more simple setup is the height constraints of the Horizontal ScrollView? Also, would it be possible to still display other people cards while one is selected? .opacity(selectedPerson == nil || selectedPerson?.id != person.id ? 1 : 0) Almost works but hides the presented view as well.
@Astro If you want the other cards to remain visible then just comment out the .opacity modifier in personView, answer updated. Re. height of ScrollView, don't understand the issue, sorry. I would say, the main road block towards a more simple solution is the fact that you want .matchedGeometryEffect to work smoothly. However, if you want more rows of cards then these can be added as independent rows, each having its own ScrollView. On the other hand, if you want to have a scrollable grid then you need to change the HStack inside the ScrollView to some kind of grid instead.
This fixe an issue I was having. ChatGPT couldn't figure it out. Thanks so much!
1

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.

2 Comments

Please include all code relevant to your answer. Links to repos can go stale rendering the answer without all the information it requires.
@Andrew Gotcha. Done.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.