Left cloud filled with code
Right cloud filled iwth code
A napping developer

Animating from one view to another in SwiftUI

Saturday, January 6, 2024 8:29 AM

The magic of matchedGeometryEffect()


Level: Intermediate


In this post, we explored how to reliably animate changes to a view’s properties by avoiding if…else conditional statements inside a view body. We learned how to leverage the power of the SwiftUI ternary conditional operator to do this. That solution works well when we want to animate changes to a single view, but what if we want to animate a change from one view to another? We can do that too, but the solution is a bit more complicated.

Here’s what we’re going to create:


We’ve got two views, a blue circle in the center and a yellow rectangle offset to the left. When the user switches the toggle on, the circle appears to smoothly morph into the rectangle with a subtle bounce at the end. When the user switches the toggle off, the rectangle morphs into the circle.

Let’s begin by creating our body and giving it a toggle. Just like last time, we’ll use an @State Boolean variable to keep track of the state of our toggle, and our toggle will use the same bouncy animation we used before:

import SwiftUI


struct ContentViewView {

  @State private var showRectangle = false

  

  var body: some View {

    VStack {

      Spacer()

      Toggle("Show the rectangle"isOn$showRectangle.animation(.bouncy)

      )

      .padding()

    }

  }

}


#Preview {

  ContentView()

}


Now we’ll create the circle and rectangle views and conditionally insert them based on the value of showRectangle:

import SwiftUI


struct ContentViewView {

  @State private var showRectangle = false

  

  var body: some View {

    VStack {

      Spacer()

      if showRectangle {

        rectangleView

      } else {

        circleView

      }

      Spacer()

      Toggle("Show the rectangle"isOn$showRectangle.animation(.bouncy)

      )

      .padding()

    }

  }

  

  var circleViewsome View {

    Circle()

      .foregroundColor(.blue)

      .shadow(color: .grayradius5)

      .frame(width150.0height150.0)

  }

  

  var rectangleViewsome View {

    Rectangle()

      .foregroundColor(.yellow)

      .shadow(color: .grayradius5)

      .offset(x: -50)

      .frame(width150.0height200.0)

  }

}


#Preview {

  ContentView()

}


No errors or warnings! We should be good to go. Run this in the preview window or simulator.




Hmmm. That’s not what we want. SwiftUI is doing something - it’s fading out the circle and fading in the rectangle. To see the difference, comment out the .animation(.bouncy) modifier. But where’s our smooth blending from one view to the other and the subtle bounce effect? Clearly, SwiftUI doesn’t have enough information to do what we want, even though this appears to be valid code. In my opinion a compiler warning would be nice here, but Xcode is silent.

The additional information that SwiftUI needs is provided by an instance method called matchedGeometryEffect(), which the Apple documentation tells us, 

"Defines a group of views with synchronized geometry using an identifier and namespace that you provide."

To use it, we have to add this method to each of the views we want to animate. Here’s how:


import SwiftUI


struct ContentViewView {

  @State private var showRectangle = false

  @Namespace private var myAnimation

  

  var body: some View {

    VStack {

      Spacer()

      if showRectangle {

        rectangleView

      } else {

        circleView

      }

      Spacer()

      Toggle("Show the rectangle"isOn$showRectangle.animation(.bouncy)

      )

      .padding()

    }

  }

  

  var circleViewsome View {

    Circle()

      .foregroundColor(.blue)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimation)

      .frame(width150.0height150.0)

  }

  

  var rectangleViewsome View {

    Rectangle()

      .foregroundColor(.yellow)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimation)

      .offset(x: -50)

      .frame(width150.0height200.0)

  }

}


#Preview {

  ContentView()

}


That works! The matchedGeometryEffect method takes a @Namespace property wrapper which we’ve called myAnimation and an id. It uses these to give SwiftUI enough information to synchronize the geometries of our two views, creating the appearance of a smooth transition from one to the other.

The matchedGeometryEffect method is pretty magical, but it can be tricky to use. For instance, view modifiers that affect the geometry of a view must appear after .matchedGeometryEffect() in your code, just as I’ve done with the .offset() and .frame() modifiers.

