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