Hey there! Today we're going to go through a journey from having nothing to having a nicely animated, UX-aware side menu, building it step-by-step, and sharing the best practices with you.
We will answer some of the questions, starting from really basics up to various hints and tricks that you may need in your product, by taking into account code quality and following SwiftUI API style.
This is what we're going to talk about in this series of articles by gradually increasing complexity of covered topics:
Here is what we'll end up with:
Before we start, you may want to download initial project from our GitHub repo to follow code by hands. It's build on top of Swift Package Manager and assumes you have basic experience with it. Once downloaded, open "Example" project and we're ready to go.
From time to time we need to show a menu or any other supplementary/navigation content that should be revealed from leading/trailing edge. And we don't want to repeat it over and over again, so our Drawer should be generic enough over it's content to be used in any place, so let's begin by opening a Drawer.swift
and jump into the code right away:
public struct Drawer<Menu: View, Content: View>: View {
@Binding private var isOpened: Bool
private let menu: Menu
private let content: Content
// MARK: - Init
public init(
isOpened: Binding<Bool>,
@ViewBuilder menu: () -> Menu,
@ViewBuilder content: () -> Content
) {
_isOpened = isOpened
self.menu = menu()
self.content = content()
}
// MARK: - Body
public var body: some View {
ZStack(alignment: .leading) {
content
if isOpened {
Color.clear
.onTapGesture {
if isOpened {
isOpened.toggle()
}
}
menu
.transition(.move(edge: .leading))
}
}
.animation(.spring(), value: isOpened)
}
}
We're almost done with basics. It is surprising how simple and concise code is thanks to SwiftUI. You can add a following code to either a Preview or to a ContentView.swift
in the Example app:
struct ContentView: View {
@State private var isOpened = true
var body: some View {
Drawer(
isOpened: $isOpened,
menu: {
ZStack {
Color.gray
Text("Menu")
}
.frame(width: 200)
},
content: {
ZStack {
Color.white
Button {
isOpened.toggle()
} label: {
Text("Open")
.foregroundColor(Color.secondary)
}
}
}
)
}
}
After running, you can quickly notice an issue with app not reacting to touches outside of a menu as well as the menu itself dissapearing whenever you click on a "Open" button.
First of all, let's fix and understand a pretty simple issue: Color.clear
is invisible for touches which is somehow expected. To resolve this, there are workarounds such as placing a solid color of 0.01 opacity, but we're going to use an intended view modifier — contentShape — that sets tappable area for a view hit testing.
Also, let's break down the issue with accidental dissappearing on menu dismissal:
ZStack(alignment: .leading) {
content
if isOpened {
...
menu
.transition(.move(edge: .leading))
}
}
The thing is that ZStack lays subviews out by a zIndex order. By default, it is inferred by order of declared views. Once we remove our menu from the ZStack
there is zero reason to place it on top even being declared as a most latest one. But we can force the view to be layed out as top-most even upon removal by using zIndex view modifier.
Here is our updated Drawer
's body:
public var body: some View {
ZStack(alignment: .leading) {
content
if isOpened {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
if isOpened {
isOpened.toggle()
}
}
menu
.transition(.move(edge: .leading))
.zIndex(1)
}
}
.animation(.spring(), value: isOpened)
}
Now, after building and running we can get exactly what we wanted to do:
Ok, we've done really basic interactions so far. However, this is obviously not the state-of-art of a reusable component. For being generic enough, we need to establish communication with siblings to share information whether they are presented or not and — what's equally important — to let them dismiss themselves.
In the UIKit world, you could traverse parent-chiled hierarchy until desired view controller is found — just like navigationController behaves.
In SwiftUI world, this pattern is no longer applicable. To address this capability, Apple uses a PresentationMode that lets the presented child dismiss itself no matter how deep in the hierarchy it is — this one is managed by powerful environment values system.
Unfortunately, we can not instantiate our own instance of PresentationMode due to a hidden initializer, but we definetely should mimic what Apple did.
It's generally a great approach to stay on par with a built-in API, since you're automatically let other developers to lift up all their previous knowledge and experience about how similar API works. Needless to say, how such an approach improves local reasoning.
The goal is to allow the following code within our menu or any descendant siblings down the hierarchy:
struct SiblingView: View {
@Environment(\.drawerPresentationMode) var presentationMode
var body: some View {
Button("Dismiss") {
presentationMode.wrappedValue.close()
}
}
}
Our presentation mode will look similar, yet a bit different to fit the following need: being able to understand/control Drawer's isOpened
flag. Let's make a draft of an API we need:
struct DrawerPresentationMode {
var isOpened: Bool
mutating func open()
mutating func close()
}
This looks pretty similar to what stardard PresentationMode offers, but how do we sync it with out source of truth isOpened
that is managed by Drawer
itself? There is a powerful capability within Binding itself: an ability to map from one type to another with little to no effort.
To implement it, let's figure out that DrawerPresentationMode
is build around a plain Binding<Bool>
but offers a bit fancier API:
public struct DrawerPresentationMode {
@Binding private var _isOpened: Bool
init(isOpened: Binding<Bool>) {
__isOpened = isOpened
}
public var isOpened: Bool {
_isOpened
}
public mutating func open() {
if !_isOpened {
_isOpened = true
}
}
public mutating func close() {
if _isOpened {
_isOpened = false
}
}
}
Now, once implementation is done, let's add mapping from Binding<Bool>
to our DrawerPresentationMode
:
extension Binding where Value == Bool {
func mappedToDrawerPresentationMode() -> Binding<DrawerPresentationMode> {
Binding<DrawerPresentationMode>(
get: {
DrawerPresentationMode(isOpened: self)
},
set: { newValue in
self.wrappedValue = newValue.isOpened
}
)
}
}
After getting familiar with how PresentationMode can be implemented, you can leverage nearly the same API as Apple did — this is exactly what we're striving for — to reuse all of the adopted knowledge for the Apple's toolchain.
Passing this binding to Drawer's siblings is straightforward by using EnvironmentValues:
extension DrawerPresentationMode {
static var placeholder: DrawerPresentationMode {
DrawerPresentationMode(isOpened: .constant(false))
}
}
private struct DrawerPresentationModeKey: EnvironmentKey {
static var defaultValue: Binding<DrawerPresentationMode> = .constant(.placeholder)
}
extension EnvironmentValues {
public var drawerPresentationMode: Binding<DrawerPresentationMode> {
get { self[DrawerPresentationModeKey.self] }
set { self[DrawerPresentationModeKey.self] = newValue }
}
}
What's left is to pass this data down the road in the Drawer's body:
public var body: some View {
ZStack(alignment: .leading) {
content
...
}
}
.animation(.spring(), value: isOpened)
.environment(\.drawerPresentationMode, $isOpened.mappedToDrawerPresentationMode())
}
Now we have everything that we need to finally start implementing a menu.
An attentive person could notice that there is no reason to make a wrapper around
Binding<Bool>
and that's totally correct. For our purpose, it is sufficient to pass binding directly. However, it is still worth to understand how things can be done under the hood.
You can download the final version of this section from our repo as well.
Now it's time to start implementing the menu. Let's recap what needs to be done and split it down to a set of features:
So far here is what we will implement step-by-step:
MenuItem
's with header with support of expanded and compact state in place.As a starting point you can use a following commit or continue from where we left off in the previous section.
Before diving into the UI, it's worth to clarify how we can manage different states. Let's declare a MenuAppearance
with a following content:
enum MenuAppearance: String {
case `default`, compact
mutating func toggle() {
switch self {
case .default:
self = .compact
case .compact:
self = .default
}
}
}
private struct MenuAppearanceEnvironmentKey: EnvironmentKey {
static var defaultValue: MenuAppearance = .default
}
extension EnvironmentValues {
var menuAppearance: MenuAppearance {
get {
self[MenuAppearanceEnvironmentKey.self]
}
set {
self[MenuAppearanceEnvironmentKey.self] = newValue
}
}
}
extension View {
func menuAppearance(_ appearance: MenuAppearance) -> some View {
environment(\.menuAppearance, appearance)
}
}
Since there are only two states we should take care of, enum
matches our needs perfectly. Utility code after enum declaration allows us to bypass current menu state down the hierarchy just as we did before with Drawer.isOpened
flag.
What's cool here is that due to SwiftUI layout model, the child can rely on a given menu appearance to adjust it's layout. This has a dramatic impact on code simplification: basically, there is no presentation detent involed to manage different layout sizes — SwiftUI does proper resizing for us.
Now lets move on to basic building blocks. As stated above, we strive to keep our code as close as possible to the SwiftUI built-in API not only in terms of naming and semantics, but also to reuse existing components and conceptions.
To get a better undertanding of what I'm talking about, take a look here and try to find out common views:
It is obvious from a design, that we need to maintain some sort of a HStack with a constant spacing and a similar behaviour when the menu collapses to show only an icon.
In UIKit world, it would be not a disaster, yet a task to think about. But it is not, when it comes to SwiftUI. At a glance, these are two separate types of views: user header and a cell that can be selected. However, in SwiftUI both can be described by a single view called Label and it's accompanied LabelStyle that describes the way Icon an Title are going to be presented.
This is a very powerful idea, since in most of iOS apps there are plenty of abstractions that can be perfectly described as a pair of Title and an Icon. SwiftUI lifts this idea to the next level by letting us provide any appearance modifier through style.
And since both Icon and Title are generic types, there is nothing surpising that VStack from user header and a menu item title from the cell are both represented as a Title. Thus, having this in mind we can scale this cocept even further in our applications by simplifying and reusing layour here and there. What a time to be alive! :)
Enough talk, let's implement our MenuLabelStyle
:
struct MenuLabelStyle: LabelStyle {
@Environment(\.menuAppearance) var appearance
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 24) {
configuration.icon
.frame(width: 48, height: 48, alignment: .center)
if appearance == .default {
configuration.title
.typographyStyle(.title)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
}
}
extension LabelStyle where Self == MenuLabelStyle {
static var menuLabel: Self {
MenuLabelStyle()
}
}
typographyStyle
is just a handy wrapper to acomplish a single styling system. It can be found in our example code.
As you can see, all we have here is a dead-simple layout that reacts to a menu appearance by hiding Title of the given configuration (see LabelStyleConfiguration for more info).
The only noticable modifier here is transition of a title, that moves and fades our label in/out. This is a part of out animation. You can play around to see final effects.
Once we've done with label layout, how about finishing with cells? These are not cells here — that's rather a VStack with buttons in it for each MenuItem that we have. And just like with LabelStyle
, SwiftUI provides us ability to modify appearance of a Button by a custom ButtonStyle. This is a common approach where the composition shows all its advantages over the inheritance. We can literally extend and compose any style in any combination which is mind-blowing.
You may find here and there the implementations of a custom button with Gesture Recognizers, that handles highlighting and selection, however it's clearly not an intended approach and a workaround in a nutshell.
ButtonStyle
on the other hand, passes you not only the view you have to display, but also states whether the view is pressed or not. Button at the same time has the ability to manage highlighting behaviour properly by providing a iOS-wide behaviour — try to press and move your finger around any system button to see what I mean.
Long story short: ButtonStyle
one more time tells us how SwiftUI wants us to extend and style views.
Let me show you the implementation of a MenuButtonStyle
:
struct MenuItemButtonStyle: ButtonStyle {
var isSelected: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration
.label
.labelStyle(.menuLabel)
.foregroundColor(configuration.isPressed || isSelected ? Color("color/selectionForeground") : Color("color/text"))
.animation(.spring(), value: isSelected != configuration.isPressed)
.frame(maxWidth: .infinity, minHeight: .contentHeight, alignment: .leading)
.contentShape(Rectangle())
}
}
Take a closer look at what we have here. Doesn't look that complex, yet few lines require some explanation:
- animation
for isSelected != configuration.isPressed
gives system look and feel to your customer button. Thus, whenever user makes a touch down event, in case there any visual feedback, you get a nice animation. Remember, visual feedback is imporant, since user has no clue about what's going on.
- frame
— this one is a tricky modifier. It lets the button be expandable to any width, otherwise it'll shrink to hug it's content perfectly. Later on, we're using it within VStack
to get a fully width-sized buttons, even though their actual content size may be much smaller. Play around with this modifier to see what's gonna happen.
Managing frames is a wide topic and we highly recommend a Pro SwiftUI to get a hands-on knowledge about SwiftUI layout system.
- contentShape
— Returns a view that uses the given shape for hit testing — Apple Documentation clearly says what's that. However, to clarify what exactly it does here we need to get back to the frame modifier above. Remember, Button will shrink itself to hug it's content perfectly. Thus, it has zero reasons to handle touches elsewhere, but within its icon and title. But our button should be interactive within full width of the VStack and by applying this modifier we explicitly force this behaviour.
- isSelected
— last but not least. ButtonStyle
and Button
are not handling selected state and let you decide how to store this flag and how to present it to the user. Our list should keep selected MenuItem
so we're passing this flag explicitly to our custom style.
struct MenuItemList: View {
...
// MARK: - Body
var body: some View {
VStack(alignment: .leading) {
UserHeader(user: user)
.onTapGesture {
onUserSelection?()
}
Spacer()
.frame(height: .headerBottomSpacing)
itemsList
}
.animation(.spring(), value: selection)
}
@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))
}
}
}
}
Some of the properties and utility code is skipped. It is recommended to browse GitHub repo to get a full code.
Now it's time to update our ContentView
and to build and run our code to see what we've got:
struct ContentView: View {
@State var selection: MenuItem? = .dashboard
@State var appearance: MenuAppearance = .default
var body: some View {
MenuItemList(selection: $selection) {
withAnimation(.spring()) {
appearance.toggle()
}
}
.fixedSize(horizontal: true, vertical: false)
.menuAppearance(appearance)
}
}
Here is our results after making final changes to our ContentView:
And with just a few additions to assemble things together:
While final version of the code above with background, etc, can be found here, it's time to move to the fun Part 2 — selection and custom transitions.
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