LANARS

The Ultimate Guide to Creating a Side Menu in SwiftUI: Part 2. Custom transition and animations

The Ultimate Guide to Creating a Side Menu in SwiftUI: Part 2. Custom transition and animations
Time to read
24 min
Section
Share
Subscribe
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Welcome back

Hey there, folks. In part 1 of this series of articles we covered writing your own components in SwiftUI-designed way by using Label, LabelStyle, ButtonStyle and others. In addition, you could see how simple it is to design your own container and pass presentation information to siblings just like Apple does with their Sheet and similar presentation containers.

Today we will continue our journey into the SwiftUI by learning how to create your own transitions, how to do fancy animations and to get layout data in declarative world. 

Selection

In this section we will implement the handling of a selection of an exact item in the list. You can start from this commit where we left off in the previous part.

Let us remind you what we need to implement:

Anchor and AnchorPreference

As you can see from a preview above, we're going to highlight the exact button. We could switch on and off a background for a selected button, but that's neither interesting nor challenging and would lead to a simple fade-in/out animation.

The example above looks like it animates the background by moving it from one button to another. 

In plain UIKit, we could read the frame for each subview and then move a single background view to an appropriate position, but what about SwiftUI? Its declarative nature doesn't even let us read this data directly, however, there are a few tools that address this:

  • GeometryReader — a special view that gives you access to a GeometryProxy — a proxy to a different geometry characteristic such as frame, size, and an Anchor
  • matchedGeometryEffect - a modifier that lets you synchronize geometry across a group of views. We recommend this series from swiftui-lab.com of articles to get a better understanding of what and how.
  • anchorPreferences — a modifier that lets you save geometry characteristics from a given view's coordinate space to preferences and then convert them to your coordinate space via GeometryReader. 

Sure, this is not an exhaustive list of available modifiers, views, and effects, yet more than enough for our task. 

We'll start from the first and the last options in conjunction. Here is a breakdown of what can be done for a dynamic selection that would highlight the whole button: 

  1. Get its bounds in a local coordinate space through anchor preferences. 
  2. Store it in the preferences. As a result, we will have a dictionary of bounds for each menu item.
  3. Upon selected menu item, change move selection background to a corresponding item's bounds, converted to our coordinate space. 

It's time to bind the plan above with practical knowledge. First of all, we need to store the received anchor somewhere — this is what method signature — anchorPreference(key:value:transform:) requires us to do. Create a new file called MenuItemGeometryPreferenceKey:

struct MenuItemGeometry {

  let item: MenuItem
  let bounds: Anchor<CGRect>
}

struct MenuItemGeometryPreferenceKey: PreferenceKey {

  static var defaultValue: [MenuItem: MenuItemGeometry] = [:]

  static func reduce(value: inout [MenuItem : MenuItemGeometry], nextValue: () -> [MenuItem: MenuItemGeometry]) {
    value.merge(nextValue()) { _, rhs in rhs }
  }
}

Take a moment to check what we've done just a moment ago: this preference is storing an anchor per item. We'll get back to the nature and capabilities of Anchor a bit later, but for now, we can preserve the geometry data of each button. In order to do so, go to the MenuItemList and update itemsList property as follows:

@ViewBuilder
private var itemsList: some View {
  VStack(alignment: .leading) {
    ForEach(items) { item in
      Button(
        action: {
          if selection == item {
            selection = nil
          } else {
            selection = item
          }
        },
        label: {
          Label(item: item)
        }
      )
      .buttonStyle(MenuItemButtonStyle(isSelected: item == selection))
      .anchorPreference(key: MenuItemGeometryPreferenceKey.self, value: .bounds) {
        [item: $0]
      }
    }
  }
  .backgroundPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { preferences in
    fatalError("To be implemented")
  }
}
 

Nothing special this time either, yet this code fulfilled our first requirement: by adding a anchorPreference modifier for each Button we then pass it via the preferences hierarchy higher up to the backgroundPreferenceValue, where preferences are of [MenuItem: Anchor<CGRect>] type. Thus, we have all of the frames in one place, but what does backgroundPreferenceValue on itself? Apple is clear on that: 

Reads the specified preference value from the view, using it to produce a second view that is applied as the background of the original view.

With that being said, it's a perfect place to return our selection background. Place the following code below our itemsList property:

@ViewBuilder
private func selectionBackground(from preferences: MenuItemGeometryPreferenceKey.Value, in geometry: GeometryProxy) -> some View {
  if let selection, let frame = preferences[selection].map({ geometry[$0] }) {
    RoundedRectangle(cornerRadius: 13)
      .frame(width: frame.width, height: frame.height)
      .offset(x: frame.minX, y: frame.minY)
  }
}

 