Our code works as expected and should be fine in the majority of cases, but can we do better? I suggested here that conditional statements inside a view might not be ideal. While the SwiftUI ViewBuilder can handle conditional content, there are the previously noted animation issues as well as potential performance issues related to the complexity of the view hierarchy. For a deep dive I recommend watching this WWDC21 talk, but for now, let’s see if we can achieve the same result without an if…else conditional.

You might be tempted to use a ternary operator inside our view like we did  here, but that won’t work here because body returns an opaque View of type 'some View'. Because a ternary operator in this case contains two different views (either a circle or a rectangle), Xcode will give you an error. And from a SwiftUI performance perspective, it’s actually better to always include both views in our view hierarchy and to use modifiers to alter their appearance.

In this case, the modifier we want to use is opacity. In the initial state we want the circle’s opacity to be 1 and the rectangle’s opacity to be 0. When the user slides the toggle to show the rectangle, we want the circle’s opacity to be 0 and the rectangle’s opacity to be 1. Let’s give it a try by changing our body:

 var body: some View {

    VStack {

      Spacer()

      ZStack {

        rectangleView

          .opacity(showRectangle ? 1 : 0)

        circleView

          .opacity(showRectangle ? 0 : 1)

      }

      Spacer()

      Toggle("Show the rectangle"isOn$showRectangle.animation(.bouncy)

      )

      .padding()

    }

  }


No errors, but our animation is broken again and our circle isn’t centered anymore!. Why? Using matchedGeometryEffect() can be more complicated (and sometimes mysterious) than our first implementation. It actually accepts more parameters than we needed in our first solution, and in this case we need to use one of them called isSource. As the name implies, isSource is a Boolean that tells matchedGeometryEffect() which of the views it should use as the source when the view changes. In order to fix our animation, we need to make the circle the source when the rectangle will appear and vice versa. Fortunately, we already have a variable we can use to do this called showRectangle.

Here’s the final implementation:

import SwiftUI


struct ContentViewView {

  @State private var showRectangle = false

  @Namespace private var myAnimation

  

  var body: some View {

    VStack {

      Spacer()

      ZStack {

        rectangleView

          .opacity(showRectangle ? 1 : 0)

        circleView

          .opacity(showRectangle ? 0 : 1)

      }

      Spacer()

      Toggle("Show the rectangle"isOn$showRectangle.animation(.bouncy)

      )

      .padding()

    }

  }

  

  var circleViewsome View {

    Circle()

      .foregroundColor(.blue)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimationisSource: !showRectangle)

      .frame(width150.0height150.0)

  }

  

  var rectangleViewsome View {

    Rectangle()

      .foregroundColor(.yellow)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimationisSourceshowRectangle)

      .offset(x: -50)

      .frame(width150.0height200.0)

  }

}


#Preview {

  ContentView()

}


Note that I’ve attached the .opacity() modifiers to the rectangleView and circleView within the ZStack.  I think this makes it easier to understand the behavior of the two views. But if you prefer to keep your view body as sparse as possible, you can add the .opacity() modifiers to the Circle() and Rectangle() inside the circleView and rectangleView variables, like this:

 var circleViewsome View {

    Circle()

      .foregroundColor(.blue)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimationisSource: !showRectangle)

      .frame(width150.0height150.0)

      .opacity(showRectangle ? 0 : 1)

  }

  

  var rectangleViewsome View {

    Rectangle()

      .foregroundColor(.yellow)

      .shadow(color: .grayradius5)

      .matchedGeometryEffect(id"ID"inmyAnimationisSourceshowRectangle)

      .offset(x: -50)

      .frame(width150.0height200.0)

      .opacity(showRectangle ? 1 : 0)

  }


We’ve just scratched the surface of the power (and complexity) of matchedGeometryEffect(). You can read more about it here.



•••

If you find any of these posts useful, please make a charitable donation

Link to the Epilepsy Foundation of Kentuckiana
Link to The Asclepius Initiative