Moving to SwiftUI: using TabView instead of UITabBarController.
Converting UIKit project to SwiftUI (or SUI) in 2022 is no longer a matter of time. It rather depends on availability of appropriate skills. I’ve been working for Utkonos — one of the leaders of e-commerce in the Russian market. We started to develop the application using SUI at the end of 2020 when we picked iOS 13 as the lowest supported version (yes, we’ve decided not to wait for the iOS 14). This was also triggered by the task of a complete redesign of the application. At the moment, we have implemented two main screens out of five on SUI.
One of the main tasks facing iOS-developers is to implement navigation flow. It’s now rare to find a single-page application. The tab bars are used in iOS to support user interfaces where multiple screens can be accessed in no particular order. If the application is developed using SUI with no legacy code at all, then the typical development pattern is still the following: the screens are made up with SUI but the tab bar with UIKit. With the growth of the SUI-code in Utkonos, we began to gradually abandon navigation on UIKit, a big step in this direction was to convert the tab bar to the TabView instead of the UITabBarController.
Hello everyone! My name is Alexander Kraev! In this article I want to share with you my own experience of adding TabView with all the pitfalls: when you have screens developed both using SUI and UIKit. This article is not for those who have just started to learn SUI. If you are just like that, then I advise you to begin with some small features. You can find more interesting posts on my telegram channel dedicated to iOS development on SwiftUI.
Preparing the structure
In our team we work using Trunk Based Development (TDD). If you aren’t familiar with this version control management practice then I would advise you to watch this session. In one word, the development goes through Feature flags and toggles.
Let’s get start with adding a toggle for a new Tab Bar on SUI:
In the part of the code where the root view controller is created for the main window we should write:
setupTabBarController()
is the function of creating the tab bar using UIKit, and RootTabView()
is a view of the tab bar on SUI integrated through UIHostingViewController
.
The flow of UIKit’s tab bar is quite familiar: navigation controller is created with the root view controller for each screen:
First view controllers (used as navigation controllers) are created. Then, the tab bar controller is initialized:
As you can see above NavigationController
is a class inherited from UINavigationController
but with custom behavior of the navigation bar, its appearance of the back button and as simple as that.
Let’s go back to the SUI Tab Bar. Obviously, RootTabView()
will consist of child screen views. It’s time to start writing UIViewControllerRepresentable
wrappers for view controllers on UIKit. I’ll give you an example of one for the user profile screen:
As I said earlier, we have two screens fully developed using SUI. In order not to break the routing between these screens and other legacy screens developed on UIKit, we decided to wrap them also using UIViewControllerRepresentable
in NavigationController
:
You don’t have to think about the design of the tab bar yet, we will come back to this later. First it’s necessary to get the efficiency of the current structures. Let’s implement RootTabView
in the simplest possible way. Let’s declare an enum with screens:
Next, let’s designRootTabView {...}
using icons from SF Symbols:
Let’s launch the project for test to see how switching between tabs works:
At the same time we can notice that the routing on SUI’s screens has broken: any child screen opens as a modal, a white stripe appears in the safe area. Let’s fix these problems one by one.
Briefly, the routing in our legacy code consists of a navigation screens enum and a factory where this enum is handled:
If the view controller, from which we are routing, is nil then by default we take the top view controller (you can easily google its code). This is the reason for any view controller’s modal opening. Top view controller in our scheme is no longer a UINavigationController
or UITabBarController
but a hosting view controller:
Previously the navigation controller could be reached this way:
So now we should pass an instance of the navigation controller into SUI screens to use it with routing. One of the possible ways to do it is to create an EnvironmentKey
:
Then declare the Environment
in screen’s view:
We should inject instance of navigation controller during the creating of the hosting view controller, so the CartSUIView
will be transformed to:
So before injecting .environment(\.navigationController, cartNav)
we should initialise an instance of the navigation controller cartNav
using a “proxying” UIHostingController
with an EmptyView()
. After that we can add the main screen as child: cartNav.addChild(suiCartVC)
. We shouldn’t forget to hide the empty view’s navigation bar: cartNav.setNavigationBarHidden(true, animated: false)
.
Moreover it is necessary to hide the back button (because of the empty view) on the screen:
It is quite simple to fix such a problem by applying the following modifier:
Next step is injecting the dependency into all child views:
Note that there will be no error if we route to the same view from different screens with its own instances of the navigation controller (for example in our app we can route to a goods card screen from the main screen or from the cart screen). This is because of SwiftUI dependency graph Environment value taken from the parent view.
Example of routing in view:
Now let’s go back to the white stripe in the safe area. This can be fixed very easily. Let’s define the following modifier:
and apply it to the content of tab items:
We run the app, we can see that our problems are gone:
Customizing TabView
Let’s go back to the TabBar’s layout. The TabItem(_..)
modifier which is available out of the box has very limited features. Our Tabbar is different from the basic UI of iOS, so we need to custom it. Fortunately, SUI makes it very easy to do:
Let’s take a look at different states of the TabBar buttons at the example of Cart button:
The design is quite simple so I’ll just provide an example of the code:
In the code above boldFont
is a custom modifier for the font. Note that colors for the foregroundColor
, background
modifiers are not included in the let
properties of view because there will be no other such tab buttons with different colors in the app, otherwise I would recommend to add colors in the properties. We organize our project in modular way. UI elements such as the tab bar buttons were placed in a separate package called UtkonosUI.
I advise you to organize your code this way.
Let’s see on RootTabView
:
In the code above onCartChanged
modifier returns a View
with modified cart button title. Its implementation is quite simple. It is built around onReceive
of the necessary event in the NotificationCenter
.
Launching the app:
Bingo! Buttons are drawn correctly, changing the badge and title also works. But the Tab Bar buttons move up when the keyboard appears. This bug is fixed with the modifier: ignoresSafeArea(.keyboard)
:
Applying the animation
It is quite rare when designers limit themselves only to drawing buttons in Figma forgetting about animation. Moreover successful animation makes the application comfortable for using but doesn’t distract the user. Maybe the main goal of animation is to help users to get some response from the app content. In our case animation feedbacks the user that their cart has changed after goods were added.
It looks really stylish! Let’s implement it.
We need an array of coordinates with the offsets for the icon and the current offset index in this array:
In the onCartChanged
modifier described above we will change the currentOffsetIndex
in a loop over the entire array of offsets:
Task.delayed(byTimeInterval: ...)
is actually the same as asyncAfter(deadline:execute)
in the New Concurrency Model.
We wrap into await MainActor.run {...}
because property State
properties isolated to global actor ‘MainActor’ can not be mutated from a non-isolated context.
Now let ‘s get down to the most interesting part — the modifier offset
combined with spring
animation.
Where should we apply it? Obviously, the icon with the badge should be offset:
But what if the designer suggests to add another animation that is no longer related to the offset. Let’s pass the modifier for animation in the TabBarItem
as parameter, we should define TabBarItem
as a generic:
But I need a way to skip the parameter animation
when creating TabBarItem
.
Let’s create an extension:
Now we need a modifier for animation from the outside to pass it as a parameter to TabBarItem
. There was an AnimatableModifier protocol for such purposes earlier, but Apple deprecate it. Instead of this we should use Animatable:
Note that animatableData
is the data for animation, in our case this is the offset point as CGPoint
.
An important thing that Apple deprecates the animation(_:)
modifier, which definitely creates a lot of bugs with animation. Instead of deprecated one Apple recommends to use animation(_:value:). The main point of the last one is to play the animation when the specific value changes. That’s why our OffsetAnimation
is a generic ViewModifier
because of this value as a parameter.
So code of RootTabView
with an animated button is below:
Let’s run the app! Don’t you think it’s really cool?
Final refactoring
Let’s now move the State
proprerty selectedTab
property out of the RootTabView
:
In our project we use the MVVM-S architecture. Special service is responsible for routing, we will transfer selectedTab
into it:
Final implementation of RootTabView
is below:
That’s all for now! Thank you for reading till the end!
Don’t forget to subscribe me on github and to my Telegram channel dedicated to mobile development.