To use this new view in our list, we just need to update a backgroundPreferenceValue body from an itemsList above:

.backgroundPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { preferences in
  GeometryReader { proxy in
    selectionBackground(from: preferences, in: proxy)
      .foregroundColor(Color("color/selectionBackground"))
  }
}

Before we dive into explanations, let's finally build and run to see what we have: 

Wow, this looks already like what we wanted initially. Yet, it is not fancy enough, so we'll improve the selection animation even more in a few minutes. Now it's time to explain what we did above and why.

In previous steps of implementation, we gathered the geometry characteristic (frame) of each button into a preference. After that, we managed to move our selection background to a frame of a selected view or even dissapear altogether in case there is no selection whatsoever.

As you could notice, Anchor<CGRect> gathered into a dictionary acts as a proxy that knows how to translate exact geometry characteristics according to a given coordinate space. And GeometryReader, which holds our selectionBackground defines one automatically. 
Also, SwiftUI doesn't let us set the frame directly, and instead, we're forced to offset view first and set a frame size.

We could easily end here and move to a background transition, however, we're not done yet. Let's add a subtle transition effect when selection goes from one button to another:

Woah, that's looks neat, isn't it? But how can we achieve this effect with SwiftUI? 
Don't scroll below right away, give yourself a minute to think about how it can be achieved first. 

Ready to move on? Ok, the idea is dead-simple and surprisingly short in SwiftUI: on top of our items list, we can add an already selected list with a background, filled by a selection color. Then all we'll have to do is move the mask, that "shows" this overlay over our list. Hard to imagine? Take your time and when ready, it's time to add more code to MenuItemList.itemsList property. But this time instead of a backgroundPreferenceValue we'll use its counterpart — overlayPreferenceValue:

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
    ForEach(items) { item in
      Button(
        action: { /* noop */ },
        label: { Label(item: item) }
      )
      .buttonStyle(MenuItemButtonStyle(isSelected: true))
    }
  }
  .background(Color("color/selectionBackground"))
}

Oh. This doesn't look like a finished version, so let's finally add a mask to the VStack from the code above: 

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
  ...
  .background(Color("color/selectionBackground"))
 .mask {
    GeometryReader { proxy in
      selectionBackground(from: value, in: proxy)
    }
  }
}

Ok, now we achieved the same appearance as before the mask, but attempting to select anything results in nothing. The thing is since we've added an overlay full of buttons, all of them react to our taps, however, action closure is empty. And this is done intentionally. Let me add a final line after mask modifier and explain it:

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
  ...
  .background(...)
  .mask {  
    ...
  }
  .allowsHitTesting(false)
}

After this small change, you can build and run to get a final smooth animation from one selected menu item to another.

But why didn't we handle touches in the overlay? The thing is, by doing so we would effectively grab touches from the original list, thus leaving it without any information about whether the button is being pressed or not. Not a big deal at a glance, but this will break a very important UX part — visual feedback since no highlight animation of the selected menu item will be done. You can experiment to see the outcome. Therefore, what we did is simply exclude the whole overlay from a user interaction, yet being rendered onscreen.

The final version can be found here.

Custom Transition

In addition to selection, we also want to have a customized transition between color schemes. You can refer to the very beginning of the article to see that there is a picker that can also be expanded/collapsed just as the rest of the menu.

Moreover, during the selection of a different color scheme, a new background gradually fades in, repeating the shape of the toggle. But before we start, let's add the existing ColorSchemePicker to match the menu to our design: it should go right after all of the menu items. This view already exists in the sample app, so all we need is to add it in the MenuView:

var body: some View {
  VStack {
    MenuItemList(selection: $selection)
    ...
    Spacer()
      .frame(height: 10)

    ColorSchemePicker(selection: Binding($colorScheme, default: systemColorScheme))
      .overrideColorScheme($colorScheme.animation(.default))

    ...
  }
  ...
}

You can notice overrideColorScheme modifier that we didn't cover before, and it's not presented in the SwiftUI library. The whole purpose of it is to override the color scheme of encompassing UIWindow. The reason for going this way is because preferredColorScheme does override the color scheme, however after the menu disappears, there is no more View in hierarchy that sets a preferred color scheme, so it will be reset automatically right after the menu goes off screen. You can investigate implementation if you'd like. If there is anything in the code that we didn't cover, please, explore the source code repo.

Once that's done, let's build and run to see how our menu slowly and steadily becomes the one from the gif in part 1:

