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

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

In plain UIKit, we could read 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 few tools that addresses this:

  • GeometryReader — a special view that gives you access to a GeometryProxy — a proxy to a different geometry characteristics 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 characteristic from a given view's coordinate space to preferences and then convert to your coordinate space via GeometryReader. 

Sure, this is not an exhaustive list of available modifiers, views and effects, yet more that 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 plan above with a practical knowledge. First of all we need to store 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 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 out first requirement: by adding a anchorPreference modifier for each Button we then pass it via preferences hiearchy higher up to the backgroundPreferenceValue, where preferences is 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 a 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 a 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, not fancy enough, so we'll improve selection animation even more in a few minutes. Now it's time to explain what we did above and why.

At previous steps of implementation we gatherer 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 altoghether 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 characteric according to a given coordinate space. And GeometryReader, that holds our selectionBackground defines one automatically. 
Also, SwiftUI doesn't let us set 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 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 already selected list with a background, filled by a selection color. Then all we'll have to do is move 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 it's 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 a 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 mask, but attempt to select anything results into 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 whether 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 excluded the whole overlay from a user interaction, yet being rendered onscreen.

Final version can be found here.

Custom Transition

In addition to selection we also want to have a customized transiton between color schemes. You can refer to the very beggining 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 gradially fades in, repeating shape of the toggle. But before we start, let's add existing ColorSchemePicker to match 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 color scheme of encompassing UIWindow. The reason of going this way is because preferredColorScheme does override color scheme, however after menu dissappears, there are no more View in hierarhcy that sets a preferred color scheme, so it will be resetted automatically right after menu goes off screen. You can investigate implementation, if you'd like. If there anything in code that we didn't cover, please, explore source code repo.

Once that's done, lets 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 scheme, 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, 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 specifcially Advanced Transitions to get a really deep understanding of transition and animation capabilities. Let us briefly go through an example of a 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 it's machinery identifies the view that will appear or dissapear and animates 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 few moments ago, but how does SwiftUI knows 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 our 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 value of opacity between 0 and 1 acorrding to the curve of animation (linear, easy in out, etc). By applying our opacity modifier over and over againg 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. 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. 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 previous value in case nextValue() returns nil and the reason for such code is that preferences seem to throw away anchor's value once 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 background to use anchor for 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)
  }
  ...
}

Few moments to explan: we added setting of a Picker's anchor to AnchorPreferenceKey and changed 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: assymetric 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 returning the unmodifier 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 layout process of SwiftUI: a parent proposes it's size to a child and the child then returns it's own counterproposal. The parent must respect that choice and position the child accordingly. It means, that our background will receive whole available space of an encompassing view and thus we don't need to pass this size directly.

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 whole give frame.

As you remember, to convert Anchor to a local coordinate space we need a GeometryReader, since Anchor in itself encodes source of it's geometry characteristic and GeometryReader knows how to covert it propery 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 proposed size fully and thus when transition is finished, the background gets a full menu's size as well. In case transition started 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 themselfes 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. 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 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 tell that even though structure of views is the same, this is a different view? id modifier let us do exactly this. By passing a different value, SwiftUI will treat 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 old background dissappears immediately. This is not something we want, though. 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 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 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 into a nice and clean transition:

The final version of 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 were 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 avarious interesting interactions, animations, but becomes very powerful in it's capabilities. It is hard to get everything done at 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!