Skip to content

Commit

Permalink
feat: Handle custom icons (#8)
Browse files Browse the repository at this point in the history
* feat: add material bottom tabs reference example

* feat: implement custom image handling on iOS

* feat: implement custom image handling on Android

* fix: native iOS build

* fix: support remote images for android

* feat: handle SF Symbols

* feat: add sfsymbols

* fix: top safe area

* attempt to fix CI
  • Loading branch information
okwasniewski authored Oct 4, 2024
1 parent f121778 commit 3b66be2
Show file tree
Hide file tree
Showing 24 changed files with 599 additions and 177 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 57 additions & 11 deletions android/src/main/java/com/rcttabview/RCTTabView.kt
Original file line number Diff line number Diff line change
@@ -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<Int, ImageSource> = mutableMapOf()

var items: MutableList<TabInfo>? = null
var onTabSelectedListener: ((WritableMap) -> Unit)? = null
Expand Down Expand Up @@ -62,17 +74,10 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context

fun updateItems(items: MutableList<TabInfo>) {
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)
Expand All @@ -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<CloseableBitmap>
val bitmap = result.get().underlyingBitmap

CloseableReference.closeSafely(result)
dataSource.close()

return BitmapDrawable(resources, bitmap)
}

// Fixes issues with BottomNavigationView children layouting.
private fun measureAndLayout() {
measure(
Expand Down
7 changes: 5 additions & 2 deletions android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import com.facebook.yoga.YogaNode

data class TabInfo(
val key: String,
val icon: String,
val title: String,
val badge: String
)
Expand All @@ -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") ?: ""
)
Expand All @@ -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)
Expand Down
Binary file added example/assets/icons/article_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/icons/chat_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/icons/grid_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/icons/person_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 9 additions & 9 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 })\"",
Expand All @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
22 changes: 18 additions & 4 deletions example/src/Examples/FourTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
61 changes: 61 additions & 0 deletions example/src/Examples/MaterialBottomTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Image style={{ width: 20, height: 23 }} source={source} />

Check warning on line 11 in example/src/Examples/MaterialBottomTabs.tsx

View workflow job for this annotation

GitHub Actions / lint

Inline style: { width: 20, height: 23 }
);

function MaterialBottomTabs() {
return (
<Tab.Navigator shifting>
<Tab.Screen
name="Article"
component={Article}
options={{
tabBarIcon: () => (

Check warning on line 21 in example/src/Examples/MaterialBottomTabs.tsx

View workflow job for this annotation

GitHub Actions / lint

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “MaterialBottomTabs” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
<TabBarIcon
source={require('../../assets/icons/article_dark.png')}
/>
),
}}
/>
<Tab.Screen
name="Albums"
component={Albums}
options={{
tabBarIcon: () => (

Check warning on line 32 in example/src/Examples/MaterialBottomTabs.tsx

View workflow job for this annotation

GitHub Actions / lint

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “MaterialBottomTabs” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
<TabBarIcon source={require('../../assets/icons/grid_dark.png')} />
),
}}
/>
<Tab.Screen
name="Contacts"
component={Contacts}
options={{
tabBarIcon: () => (

Check warning on line 41 in example/src/Examples/MaterialBottomTabs.tsx

View workflow job for this annotation

GitHub Actions / lint

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “MaterialBottomTabs” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
<TabBarIcon
source={require('../../assets/icons/person_dark.png')}
/>
),
}}
/>
<Tab.Screen
name="Chat"
component={Chat}
options={{
tabBarIcon: () => (

Check warning on line 52 in example/src/Examples/MaterialBottomTabs.tsx

View workflow job for this annotation

GitHub Actions / lint

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “MaterialBottomTabs” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
<TabBarIcon source={require('../../assets/icons/chat_dark.png')} />
),
}}
/>
</Tab.Navigator>
);
}

export default MaterialBottomTabs;
54 changes: 54 additions & 0 deletions example/src/Examples/SFSymbols.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TabView
navigationState={{ index, routes }}
onIndexChange={setIndex}
renderScene={renderScene}
/>
);
}
16 changes: 13 additions & 3 deletions example/src/Examples/ThreeTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading

0 comments on commit 3b66be2

Please sign in to comment.