iosswiftswiftuiswiftui-animationmatchedgeometryeffect

SwiftUI .matchGeometryEffect not working smoothly


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
            }
        }
    }
}

Solution

  • 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.