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



