Animating SFSymbol Icons in the native iOS tab bar
Not too long ago, I released an open-source library, react-native-nitro-symbols that allows one to use Apple's SFSymbol Icons in their React Native apps! This library also supports animating the icons with effects a such as wiggle and bounce and when used as icons with JS-based tabs (more on that later) in libraries/frameworks such as One, Expo Router and React Navigation, it would animate when a user tapped a tab bar Icon.
As time went on, a user asked me whether they could use the animated icons in a native tab bar, and when I tried to do so, I found out something interesting. Now remember when I said "JS-based tabs"? For a while, the tab bar in React Native was mostly implemented in JS while React Navigation handled the navigation under the hood so users could freely customize it as they wished, but as React Native approaches version 1.0 (come on Meta🙏🏾), the general trend has been towards making RN apps feel as "native" as possible. That said, libraries such as react-native-bottom-tabs and Expo Router allow you leverage the native tab bar for each respective platform. (react-native-bottom-tabs goes as far as to support VisionOS!).
Now whenever we use the default native tab bar provided by iOS/Android we lose the freedom to customize it however we like, but then we also get the native feel many power (and even normal) users prefer. However, this also means users can't pass a SymbolView component from my nitro-symbols library as their tab bar icon. At the time, no library providing native tab bars supported custom React Native views as icons on iOS. To get the SFSymbol as their icons, fortunately, Expo Router and react-native-bottom-tabs both support the option of using Apple's native SF Symbol as tab bar icons, and react-native-bottom-tabs also supports SVGs, but those SFSymbols were static and couldn't be animated because by default Apple strips away the animation when you set the symbol as the icon. Funnily enough, even in SwiftUI, this is not possible by default and would involve hacking around with UIKit to make it work (weird right?).
Well, I hacked around, and today we'll be looking into how I achieved that using a custom fork of react-native-bottom-tabs and One, a new React (Native) framework by Nate, the creator of Tamagui.
react-native-bottom-tabs was built using React Native's New Architecture with Fabric and Codegen. We also have other native module systems such as Nitro and Expo modules. In short, these systems allow you write native Swift, Kotlin and C++ code in your React Native apps.
In essence, I achieved this feat by traversing the UITabButton views in the UITabBar in search of a UIImageView (the type the SFSymbol Icon is held in) and when I found it, I applied the animation effect such as wiggle. For brevity in this already long blog post, I will leave out some details which are common knowledge and can be also understood by looking at the source code with your favorite AI agent, so don't fret.
In general, this is the flow:
- A user defines route with tabIconEffect:
<NativeTabs>
<NativeTabs.Screen
name="notifications"
options={{
title: "Notifications",
tabBarIcon: () =>
Platform.select({
ios: { sfSymbol: "bell.badge.fill" } as any,
}),
tabIconEffect: "wiggle",
}}
/>
<NativeTabs.Screen
name="favorites"
options={{
title: "Favorites",
tabBarIcon: () =>
Platform.select({
ios: { sfSymbol: "heart.fill" } as any,
}),
tabIconEffect: "bounce",
}}
/>
</NativeTabs>
The above was done using createNativeBottomTabNavigator imported from @bottom-tabs/react-navigation and also withLayoutContext from One.
See (tabs)/_layout.native.tsx in the example app (which I'll link at the end) for more info on how to set it up.
NOTE: I have previously defined the tabIconEffect prop with its types and have also added it to the various type files. (see the source code for more info)
- In TabView.tsx (line 298-341) we build the items array (this will be important later to find the effect for each tab bar button):
const items: TabViewItems = trimmedRoutes.map(
(route) => ({
tabIconEffect: getTabIconEffect({ route }), // line 324
})
)
- TabViewNativeComponent.ts defines the Codegen schema (see line 37)
- RCTTabViewComponentView.mm receives props from JS and carries out some conversions.
- Skipping some prop passing logic, TabItemEventModifier.swift receives items & handles tap:
if let effect = items[safe: index]?.tabIconEffect {
animateTabIcon(tabBar:..., effect: effect)
}
The core animation logic lives in TabItemEventModifier.swift where I defined a bunch of important functions:
@available(iOS 17.0, *)
private func applySymbolEffect(to imageView: UIImageView, effect: String?) {
switch effect {
case "bounce":
imageView.addSymbolEffect(.bounce)
case "scale":
imageView.addSymbolEffect(.scale)
case "wiggle":
if #available(iOS 18.0, *) {
imageView.addSymbolEffect(.wiggle)
} else {
imageView.addSymbolEffect(.bounce)
}
default:
imageView.addSymbolEffect(.bounce)
}
}
}
This function defined above is responsible for applying the specific animation effect a user defines by using a switch statement on strings passed from JS-land to the native side of things. (The JS strings are converted to native Objective-C strings using React Native's Codegen.)
private func findTabButtons(in view: UIView, results: inout [UIView]) {
let typeName = String(describing: type(of: view))
if typeName.contains("UITabButton") {
results.append(view)
return
}
for subview in view.subviews {
findTabButtons(in: subview, results: &results)
}
}
This function is responsible for recursively going through the views in the tab bar to find the UITabButton which contains the UIImageView we want to animate.
private func findImageView(in view: UIView) -> UIImageView? {
for subview in view.subviews {
if let imageView = subview as? UIImageView {
return imageView
}
if let found = findImageView(in: subview) {
return found
}
}
return nil
}
The above function is responsible for recursively finding the UIImageView which is our SFSymbol we plan to animate.
And then finally, this function defined below combines the above functions to do the animating:
private func animateTabIcon(tabBar: UITabBar, at index: Int, effect: String?) {
var tabButtons: [UIView] = []
findTabButtons(in: tabBar, results: &tabButtons)
let sortedButtons = tabButtons.sorted { $0.frame.minX < $1.frame.minX }
// Cluster buttons that are close together (within 20pt = same tab)
var clusters: [[UIView]] = []
for button in sortedButtons {
if let lastCluster = clusters.last,
let lastButton = lastCluster.last,
abs(button.frame.minX - lastButton.frame.minX) < 20 {
clusters[clusters.count - 1].append(button)
} else {
clusters.append([button])
}
}
let uniqueButtons = clusters.compactMap { $0.first }
guard let button = uniqueButtons[safe: index],
let imageView = findImageView(in: button) else {
return
}
if #available(iOS 17.0, *) {
applySymbolEffect(to: imageView, effect: effect)
}
}
The clustering logic above handles edge cases where UIKit's private view hierarchy might contain multiple UITabButton views at similar positions for the same tab.
A lot of less significant details have been left out of the blog post for the sake of brevity: most of them are just adding/passing props to places. Looking through the source code with a good AI agent will help you identify them quickly. Nonetheless, if you have any questions feel free to reach out to me on X at 1804davey.
To try this out, see the example app built using One.js (linked below).
Thanks
- Huge thanks to Oskar and the Callstack team for the incredible architecture of this library that made it so easy to add and test this feature.
- Huge thanks to Kavsoft for his YT video that gave me this insight which Claude couldn't even come up with until it was told about this approach.
Links
- react-native-bottom-tabs
- Expo Router Native Tabs
- My fork of react-native-bottom-tabs that supports this see the
add-animated-symbolsbranch - Example One app
That's all for today, thanks for reading. Till next time, Happy coding. 🤞🏾