Home > Enterprise >  SwiftUI: remove view at animation end?
SwiftUI: remove view at animation end?

Time:05-24

I have the following code:

enum AnimationDirection {
    case opening
    case closing
}

struct AnimtionStateMod: AnimatableModifier {
    
    var progress: CGFloat = 0
    private(set) var direction:AnimationDirection = .opening
    
    var animatableData: CGFloat {
        get { progress }
        set {
            direction = progress > newValue ? .opening : .closing
            progress = newValue
            print("progress: \(progress). direction: \(direction)")
        }
    }
    
    func body(content: Content) -> some View {
        content.opacity(1)
    }
    
}



struct PersonDetails: View {
    @State var person:Person
    
    var body: some View {
        ZStack {
            
            Color.yellow
            
            HStack {
                Text("PERSON")
                Spacer()
            }
            
            Text(person.name)
            
            Spacer()
            
        }
        .border(.red)
    }
}


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





struct ContentView: View {

    @State var persons = [Person(name: "A"), Person(name: "B"), Person(name: "C")]
    
    @Namespace private var gridSpace
    @State var selectedPerson:Person?
    @State var expandingPersonDetails:Bool = false
    var matchDetailsToPerson: Bool { !expandingPersonDetails }
    @State var matchSelectionToGridItem = true
    
    let c = GridItem(.adaptive(minimum: 200, maximum: 400), spacing: 20)

    var body: some View {

        ZStack {
            
            ScrollView {
                LazyVGrid(columns: [c]) {
                    ForEach(persons) { person in
                        
                        ZStack {
                            Color.blue
                            Text(person.name)
                                .foregroundColor(.white)
                        }
                        .frame(width: 200, height: 200)
                        .onTapGesture {
                            print(person.name)
                            withAnimation {
                                selectedPerson = person
                            }
                            
                        }
                        .matchedGeometryEffect(id: person.id, in: gridSpace, isSource: true)
                        
                    }
                }
            }
            .zIndex(1)
            
            
            if let person = selectedPerson {
                
                PersonDetails(person: person)
                    .matchedGeometryEffect(id: matchDetailsToPerson ? person.id : UUID() , in: gridSpace, isSource: false)
                    .frame(width: 600, height: 600)
                    .zIndex(2)
                    .onAppear {
                        withAnimation {
                            expandingPersonDetails = true
                        }
                    }
                    .onDisappear {
                        expandingPersonDetails = false
                    }
                    .transition(.identity)
                    .modifier(AnimtionStateMod(progress: expandingPersonDetails ? 1 : 0))
                    .onTapGesture {
                        withAnimation {
//                            selectedPerson = nil
                            expandingPersonDetails = false
                        }
                    }   
            }   
        }
    }

It behaves like this:

enter image description here

What I am stuck on:

I want PersonDetails to be removed once it has finished animating back on the grid item. The open animation (from grid to expanded PersonDetails) looks like I want it to look. The close animation (when PersonDetails scales back to the related grid item) looks like I want it to look. But, I want PersonDetails to be removed once it lands back on the grid item.

I thought I could use AnimatableModifier to track the animation when going back on the grid item and then trigger selectedPerson to nil. I think I could use that approach, but, I am not sure if that is what I should be doing in this case.

How should I go about this?

JUST TO ADD FEEDBACK:

using asperi's answer I get a different result. I am also using a newer version of Xcode, that's the only thing I can think of right now, but I wanted to provide feedback here for context:

enter image description here

CodePudding user response:

If I correctly imagined your needs (I'm still not sure), the effect can be achieved in simpler way - by animating just selected person and having person view with overlayed matching the same to keep origin in place.

Tested with Xcode 13.3 / iOS 15.4

demo

Here is main part:

ZStack {
    ScrollView {
        LazyVGrid(columns: [c]) {
            ForEach(persons) { person in
                PersonView(person: person)   // << just to keep same in place
                    .overlay(
                        PersonView(person: person)
                            .matchedGeometryEffect(id: person.id, in: gridSpace)
                            .onTapGesture {
                                if nil == selectedPerson {
                                    selectedPerson = person
                                }
                            })
                    .frame(width: 100, height: 100)
            }
        }
    }
    VStack {   // << to remove fluently
        if let person = selectedPerson {
            PersonDetails(person: person)
                .matchedGeometryEffect(id: person.id, in: gridSpace)
                .transition(.scale(scale: 1))
                .frame(width: 360, height: 360)
                .onTapGesture {
                    selectedPerson = nil
                }
        }
    }
}
.animation(.default, value: selectedPerson)

Complete test module is here

CodePudding user response:

Asperi's code is quite simple and effective. If you want a different approach, although a little more complicated, you can try setting a duration to the animation and - only after that - set the selectedPerson to nil.

First, inside ContentView set a constant for the duration of the effect:

    // This is the duration of the animation
    let effectDuration = 0.5

Then, use a transition with an animation based on the expandingPersonDetails variable. You can completely drop the AnimatableModifier. Before dismissing the person detail, first change the variable expandingPersonDetails to place the view back to its original position. Only after the animation is over, using .asyncAfter() you can dismiss the selectedPerson. Here:

PersonDetails(person: person)

    // Use simple transition
    .transition(.opacity)

    // The duration must match the .asyncAfter method below
    .animation(.easeInOut(duration: effectDuration), value: expandingPersonDetails)

    .matchedGeometryEffect(id: expandingPersonDetails ? UUID() : person.id , in: gridSpace, isSource: false)
    .frame(width: 600, height: 600)
    .zIndex(2)
    .onAppear {
        withAnimation {
            expandingPersonDetails = true
        }
    }
    .onTapGesture {
        withAnimation {
            
            // Change this variable first
            expandingPersonDetails = false
            
            // When the animation is over, un-select the person
            DispatchQueue.main.asyncAfter(deadline: .now()   effectDuration) {
                selectedPerson = nil
            }
        }
    }
  • Related