But at this point, if you select any of the color schemes, the UI simply performs a fade transition. What we really would want here is our shape-based fading in. Take a moment to think how you would achieve this step-by-step without even taking SwiftUI into account. Ready to move on? 

Ok, here is what we can do to achieve this: clearly, there should be a shape with a color we would like to have, that would act as our background. We can either clip our whole background or scale it, actually it doesn't matter that much here.

The next part is to get a frame from which we can start to fade in and, of course, a frame to which we should resize our shape. Going back to UI, it becomes obvious that the initial frame is the same as Picker has, while the final — the whole view's bounds. 

AnyTransition

Have you ever wondered how does AnyTransition work? We highly encourage you to go through a series of articles about animation from swiftui-lab.com: Animations and specifically Advanced Transitions to get a really deep understanding of transition and animation capabilities. Let us briefly go through an example of an opacity transition and then implement our shape-fade transition.

ViewModifier and AnyTransition.modifier(active:idenity:) — this is literally all that stands behind the transition magic. SwiftUI through its machinery identifies the view that will appear or disappear and animates the difference between two states through the given modifiers. It is important to understand that the modifier remains applied even after view appearance, in case it's an insertion transition.

You could notice a different animation a few moments ago, but how does SwiftUI know about what has changed? This is where the second player enters the game — Animatable

public protocol Animatable {
    /// The type defining the data to animate.
    associatedtype AnimatableData : VectorArithmetic
    /// The data to animate.
    var animatableData: Self.AnimatableData { get set }
}

We won't dive really deep with this one — the animation goes way beyond today's topic, but just note that SwiftUI knows through a VectorArithmetic protocol and can interpolate between two snapshots.

As an example, you can think of having two values of opacity: 0 and 1. SwiftUI's job during animation is to change the value of opacity between 0 and 1 according to the curve of animation (linear, easy in-out, etc). By applying our opacity modifier over and over again with an interpolated value we can see a smooth opacity change. This is basically the whole idea of an animated transition in a nutshell.

Now it is time to finally build on with all the knowledge we gained. The first step will be to get a Picker's frame that we will fade in from. As before, we can use Anchor to get a frame and pass this data over the hierarchy by preferences and specifically by calling an anchorPreference modifier. Since all the data we need is the Anchor itself, let's simply pass it to the preferences. The source code already has all the structs it needs, but let's review them first:

struct AnchorPreferenceKey<Value>: PreferenceKey {

  static var defaultValue: Anchor<Value>? {
    nil
  }

  static func reduce(value: inout Anchor<Value>?, nextValue: () -> Anchor<Value>?) {
    value = nextValue() ?? value
  }
}

As you can see, that's just a generic wrapper to pass any Anchor through preference. The only tricky moment here is that we use the previous value in case nextValue() returns nil and the reason for such code is that preferences seem to throw away the anchor's value once the layout is done, but our transition should be performed at any point in time, so we want to keep most recent valid value. 

Once that's done, we can update our MenuView to both apply anchor preference and update the background to use anchor for the transition we're going to build:

var body: some View {
  VStack {
    ...

    ColorSchemePicker(selection: Binding($colorScheme, default: systemColorScheme))
      .anchorPreference(key: AnchorPreferenceKey<CGRect>.self, value: .bounds) { $0 }
      .overrideColorScheme($colorScheme.animation(.default))

    ...
  }
  ...
  .backgroundPreferenceValue(AnchorPreferenceKey<CGRect>.self) { anchor in
    MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  }
  ...
}

A few moments to explain: we added the setting of a Picker's anchor to AnchorPreferenceKey and changed the background to backgroundPreferenceValue. The latter gives us the ability to read a last known anchor and we can use it for our transition.

Create a new file MenuBackgroundFadeInModifier and paste the following code: 

private struct MenuBackgroundFadeInModifier: ViewModifier {

  private let identity: Bool
  private let anchor: Anchor<CGRect>?

  init(identity: Bool, anchor: Anchor<CGRect>?) {
    self.identity = identity
    self.anchor = anchor
  }

  func body(content: Content) -> some View {
    content
  }
}

extension AnyTransition {

  static func menuBackgroundFadeIn(from anchor: Anchor<CGRect>?) -> AnyTransition {
    .asymmetric(
      insertion: .modifier(
        active: MenuBackgroundFadeInModifier(identity: false, anchor: anchor),
        identity: MenuBackgroundFadeInModifier(identity: true, anchor: anchor)
      ),
      removal: .identity
    )
  }
}

For now, we applied another new piece of AnyTransition: asymmetric that lets us specify different transitions for insertion and removal of a view and since we don't really want to apply the same fade-in animation, let's leave identity for fade-out.

