skip to Main Content

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


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

    I’ve put your original code and my solution on GitHub for you here. You’ll want to adjust it a bit because I didn’t center the detailed view on the screen like you did, but everything else works.

    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.

        var body: some View {
            ScrollView {
                VStack {
                    ScrollView(.horizontal) {
                        HStack {
                            ForEach(people) { person in
                                //  if !isDetailPresented {
                                if person != selectedPerson {
                                    PersonView(person: person, size: 100)
                                        .matchedGeometryEffect(id: person.id, in: animationNamespace)
                                        .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)
                                .onTapGesture {
                                    withAnimation(.interactiveSpring(
                                        response: 0.3,
                                        dampingFraction: 0.8,
                                        blendDuration: 0.8)) {
                                            self.selectedPerson = nil
                                            self.isDetailPresented = false
                                        }
                                }
                            
                            Text(selectedPerson.first + " " + selectedPerson.last)
                        }
                    }
                }
            }
        }
    

    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.

    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.

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

    Also removed an image and replaced with text.

    Login or Signup to reply.
  2. 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

    You might also want to make sure that the rectangles which are shown as placeholders for the cards when a selection is active have the same size and rounded corners as the cards themselves.

    Also as a suggestion, you don’t need a separate state variable for when a selection is active. A computed property can be used instead:

    @Namespace private var animationNamespace
    @State private var selectedPerson: Person? = nil
    
    private var isDetailPresented: Bool {
        selectedPerson != nil
    }
    

    As for the other comments made in another answer, your use of computed properties and functions for returning Views seems fine to me. I use this technique a lot for breaking down large view sections into smaller sections. But we all have our own personal preferences and opinions.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search