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.
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:
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:
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:
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 — anchor
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.
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 becausepreferredColorScheme
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.
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.
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!
08.01.2024
Mobile development with Flutter - Pros and Cons in 2024What is the point of developing apps using the Flutter framework, and what are the advantages? Is it flawless to the point that no one can find any major errors? Is Flutter the way to go if you want to create an app that works on several platforms? Find the answers here.Read more04.01.2024
7 Top IDEs for Android Development in 2024Back in the day, everything had to be typed in by hand using a simple text editor that didn't have built-in debuggers or continuous text analysis to show syntax mistakes right away.Read more28.12.2023
Top 10 Mobile apps utilizing AIIn the year 2024, one can discover an abundance of AI products and services. Everything from top-tier AI writing apps to AI image editors is available. Here is a selection of the best AI apps for both Android and iOS to help you out.Read more