Skip to content

Commit 172a7bb

Browse files
authored
fix(Android, Tabs): Fix tabs icons loading in release mode (#3413)
## Description This PR improves the approach to loading image assets used as tab icons, addressing issues with inconsistent or unreliable URI resolution - particularly around local resources and assets in Android builds (debug vs. release). Previously, the image loading logic assumed that assets would always be bundled with a `_` prefix in release builds. However, this is not a reliable assumption and can break under certain project setups or custom bundling configurations. Assets are now resolved directly using the appropriate `android.resource` or `android_asset` URI schemes. Additionally, now I'm resolving both drawable and raw into the `res:/<id>` URI format before being converted to the proper `android.resource://` URI expected by Fresco. This ensures consistent behavior regardless of resource type. This change ensures that both local resources and bundled assets are correctly rendered as tab icons across different build variants, without relying on unstable heuristics. Fixes #3399 ## Changes - Updated the approach in TabsImageLoader ## Screenshots / GIFs Here you can add screenshots / GIFs documenting your change. You can add before / after section if you're changing some behavior. ### Before Debug: <img width="325" height="747" alt="debug-before" src="https://github.com/user-attachments/assets/22af3dde-e5c2-4596-82d8-71bff06d03e5" /> Release: <img width="325" height="746" alt="release-before" src="https://github.com/user-attachments/assets/9dfae14a-7858-45f2-84ca-75a81bb674ed" /> ### After Debug: <img width="322" height="748" alt="debug-after" src="https://github.com/user-attachments/assets/3c2b2da6-4927-47ff-b779-2e173dbb17c7" /> Release: <img width="328" height="745" alt="release-after" src="https://github.com/user-attachments/assets/e5d00ff9-49ff-440e-8c0f-6eea21643254" /> ## Test code and steps to reproduce It's working fine it both debug and release in our examples, before and after applying these changes. Therefore, I was testing it on a separate application with this code snippet: ```js import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { createNativeBottomTabNavigator } from '@react-navigation/bottom-tabs/unstable'; import { NavigationContainer } from '@react-navigation/native'; const Tab = createNativeBottomTabNavigator(); function App() { return ( <NavigationContainer> <Tab.Navigator> <Tab.Screen name="Home" component={HomeScreen} options={{ tabBarIcon: { type: "drawableResource", name: "custom_home_icon" }, }} /> <Tab.Screen name="Profile" component={ProfileScreen} options={{ tabBarIcon: { type: 'image', source: require('./assets/icon_fill.png'), }, }} /> <Tab.Screen name="Settings" component={SettingsScreen} options={{ tabBarIcon: { type: 'image', source: require('./assets/icon.png'), }, }} /> </Tab.Navigator> </NavigationContainer> ); } const HomeScreen = () => ( <View style={styles.container}> <Text>Home</Text> </View> ); const ProfileScreen = () => ( <View style={styles.container}> <Text>Profile</Text> </View> ); const SettingsScreen = () => ( <View style={styles.container}> <Text>Settings</Text> </View> ); const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center' }, }); export default App; ``` I'm adding `custom_home_icon` via android resource manager and other icons via `assets/` directory ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes
1 parent cf5dc93 commit 172a7bb

File tree

1 file changed

+82
-41
lines changed

1 file changed

+82
-41
lines changed
Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package com.swmansion.rnscreens.gamma.tabs.image
22

3+
import android.annotation.SuppressLint
34
import android.content.Context
45
import android.graphics.drawable.Drawable
6+
import android.net.Uri
57
import android.os.Handler
68
import android.os.Looper
79
import android.util.Log
810
import androidx.core.graphics.drawable.toDrawable
9-
import androidx.core.net.toUri
1011
import com.facebook.common.executors.CallerThreadExecutor
1112
import com.facebook.common.references.CloseableReference
1213
import com.facebook.datasource.BaseDataSubscriber
@@ -16,6 +17,7 @@ import com.facebook.imagepipeline.image.CloseableImage
1617
import com.facebook.imagepipeline.image.CloseableStaticBitmap
1718
import com.facebook.imagepipeline.request.ImageRequestBuilder
1819
import com.swmansion.rnscreens.gamma.tabs.TabScreen
20+
import java.util.Locale
1921

2022
internal fun loadTabImage(
2123
context: Context,
@@ -25,7 +27,8 @@ internal fun loadTabImage(
2527
// Since image loading might happen on a background thread
2628
// ref. https://frescolib.org/docs/intro-image-pipeline.html
2729
// We should schedule rendering the result on the UI thread
28-
loadTabImageInternal(context, uri) { drawable ->
30+
val resolvedUri = ImageSource(context, uri).getUri(context) ?: return
31+
loadTabImageInternal(context, resolvedUri) { drawable ->
2932
Handler(Looper.getMainLooper()).post {
3033
view.icon = drawable
3134
}
@@ -34,23 +37,12 @@ internal fun loadTabImage(
3437

3538
private fun loadTabImageInternal(
3639
context: Context,
37-
uri: String,
40+
uri: Uri,
3841
onLoaded: (Drawable) -> Unit,
3942
) {
40-
val source = resolveTabImageSource(context, uri) ?: return
41-
val finalUri =
42-
when (source) {
43-
is RNSImageSource.DrawableRes -> {
44-
"res://${context.packageName}/${source.resId}".toUri()
45-
}
46-
is RNSImageSource.UriString -> {
47-
source.uri.toUri()
48-
}
49-
}
50-
5143
val imageRequest =
5244
ImageRequestBuilder
53-
.newBuilderWithSource(finalUri)
45+
.newBuilderWithSource(uri)
5446
.build()
5547

5648
val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context)
@@ -78,37 +70,86 @@ private fun loadTabImageInternal(
7870
)
7971
}
8072

81-
private fun resolveTabImageSource(
82-
context: Context,
83-
uri: String,
84-
): RNSImageSource? {
85-
// In release builds, assets are coming with bundle and we need to work with resource id.
86-
// In debug, metro is responsible for handling assets via http.
87-
// At the moment, we're supporting images (drawable) and SVG icons (raw).
88-
// For any other type, we may consider adding a support in the future if needed.
89-
if (uri.startsWith("_")) {
90-
val drawableResId = context.resources.getIdentifier(uri, "drawable", context.packageName)
91-
if (drawableResId != 0) {
92-
return RNSImageSource.DrawableRes(drawableResId)
73+
// Adapted from https://github.com/callstackincubator/react-native-bottom-tabs/blob/main/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/ImageSource.kt
74+
private class ImageSource(
75+
private val context: Context,
76+
private val uriString: String?,
77+
) {
78+
private fun isLocalResourceUri(uri: Uri?): Boolean = uri?.scheme?.startsWith("res") ?: false
79+
80+
fun getUri(context: Context): Uri? {
81+
val uri = computeUri(context)
82+
if (isLocalResourceUri(uri)) {
83+
return Uri.parse(
84+
uri!!.toString().replace("res:/", "android.resource://${context.packageName}/"),
85+
)
9386
}
94-
val rawResId = context.resources.getIdentifier(uri, "raw", context.packageName)
95-
if (rawResId != 0) {
96-
return RNSImageSource.DrawableRes(rawResId)
87+
return uri
88+
}
89+
90+
private fun computeUri(context: Context): Uri? {
91+
val stringUri = uriString ?: return null
92+
93+
return try {
94+
val uri = Uri.parse(stringUri)
95+
// Verify scheme is set, so that relative uri (used by static resources) are not handled.
96+
if (uri.scheme == null) {
97+
computeLocalUri(stringUri, context)
98+
} else {
99+
uri
100+
}
101+
} catch (_: Exception) {
102+
computeLocalUri(stringUri, context)
97103
}
98-
Log.e("[RNScreens]", "Resource not found in drawable or raw: $uri")
99-
return null
100104
}
101105

102-
// If asset isn't included in android source directories and we're loading it from given path.
103-
return RNSImageSource.UriString(uri)
106+
private fun computeLocalUri(
107+
name: String,
108+
context: Context,
109+
): Uri? = ResourceIdHelper.getResourceUri(context, name)
104110
}
105111

106-
private sealed class RNSImageSource {
107-
data class DrawableRes(
108-
val resId: Int,
109-
) : RNSImageSource()
112+
// Adapted from https://github.com/expo/expo/blob/sdk-52/packages/expo-image/android/src/main/java/expo/modules/image/ResourceIdHelper.kt
113+
private object ResourceIdHelper {
114+
private val idMap = mutableMapOf<String, Int>()
115+
116+
@SuppressLint("DiscouragedApi")
117+
private fun getIdForResourceType(
118+
context: Context,
119+
name: String,
120+
type: String,
121+
): Int {
122+
if (name.isEmpty()) return -1
123+
val normalizedName = name.lowercase(Locale.ROOT).replace("-", "_")
124+
val key = "$type/$normalizedName"
125+
synchronized(this) {
126+
idMap[key]?.let { return it }
127+
val id = context.resources.getIdentifier(normalizedName, type, context.packageName)
128+
idMap[key] = id
129+
return id
130+
}
131+
}
110132

111-
data class UriString(
112-
val uri: String,
113-
) : RNSImageSource()
133+
fun getResourceUri(
134+
context: Context,
135+
name: String,
136+
): Uri? {
137+
val normalizedName = name.lowercase(Locale.ROOT).replace("-", "_")
138+
139+
val drawableResId = getIdForResourceType(context, name, "drawable")
140+
if (drawableResId != 0) {
141+
return Uri.parse("res:/$drawableResId")
142+
}
143+
144+
val rawResId = getIdForResourceType(context, name, "raw")
145+
if (rawResId != 0) {
146+
return Uri.parse("res:/$rawResId")
147+
}
148+
149+
return if (name.startsWith("asset:/")) {
150+
Uri.parse("file:///android_asset/" + name.removePrefix("asset:/"))
151+
} else {
152+
Uri.parse("file:///android_asset/$name")
153+
}
154+
}
114155
}

0 commit comments

Comments
 (0)