MenuBackgroundFadeInModifier doesn't do anything except return the unmodified content. You could notice that we passed an anchor from Picker as well as a bool flag, but how about a final frame?

Here we should take into account the layout process of SwiftUI: a parent proposes its size to a child and the child then returns its own counterproposal. The parent must respect that choice and position the child accordingly. It means, that our background will receive the whole available space of an encompassing view and thus we don't need to pass this size directly.

The next step will be to interpolate between two frames. But how do we do this? We can take into account passed identity and in case it is then use it. Otherwise, use the whole given frame.

As you remember, to convert Anchor to a local coordinate space we need a GeometryReader, since Anchor in itself encodes the source of its geometry characteristic and GeometryReader knows how to convert it properly to an absolute value.

One more thing before we start coding is that an anchor can be optional, therefore we can return the whole available frame in this case treating it as an invalid scenario: 

func body(content: Content) -> some View {
  GeometryReader { proxy in
    let frame = effectiveFrame(using: proxy)

    content
      .frame(width: frame.width, height: frame.height)
      .position(x: frame.midX, y: frame.midY)
  }
}

private func effectiveFrame(using proxy: GeometryProxy) -> CGRect {
  guard let anchor, !identity else {
    return proxy.frame(in: .local)
  }

  return proxy[anchor]
}

Here is what's going on in the code snippet above in case it is a finished transition, the view gets a full proposed size of the parent just as we described above. GeometryReader accepts the proposed size fully and thus when the transition is finished, the background gets a full menu size as well. In case the transition starts and there is an anchor available, we will set it as a base frame. 


You may ask: "Ok, but you mentioned Animatable protocol in order to animate changes, but still not using it. What's the deal?", — and be totally correct about it. The reason for not using explicit animation is that frame and position modifiers know how to animate themselves under the hood. We just reuse existing capabilities. 

Once we finished with fade-in, let's update our background in MenuView to use animation:

MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  .transition(.menuBackgroundFadeIn(from: anchor))

Build and run. Woah... It doesn't work. The view simply changes color animated. Any ideas? 

As you can see, our view never goes away and for SwiftUI it is always the same. But how do we force it to transition every time the color scheme changes to trigger our transition?

This is where explicit identity comes into play. SwiftUI heavily uses structural identity to figure out whether it's the same view or not. But what if we want to say that even though the structure of views is the same, this is a different view? id modifier lets us do exactly this. By passing a different value, SwiftUI will treat the view as a totally new one:

MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  .transition(.menuBackgroundFadeIn(from: anchor))
  .id(colorScheme ?? systemColorScheme)

Build and run:

Looks nearly ok, except that the old background dissappears immediately. This is not something we want, though. The desired effect here would be to keep it alive during the whole animation without any modification. But how we can achieve it?

This is where Animatable can help us: we can force SwiftUI to animate "something" useless, while we will keep the view in the hierarchy unmodified. In MenuBackgroundTransition, add one more modifier and update our transition:

private struct MenuBackgroundFadeOutModifier: ViewModifier, Animatable {

  var animatableData: Double

  func body(content: Content) -> some View {
    content
  }
}

extension AnyTransition {

  static func menuBackgroundFadeIn(from anchor: Anchor<CGRect>?) -> AnyTransition {
    .asymmetric(
      insertion: .modifier(
        active: MenuBackgroundFadeInModifier(identity: false, anchor: anchor),
        identity: MenuBackgroundFadeInModifier(identity: true, anchor: anchor)
      ),
      removal: .modifier(
        active: MenuBackgroundFadeOutModifier(animatableData: 0),
        identity: MenuBackgroundFadeOutModifier(animatableData: 1)
      )
    )
  }
}

As you can clearly see, we do not modify anything, and do not actually animate, but by making our modifier animatable and passing a different values, we force SwiftUI to apply our fade-out modifier for the whole animation duration resulting in a nice and clean transition:

The final version of the source code can be found here.

Summary

During the two parts of this story, we went from nothing to a full-fledged and nicely animated menu, covered a bunch of details of what and why it works the way it works, we guided you along the path of mastering SwiftUI with us. Such an approach is what was lacking in various tutorials that would show you separate parts and concepts of SwiftUI and you'd have to combine everything together to get a product-like project on your own. We hope this menu guide answered all of your questions along the path. 

As you can see, SwiftUI does get complicated when it comes to various interesting interactions, and animations, but becomes very powerful in its capabilities. It is hard to get everything done the first time, but the more knowledge and experience you get, the easier it becomes. 

It was a great journey and we appreciate your patience during it. Thanks for reading and see you next time!