1

I'm trying to achieve a smooth transition between 2 states of a SwiftUI view. The following code works great, when the offset changes, SwiftUI interpret that as 2 states of the same view (same identity), and transition with a slide of the view.

import SwiftUI

struct AnimationView: View {

    @State var number: Int = 0

    var body: some View {
        VStack {
            // VIEW #1
            Image(systemName: "0.circle.fill")
            // VIEW #2
//             Image(systemName: "\(self.number).circle.fill")
                .id("my-view-identity")
                .offset(x: self.number%2==0 ? 50 : -50, y: 0)
            Button {
                self.number += 1
            } label: {
                Text("Add 1")
            }
        }
        .animation(.linear(duration: 1), value: self.number)
    }
}

struct AnimationView_Previews: PreviewProvider {
    static var previews: some View {
        AnimationView()
    }
}

The problem is when I try using the VIEW #2, SwiftUI doesn't interpret the 2 states Image(systemName: "0.circle.fill") and Image(systemName: "1.circle.fill") as 2 states of the same view, but rather 2 different views with different identity, even tho I manually set an id. This results in a fade out of the first view, and a fade in of the second.

I also tried using a subview to create some kind of view proxy and set the fixed id to that view, but that's not doing anything.

I've watch the WWDC21 talk Demystify SwiftUI and think I have a good grasp over view identity, but this one I can't figure, my guess is that we can manually set a different id for a single view, but we can't do the opposite: set the same id for different views. Am I right ? Is there any trick that would make it possible ?

Damien
  • 3,322
  • 3
  • 19
  • 29
  • “Matched geometry effect” might be what you are looking for or maybe “geometry reader” as an alternative. – lorem ipsum May 19 '23 at 11:34
  • Tanks for the tip, this doesn't seems to work tho. It might be due to the fact there's a single view declaration in my case, not multiple views. – Damien May 19 '23 at 11:57

1 Answers1

1

Method 1: ZStack and .opacity()

This trick works. Put all of the image views in a ZStack and only make the current one visible using .opacity():

struct AnimationView: View {
    
    @State var number: Int = 0
    
    var body: some View {
        VStack {
            ZStack {
                ForEach(0..<51) { num in
                    Image(systemName: "\(num).circle.fill")
                        .opacity(self.number == num ? 1 : 0)
                }
            }
            .offset(x: self.number % 2 == 0 ? 50 : -50, y: 0)
            Button {
                self.number += 1
            } label: {
                Text("Add 1")
            }
        }
        .animation(.linear(duration: 1), value: self.number)
    }
}

This works because all of the views are on the screen (just not visible), so when you move them they aren't seen as a new view but ones that just changed opacity. This preserves the object identity and allows the move animation to work correctly.


Method 2: .id() and .matchedGeometryEffect()

Assign an .id(self.number) to the view to differentiate the views, and use .matchedGeometryEffect() to unify the views for the animation.

struct AnimationView: View {
    
    @State var number: Int = 0
    @Namespace var animation
    
    var body: some View {
        VStack {
            Image(systemName: "\(self.number).circle.fill")
                .matchedGeometryEffect(id: "id1", in: animation)
                .offset(x: self.number % 2 == 0 ? 50 : -50, y: 0)
                .id(self.number)
            Button {
                self.number += 1
            } label: {
                Text("Add 1")
            }
        }
        .animation(.linear(duration: 1), value: self.number)
    }
}

The order of the view modifiers is critical here. The .id() needs to come after the .offset() for this to work correctly.


Of these two methods, I would prefer Method 2 since it doesn't create 50 extra views!

vacawama
  • 150,663
  • 30
  • 266
  • 294
  • Thank you, that works indeed, there's just an opacity change during the animation that I would love to get ride of. Do you know why it works when the code I write didn't ? – Damien May 19 '23 at 12:27
  • 1
    It works because all of the views are on the screen (just not visible), so when you move them they aren't seen as a new view but ones that just changed opacity. This preserves the object identity and allows the move animation to work correctly. – vacawama May 19 '23 at 12:30
  • I might approve your answer, I'll just wait a bit longer to see if there's any other and better solution. – Damien May 19 '23 at 12:30
  • I added a second method that uses .matchedGeometryEffect(). – vacawama May 19 '23 at 12:51
  • Damn, I did the same thing when receiving the comment of @lorem-ipsum about matchedGeometryEffect, but I did put the id modifier before the matchedGeometryEffect. Thanks for finding that out, it's doing exactly what I wanted, the answer is approved – Damien May 19 '23 at 14:29
  • Apparently an other issue I had with matchedGeometryEffect was that the id was fixed – Damien May 19 '23 at 14:34