diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0d55b0f..111a1258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,8 +147,8 @@ jobs: - name: Install cocoapods if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' run: | - cd example/ios - pod install + cd example + pod install --project-directory=ios env: NO_FLIPPER: 1 diff --git a/android/src/main/java/com/rcttabview/RCTTabView.kt b/android/src/main/java/com/rcttabview/RCTTabView.kt index 152037aa..df073ee2 100644 --- a/android/src/main/java/com/rcttabview/RCTTabView.kt +++ b/android/src/main/java/com/rcttabview/RCTTabView.kt @@ -1,15 +1,27 @@ package com.rcttabview import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri import android.view.Choreographer import android.view.MenuItem -import androidx.appcompat.content.res.AppCompatResources +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSources +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.image.CloseableBitmap +import com.facebook.imagepipeline.request.ImageRequestBuilder import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.WritableMap +import com.facebook.react.views.imagehelper.ImageSource +import com.facebook.react.views.imagehelper.ImageSource.Companion.getTransparentBitmapImageSource import com.google.android.material.bottomnavigation.BottomNavigationView + class ReactBottomNavigationView(context: Context) : BottomNavigationView(context) { private val ANIMATION_DURATION: Long = 300 + private val icons: MutableMap = mutableMapOf() var items: MutableList? = null var onTabSelectedListener: ((WritableMap) -> Unit)? = null @@ -62,17 +74,10 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context fun updateItems(items: MutableList) { this.items = items - // TODO: This doesn't work with hot reload. It clears all menu items - menu.clear() items.forEachIndexed {index, item -> - val menuItem = menu.add(0, index, 0, item.title) - val iconResourceId = resources.getIdentifier( - item.icon, "drawable", context.packageName - ) - if (iconResourceId != 0) { - menuItem.icon = AppCompatResources.getDrawable(context, iconResourceId) - } else { - menuItem.setIcon(android.R.drawable.btn_star) // fallback icon + val menuItem = getOrCreateItem(index, item.title) + if (icons.containsKey(index)) { + menuItem.icon = getDrawable(icons[index]!!) } if (item.badge.isNotEmpty()) { val badge = this.getOrCreateBadge(index) @@ -84,6 +89,47 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context } } + private fun getOrCreateItem(index: Int, title: String): MenuItem { + return menu.findItem(index) ?: menu.add(0, index, 0, title) + } + + fun setIcons(icons: ReadableArray?) { + if (icons == null || icons.size() == 0) { + return + } + + for (idx in 0 until icons.size()) { + val source = icons.getMap(idx) + var imageSource = + ImageSource( + context, + source.getString("uri") + ) + if (Uri.EMPTY == imageSource.uri) { + imageSource = getTransparentBitmapImageSource(context) + } + this.icons[idx] = imageSource + + // Update existing item if exists. + menu.findItem(idx)?.let { menuItem -> + menuItem.icon = getDrawable(imageSource) + } + } + } + + private fun getDrawable(imageSource: ImageSource): Drawable { + // TODO: Check if this can be done using some built-in React Native class + val imageRequest = ImageRequestBuilder.newBuilderWithSource(imageSource.uri).build() + val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context) + val result = DataSources.waitForFinalResult(dataSource) as CloseableReference + val bitmap = result.get().underlyingBitmap + + CloseableReference.closeSafely(result) + dataSource.close() + + return BitmapDrawable(resources, bitmap) + } + // Fixes issues with BottomNavigationView children layouting. private fun measureAndLayout() { measure( diff --git a/android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt b/android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt index ffa98afd..bb451df0 100644 --- a/android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt +++ b/android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt @@ -18,7 +18,6 @@ import com.facebook.yoga.YogaNode data class TabInfo( val key: String, - val icon: String, val title: String, val badge: String ) @@ -40,7 +39,6 @@ class RCTTabViewViewManager : itemsArray.add( TabInfo( key = item.getString("key") ?: "", - icon = item.getString("icon") ?: "", title = item.getString("title") ?: "", badge = item.getString("badge") ?: "" ) @@ -57,6 +55,11 @@ class RCTTabViewViewManager : } } + @ReactProp(name = "icons") + fun setIcons(view: ReactBottomNavigationView, icons: ReadableArray?) { + view.setIcons(icons) + } + public override fun createViewInstance(context: ThemedReactContext): ReactBottomNavigationView { eventDispatcher = context.getNativeModule(UIManagerModule::class.java)!!.eventDispatcher val view = ReactBottomNavigationView(context) diff --git a/example/assets/icons/article_dark.png b/example/assets/icons/article_dark.png new file mode 100644 index 00000000..bb7148e7 Binary files /dev/null and b/example/assets/icons/article_dark.png differ diff --git a/example/assets/icons/chat_dark.png b/example/assets/icons/chat_dark.png new file mode 100644 index 00000000..c79f74a3 Binary files /dev/null and b/example/assets/icons/chat_dark.png differ diff --git a/example/assets/icons/grid_dark.png b/example/assets/icons/grid_dark.png new file mode 100644 index 00000000..7556ecdf Binary files /dev/null and b/example/assets/icons/grid_dark.png differ diff --git a/example/assets/icons/person_dark.png b/example/assets/icons/person_dark.png new file mode 100644 index 00000000..a24a6807 Binary files /dev/null and b/example/assets/icons/person_dark.png differ diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 24207675..bd5cafa7 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1774,15 +1774,15 @@ SPEC CHECKSUMS: React-CoreModules: 2d68c251bc4080028f2835fa47504e8f20669a21 React-cxxreact: bb0dc212b515d6dba6c6ddc4034584e148857db9 React-debug: fd0ed8ecd5f8a23c7daf5ceaca8aa722a4d083fd - React-defaultsnativemodule: 371dc516e5020f8b87f1d32f8fa6872cafcc2081 - React-domnativemodule: 5d1288b9b8666b818a1004b56a03befc00eb5698 + React-defaultsnativemodule: 0d824306a15dd80e2bea12f4079fbeff9712b301 + React-domnativemodule: 195491d7c1725befd636f84c67bf229203fc7d07 React-Fabric: c12ce848f72cba42fb9e97a73a7c99abc6353f23 React-FabricComponents: 7813d5575c8ea2cda0fef9be4ff9d10987cba512 React-FabricImage: c511a5d612479cb4606edf3557c071956c8735f6 React-featureflags: cf78861db9318ae29982fa8953c92d31b276c9ac - React-featureflagsnativemodule: e774cf495486b0e2a8b324568051d6b4c722fa93 + React-featureflagsnativemodule: 54f6decea27c187c2127e3669a7f5bf2e145e637 React-graphics: 7572851bca7242416b648c45d6af87d93d29281e - React-idlecallbacksnativemodule: d2009bad67ef232a0ee586f53193f37823e81ef1 + React-idlecallbacksnativemodule: 7d21b0e071c3e02bcc897d2c3db51319642dd466 React-ImageManager: aedf54d34d4475c66f4c3da6b8359b95bee904e4 React-jsc: 92ac98e0e03ee54fdaa4ac3936285a4fdb166fab React-jserrorhandler: 0c8949672a00f2a502c767350e591e3ec3d82fb3 @@ -1792,8 +1792,8 @@ SPEC CHECKSUMS: React-jsitracing: 3935b092f85bb1e53b8cf8a00f572413648af46b React-logger: 4072f39df335ca443932e0ccece41fbeb5ca8404 React-Mapbuffer: 714f2fae68edcabfc332b754e9fbaa8cfc68fdd4 - React-microtasksnativemodule: 987cf7e0e0e7129250a48b807e70d3b906c726cf - react-native-bottom-tabs: 894d1fb8fc4e6d525b2da35e83e00e18c420cdf2 + React-microtasksnativemodule: 618b64238e43ef3154079f193aa6649e5320ae19 + react-native-bottom-tabs: 5662b5e3b5968bec6258b9d6f1a0a834bd3f7553 react-native-safe-area-context: 851c62c48dce80ccaa5637b6aa5991a1bc36eca9 React-nativeconfig: 4a9543185905fe41014c06776bf126083795aed9 React-NativeModulesApple: 651670a799672bd54469f2981d91493dda361ddf @@ -1820,11 +1820,11 @@ SPEC CHECKSUMS: React-utils: b2baee839fb869f732d617b97dcfa384b4b4fdb3 ReactCodegen: f177b8fd67788c5c6ff45a39c7482c5f8d77ace6 ReactCommon: 627bd3192ef01a351e804e9709673d3741d38fec - ReactNativeHost: 99c0ffb175cd69de2ac9a70892cd22dac65ea79d + ReactNativeHost: 62249d6e1e42a969159946c035c1cd3f4b1035dd ReactTestApp-DevSupport: b7cd76a3aeee6167f5e14d82f09685059152c426 ReactTestApp-Resources: 7db90c026cccdf40cfa495705ad436ccc4d64154 - RNGestureHandler: 18b9b5d65c77c4744a640f69b7fccdd47ed935c0 - RNScreens: 5288a8dbeedb3c5051aa2d5658c1c553c050b80a + RNGestureHandler: 366823a3ebcc5ddd25550dbfe80e89779c4760b2 + RNScreens: d86f05e9c243a063ca67cda7f4e05d28fe5c31d4 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 4ef80d96a5534f0e01b3055f17d1e19a9fc61b63 diff --git a/example/package.json b/example/package.json index a15fe9c2..6e05fd86 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,7 @@ "scripts": { "android": "react-native run-android", "build:android": "npm run mkdist && react-native bundle --entry-file index.js --platform android --dev true --bundle-output dist/main.android.jsbundle --assets-dest dist && react-native build-android --extra-params \"--no-daemon --console=plain -PreactNativeArchitectures=arm64-v8a\"", - "build:ios": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.ios.jsbundle --assets-dest dist && react-native build-ios --scheme SwiftuiTabviewExample --mode Debug --extra-params \"-sdk iphonesimulator CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ GCC_OPTIMIZATION_LEVEL=0 GCC_PRECOMPILE_PREFIX_HEADER=YES ASSETCATALOG_COMPILER_OPTIMIZATION=time DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO\"", + "build:ios": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.ios.jsbundle --assets-dest dist && react-native build-ios --scheme ReactNativeBottomTabs --mode Debug --extra-params \"-sdk iphonesimulator CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ GCC_OPTIMIZATION_LEVEL=0 GCC_PRECOMPILE_PREFIX_HEADER=YES ASSETCATALOG_COMPILER_OPTIMIZATION=time DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO\"", "build:visionos": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.visionos.jsbundle --assets-dest dist", "ios": "react-native run-ios", "mkdist": "node -e \"require('node:fs').mkdirSync('dist', { recursive: true, mode: 0o755 })\"", @@ -22,6 +22,7 @@ "react": "18.3.1", "react-native": "0.75.3", "react-native-gesture-handler": "^2.20.0", + "react-native-paper": "^5.12.5", "react-native-safe-area-context": "^4.11.0", "react-native-screens": "^3.34.0" }, diff --git a/example/src/App.tsx b/example/src/App.tsx index 14836362..49dbe840 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -18,16 +18,20 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import JSBottomTabs from './Examples/JSBottomTabs'; import ThreeTabs from './Examples/ThreeTabs'; import FourTabs from './Examples/FourTabs'; +import MaterialBottomTabs from './Examples/MaterialBottomTabs'; +import SFSymbols from './Examples/SFSymbols'; const examples = [ { component: ThreeTabs, name: 'Three Tabs' }, { component: FourTabs, name: 'Four Tabs' }, + { component: SFSymbols, name: 'SF Symbols' }, { component: FourTabs, name: 'Four Tabs - No header', screenOptions: { headerShown: false }, }, { component: JSBottomTabs, name: 'JS Bottom Tabs' }, + { component: MaterialBottomTabs, name: 'Material (JS) Bottom Tabs' }, ]; function App() { diff --git a/example/src/Examples/FourTabs.tsx b/example/src/Examples/FourTabs.tsx index 6ec946a9..aef79b1f 100644 --- a/example/src/Examples/FourTabs.tsx +++ b/example/src/Examples/FourTabs.tsx @@ -8,15 +8,29 @@ import { Chat } from '../Screens/Chat'; export default function FourTabs() { const [index, setIndex] = useState(0); const [routes] = useState([ - { key: 'article', title: 'Article', icon: 'document.fill', badge: '!' }, + { + key: 'article', + title: 'Article', + focusedIcon: require('../../assets/icons/article_dark.png'), + unfocusedIcon: require('../../assets/icons/chat_dark.png'), + badge: '!', + }, { key: 'albums', title: 'Albums', - icon: 'square.grid.2x2.fill', + focusedIcon: require('../../assets/icons/grid_dark.png'), badge: '5', }, - { key: 'contacts', title: 'Contacts', icon: 'person.fill' }, - { key: 'chat', title: 'Chat', icon: 'keyboard' }, + { + key: 'contacts', + focusedIcon: require('../../assets/icons/person_dark.png'), + title: 'Contacts', + }, + { + key: 'chat', + focusedIcon: require('../../assets/icons/chat_dark.png'), + title: 'Chat', + }, ]); const renderScene = SceneMap({ diff --git a/example/src/Examples/MaterialBottomTabs.tsx b/example/src/Examples/MaterialBottomTabs.tsx new file mode 100644 index 00000000..1313506b --- /dev/null +++ b/example/src/Examples/MaterialBottomTabs.tsx @@ -0,0 +1,61 @@ +import { createMaterialBottomTabNavigator } from 'react-native-paper/react-navigation'; +import { Article } from '../Screens/Article'; +import { Albums } from '../Screens/Albums'; +import { Contacts } from '../Screens/Contacts'; +import { Chat } from '../Screens/Chat'; +import { Image, type ImageSourcePropType } from 'react-native'; + +const Tab = createMaterialBottomTabNavigator(); + +const TabBarIcon = ({ source }: { source: ImageSourcePropType }) => ( + +); + +function MaterialBottomTabs() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} + +export default MaterialBottomTabs; diff --git a/example/src/Examples/SFSymbols.tsx b/example/src/Examples/SFSymbols.tsx new file mode 100644 index 00000000..6e166015 --- /dev/null +++ b/example/src/Examples/SFSymbols.tsx @@ -0,0 +1,54 @@ +import TabView, { SceneMap } from 'react-native-bottom-tabs'; +import { useState } from 'react'; +import { Article } from '../Screens/Article'; +import { Albums } from '../Screens/Albums'; +import { Contacts } from '../Screens/Contacts'; +import { Platform } from 'react-native'; + +const isAndroid = Platform.OS === 'android'; + +export default function SFSymbols() { + const [index, setIndex] = useState(0); + const [routes] = useState([ + { + key: 'article', + title: 'Article', + focusedIcon: isAndroid + ? require('../../assets/icons/article_dark.png') + : { sfSymbol: 'document.fill' }, + unfocusedIcon: isAndroid + ? require('../../assets/icons/chat_dark.png') + : { sfSymbol: 'bubble.left.fill' }, + badge: '!', + }, + { + key: 'albums', + title: 'Albums', + focusedIcon: isAndroid + ? require('../../assets/icons/grid_dark.png') + : { sfSymbol: 'square.grid.3x2.fill' }, + badge: '5', + }, + { + key: 'contacts', + focusedIcon: isAndroid + ? require('../../assets/icons/person_dark.png') + : { sfSymbol: 'person.fill' }, + title: 'Contacts', + }, + ]); + + const renderScene = SceneMap({ + article: Article, + albums: Albums, + contacts: Contacts, + }); + + return ( + + ); +} diff --git a/example/src/Examples/ThreeTabs.tsx b/example/src/Examples/ThreeTabs.tsx index 87ea997c..8c867471 100644 --- a/example/src/Examples/ThreeTabs.tsx +++ b/example/src/Examples/ThreeTabs.tsx @@ -7,14 +7,24 @@ import { Contacts } from '../Screens/Contacts'; export default function ThreeTabs() { const [index, setIndex] = useState(0); const [routes] = useState([ - { key: 'article', title: 'Article', icon: 'document.fill', badge: '!' }, + { + key: 'article', + title: 'Article', + focusedIcon: require('../../assets/icons/article_dark.png'), + unfocusedIcon: require('../../assets/icons/chat_dark.png'), + badge: '!', + }, { key: 'albums', title: 'Albums', - icon: 'square.grid.2x2.fill', + focusedIcon: require('../../assets/icons/grid_dark.png'), badge: '5', }, - { key: 'contacts', title: 'Contacts', icon: 'person.fill' }, + { + key: 'contacts', + focusedIcon: require('../../assets/icons/person_dark.png'), + title: 'Contacts', + }, ]); const renderScene = SceneMap({ diff --git a/example/src/Screens/Article.tsx b/example/src/Screens/Article.tsx index 9e76905c..9accefb2 100644 --- a/example/src/Screens/Article.tsx +++ b/example/src/Screens/Article.tsx @@ -3,6 +3,7 @@ import { Button, Image, Platform, + SafeAreaView, ScrollView, type ScrollViewProps, StyleSheet, @@ -49,95 +50,98 @@ export function Article({ console.log(Platform.OS, ' Rendering Article'); return ( - - + + + + + + {author.name} + {date} + + +