As a startup that relies heavily on tech for all its functions, Cult.fit always prioritizes user experience. When you are a tech-enabled organization, the digital interaction of the user with your interface determines their experience. A few glitches and delays are enough reasons for your customer to leave. Hence, we decided to drop our years-old front-end partner React Native and decided to move towards Flutter.
We have been developing with React Native since our inception. It has helped us set up our Mobile App but we had to let go of our tech stack. We started thinking about improving the user journey with better tech and design support. While we were doing this evaluation, our design team had already started building a new design system which we now call ‘Aurora’ — a complete revamp of the design language.
Read more about how we designed ‘Aurora’ here.
Limitations with React Native
Since React Native relied heavily on the JS bridge to communicate with the native environment, there was a lot of data being exchanged between these two environments. The bridge became a bottleneck and also led to unresponsive UI and general slowness in the app.
A simple example of this is the bottom bar which was built in React Native. We did a test on a low-end Samsung Device to check if the app is responsive. There was a noticeable delay in the response time between switching tabs.
Each bottom tab view consists of an RN Flatlist, which renders a list of rich components such as an image/video carousel and this makes the view hierarchy quite complex. Whenever we switch to a different bottom tab, a reload + rerender instruction is sent via the JS bridge to the native layer to update the UI. We observed a drop in the frame rate (from 60FPS to around 20FPS) on low-end phones on switching bottom tabs.
To the user, this would seem something like this where it takes around 2 seconds to switch a bottom tab: https://youtube.com/shorts/GBt8yidp0gc?feature=share
Our new design system was dependent on the ability of our tech stack to build delightful motion animations without compromising performance. We observed a significant drop in frame rate while building these interactions on React Native, especially on android devices.
There were certain technical difficulties related to animations in React Native. Most of the new screens required:
- Background animation of moving radial gradients at all times,
- Motion blur in the UI elements
- Rich image/video components with seamless scrolling
- Contextual Transitions
The other aspect was the actual effort required to build complex animations. Take an example of a parallax scroll animation, to achieve this in React Native for a FlatList, you need to add an onScrollListener() and translate the image position of each card present in the Flat List. This becomes a bottleneck when we send events from JS to the native layer to achieve this parallax effect on every row item. It eventually led to a slow/janky scroll experience, especially on Android.
This made us rethink and evaluate other options such as a native implementation or hybrid frameworks such as Flutter. It was quite interesting to see the architecture upon which Flutter was built. It leveraged a graphics library called ‘Skia’ which relied on OpenGL/Metal to render the UI components on the app. It uses a UI thread to build the widgets and also runs a raster thread in parallel to pass instructions such as apply blur, opacity, and so on.
In order to put Flutter to the test, we did a small POC by building a couple of screens and applied different types of animations and blurs to measure the performance.
Take a look at this scroll and animation experience we built as a standalone page for cult transform:
This screen consists of multiple elements:
- A lottie which is rendered using the Flutter CustomPaint
- A continuously moving animation of radial gradients which also uses CustomPaint
- Motion blurred items in the ListView built using BackdropFilter
- Custom Animation sequences for the habit cards made using multiple AnimationController instances
We measured the frame rate with all of these elements running and were able to achieve 55-60 fps on an android 60hz display. These results were quite promising.
However, there were 2 significant challenges in front of us:
- We did not want to stall all development and move the complete app to flutter. So, we had to figure out a way for both to coexist while we move different parts to flutter.
- How should we introduce Flutter in our existing app codebase which is built using React Native?
- How do we ensure a smooth transition from RN to Flutter across multiple teams?
Building a hybrid cross-platform app
Adding Flutter to our existing app was the easy part, the actual challenge was enabling the communication between RN and Flutter which was a prerequisite for sharing common flows such as user sessions, navigation, etc.
To solve this communication, we created a simple flow:
Since both these frameworks support interaction with the native layer, we created our communication protocols with native being the centerpiece. This solved the problem of sharing context and navigating between React Native and Flutter screens.
- To communicate from RN to the native layer, we relied on RN Native Modules,
- To communicate from the native to the RN layer, we used RCTEventEmitter
- Flutter allows back and forth communication from native to dart via PlatformChannels
This allowed us to build an end-to-end communication pipeline from RN to Flutter and vice versa.
As our app was built in RN it uses RCTRootView (iOS) and ReactActivity (android) its root view, which meant that presenting a Flutter screen in the app would require native changes.
Adding Flutter to an existing app provided a way to present a FlutterViewController/FlutterActivity whenever we wanted to switch from an RN screen to a Flutter screen, while information exchange will happen through the platform channel as mentioned above.
Phase 1: Integration of Flutter as the new screen
We started off by integrating Flutter as a new screen in our existing RN app. Once the control passed onto the Flutter view, it was responsible for handling navigation for all Flutter screens. The only caveat is that if you had to navigate from Flutter to an older RN screen, it had to close and pass a message back to the RN environment to open the old screen. We also had to maintain a user’s session across these different environments.
For iOS, the RCTRootView was added as the top view in the UINavigationController, and the FlutterViewController was presented as a modal view controller to show a flutter screen on the app.
While for Android, the FlutterActivity was being started as an Intent from the ReactActivity. This approach meant that both RN and Flutter were running in parallel. The FlutterEngine was loaded lazily whenever any Flutter screen was to be presented.
Phase 2: Creating multiple Flutter views inside a native view
We decided to take a very bold approach for our next phase. On the rendering side, we took the approach of building a hybrid app even further. Interestingly, Flutter gives you the ability to render multiple flutter views inside a native view, we thought why not try something like this:
FlutterEngineGroup allows you to create multiple Flutter Views and embed them in a native iOS/Android view. So, we created 3 independent flutter containers and rendered them in a React native view using the RN Native UI component. This piece was quite complex because it required a deep understanding of how to manage both RN and Flutter inside the same UI hierarchy.
This was probably the most critical and complex piece where we created multiple Flutter views to be rendered inside a React Native View. We went ahead with this approach as it was not possible for all teams to completely switch to Flutter overnight. So, we had to figure out a way to move them gradually. You can think of these as independent Flutter Views rendered inside a React Native bottom tabs view. All of these flutter views were created from a Flutter Engine Group which allowed them to share resources and memory. This was challenging because we had to handle certain cases such as communication between multiple flutter engines, resuming, and pausing an engine on switching tabs.
self.engineGroup = [[FlutterEngineGroup alloc] initWithName:@"multiple_engines" project:nil];
self.engine = [self.engineGroup makeEngineWithEntrypoint:nil libraryURI:nil];
_viewController = [[FlutterViewController alloc] initWithEngine:self.engine nibName:nil bundle:nil];
this.engineGroup = new FlutterEngineGroup(_context);fullScreenEngine = this.engineGroup.createAndRunEngine(this.getContext(), new DartExecutor.DartEntrypoint(FlutterInjector.instance().flutterLoader().findAppBundlePath(), null));
Phase 3: Moving bottom navigation to Flutter
Once we migrated all of our bottom navigation views to Flutter, we moved the entire bottom navigation to Flutter, which made life easier for us. We also a visible performance improvement in app load time, tab switching, and navigation. In this approach, Flutter is handling the navigation for all of its screens, and in case you want to navigate from a Flutter to an RN screen, it is managed via react native.
There is also another scenario such as:
Flutter → React Native → Flutter
For this scenario, we had to keep another flutter engine and view ready to be presented. Having a second Flutter Engine/View allowed us to maintain the navigation stack without worrying about the top Flutter View.
Phase 4: Running the app!
Our final goal in Phase 4 is to run the entire app in a Flutter shell, as having both RN and Flutter running together adds a performance overhead on the app launch time and memory usage and also leads to a higher app size.
As we did not have an option to build the entire app from scratch in Flutter, our best bet was to introduce Flutter in our existing RN app. If we hadn’t moved ahead with this approach, we would have preferred to build a fresh flutter app, mainly due to the fact it's simpler and more predictable.
We did face some hurdles while rendering both Flutter and RN in the same view hierarchy. Considering this type of use case is not supported officially, you are on your own when you are trying to solve some random bugs/crashes which appear while developing. If you do opt in for a hybrid approach, you should have an end goal in mind, which is to eventually move to a pure flutter app.
Hope this helps you look at your product from a new lens. We are sharing this story to inspire more businesses to constantly work on improving their user experience and provide a better onboarding experience.