Skip to content

Commit c7fb301

Browse files
authored
feat: introduce config (#9)
* feat: introduce config * fix: resizing images, chat example * fix: tabview animations on Android
1 parent 3b66be2 commit c7fb301

File tree

11 files changed

+183
-58
lines changed

11 files changed

+183
-58
lines changed

android/src/main/java/com/rcttabview/RCTTabView.kt

+34-32
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,26 @@ import com.facebook.imagepipeline.request.ImageRequestBuilder
1414
import com.facebook.react.bridge.Arguments
1515
import com.facebook.react.bridge.ReadableArray
1616
import com.facebook.react.bridge.WritableMap
17+
import com.facebook.react.modules.core.ReactChoreographer
1718
import com.facebook.react.views.imagehelper.ImageSource
1819
import com.facebook.react.views.imagehelper.ImageSource.Companion.getTransparentBitmapImageSource
1920
import com.google.android.material.bottomnavigation.BottomNavigationView
2021

2122

2223
class ReactBottomNavigationView(context: Context) : BottomNavigationView(context) {
23-
private val ANIMATION_DURATION: Long = 300
2424
private val icons: MutableMap<Int, ImageSource> = mutableMapOf()
25-
25+
private var isLayoutEnqueued = false
2626
var items: MutableList<TabInfo>? = null
2727
var onTabSelectedListener: ((WritableMap) -> Unit)? = null
2828
private var isAnimating = false
29-
private val frameCallback = Choreographer.FrameCallback {
30-
if (isAnimating) {
31-
measureAndLayout()
32-
}
29+
30+
private val layoutCallback = Choreographer.FrameCallback {
31+
isLayoutEnqueued = false
32+
measure(
33+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
34+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
35+
)
36+
layout(left, top, right, bottom)
3337
}
3438

3539
init {
@@ -41,35 +45,31 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context
4145

4246
override fun requestLayout() {
4347
super.requestLayout()
44-
// Manually trigger measure & layout, as RN on Android skips those.
45-
// See this issue: https://github.com/facebook/react-native/issues/17968#issuecomment-721958427
46-
this.post {
47-
measureAndLayout()
48+
@Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
49+
if (!isLayoutEnqueued && layoutCallback != null) {
50+
isLayoutEnqueued = true
51+
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
52+
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
53+
ReactChoreographer
54+
.getInstance()
55+
.postFrameCallback(
56+
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
57+
layoutCallback,
58+
)
4859
}
4960
}
5061

5162
private fun onTabSelected(item: MenuItem) {
63+
if (isLayoutEnqueued) {
64+
return;
65+
}
5266
val selectedItem = items?.first { it.title == item.title }
5367
selectedItem?.let {
5468
val event = Arguments.createMap().apply {
5569
putString("key", selectedItem.key)
5670
}
5771
onTabSelectedListener?.invoke(event)
58-
startAnimation()
59-
}
60-
}
61-
62-
// Refresh TabView children to fix issue with animations.
63-
// https://github.com/facebook/react-native/issues/17968#issuecomment-697136929
64-
private fun startAnimation() {
65-
if (labelVisibilityMode != LABEL_VISIBILITY_AUTO) {
66-
return
6772
}
68-
isAnimating = true
69-
Choreographer.getInstance().postFrameCallback(frameCallback)
70-
postDelayed({
71-
isAnimating = false
72-
}, ANIMATION_DURATION)
7373
}
7474

7575
fun updateItems(items: MutableList<TabInfo>) {
@@ -117,6 +117,16 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context
117117
}
118118
}
119119

120+
fun setConfig(config: TabViewConfig) {
121+
labelVisibilityMode = if (config.labeled == false) {
122+
LABEL_VISIBILITY_UNLABELED
123+
} else if (config.labeled == true) {
124+
LABEL_VISIBILITY_LABELED
125+
} else {
126+
LABEL_VISIBILITY_AUTO
127+
}
128+
}
129+
120130
private fun getDrawable(imageSource: ImageSource): Drawable {
121131
// TODO: Check if this can be done using some built-in React Native class
122132
val imageRequest = ImageRequestBuilder.newBuilderWithSource(imageSource.uri).build()
@@ -130,14 +140,6 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context
130140
return BitmapDrawable(resources, bitmap)
131141
}
132142

133-
// Fixes issues with BottomNavigationView children layouting.
134-
private fun measureAndLayout() {
135-
measure(
136-
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
137-
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
138-
layout(left, top, right, bottom)
139-
}
140-
141143
override fun onDetachedFromWindow() {
142144
super.onDetachedFromWindow()
143145
isAnimating = false

android/src/main/java/com/rcttabview/RCTTabViewViewManager.kt

+13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.rcttabview
22

33
import android.view.View.MeasureSpec
44
import com.facebook.react.bridge.ReadableArray
5+
import com.facebook.react.bridge.ReadableMap
56
import com.facebook.react.common.MapBuilder
67
import com.facebook.react.module.annotations.ReactModule
78
import com.facebook.react.uimanager.LayoutShadowNode
@@ -22,6 +23,10 @@ data class TabInfo(
2223
val badge: String
2324
)
2425

26+
data class TabViewConfig(
27+
val labeled: Boolean?
28+
)
29+
2530
@ReactModule(name = RCTTabViewViewManager.NAME)
2631
class RCTTabViewViewManager :
2732
SimpleViewManager<ReactBottomNavigationView>() {
@@ -55,6 +60,14 @@ class RCTTabViewViewManager :
5560
}
5661
}
5762

63+
@ReactProp(name = "config")
64+
fun setConfig(view: ReactBottomNavigationView, config: ReadableMap?) {
65+
val tabViewConfig = TabViewConfig(
66+
labeled = if (config?.hasKey("labeled") == true) config.getBoolean("labeled") else null,
67+
)
68+
view.setConfig(tabViewConfig)
69+
}
70+
5871
@ReactProp(name = "icons")
5972
fun setIcons(view: ReactBottomNavigationView, icons: ReadableArray?) {
6073
view.setIcons(icons)

example/src/App.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import ThreeTabs from './Examples/ThreeTabs';
2020
import FourTabs from './Examples/FourTabs';
2121
import MaterialBottomTabs from './Examples/MaterialBottomTabs';
2222
import SFSymbols from './Examples/SFSymbols';
23+
import LabeledTabs from './Examples/Labeled';
2324

2425
const examples = [
2526
{ component: ThreeTabs, name: 'Three Tabs' },
2627
{ component: FourTabs, name: 'Four Tabs' },
2728
{ component: SFSymbols, name: 'SF Symbols' },
29+
{ component: LabeledTabs, name: 'Labeled Tabs' },
2830
{
2931
component: FourTabs,
3032
name: 'Four Tabs - No header',

example/src/Examples/FourTabs.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default function FourTabs() {
4242

4343
return (
4444
<TabView
45+
sidebarAdaptable
4546
navigationState={{ index, routes }}
4647
onIndexChange={setIndex}
4748
renderScene={renderScene}

example/src/Examples/Labeled.tsx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import TabView, { SceneMap } from 'react-native-bottom-tabs';
2+
import { useState } from 'react';
3+
import { Article } from '../Screens/Article';
4+
import { Albums } from '../Screens/Albums';
5+
import { Contacts } from '../Screens/Contacts';
6+
import { Chat } from '../Screens/Chat';
7+
8+
export default function LabeledTabs() {
9+
const [index, setIndex] = useState(0);
10+
const [routes] = useState([
11+
{
12+
key: 'article',
13+
title: 'Article',
14+
focusedIcon: require('../../assets/icons/article_dark.png'),
15+
badge: '!',
16+
},
17+
{
18+
key: 'albums',
19+
title: 'Albums',
20+
focusedIcon: require('../../assets/icons/grid_dark.png'),
21+
badge: '5',
22+
},
23+
{
24+
key: 'contacts',
25+
focusedIcon: require('../../assets/icons/person_dark.png'),
26+
title: 'Contacts',
27+
},
28+
{
29+
key: 'chat',
30+
focusedIcon: require('../../assets/icons/chat_dark.png'),
31+
title: 'Chat',
32+
},
33+
]);
34+
35+
const renderScene = SceneMap({
36+
article: Article,
37+
albums: Albums,
38+
contacts: Contacts,
39+
chat: Chat,
40+
});
41+
42+
return (
43+
<TabView
44+
labeled
45+
navigationState={{ index, routes }}
46+
onIndexChange={setIndex}
47+
renderScene={renderScene}
48+
/>
49+
);
50+
}

example/src/Screens/Chat.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Image,
3+
KeyboardAvoidingView,
34
Platform,
45
ScrollView,
56
type ScrollViewProps,
@@ -22,7 +23,10 @@ export function Chat({
2223
}: Partial<ScrollViewProps & { bottom: boolean }>) {
2324
console.log(Platform.OS, ' Rendering Chat');
2425
return (
25-
<View style={styles.container}>
26+
<KeyboardAvoidingView
27+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
28+
style={styles.container}
29+
>
2630
<ScrollView
2731
style={styles.inverted}
2832
contentContainerStyle={styles.content}
@@ -64,10 +68,10 @@ export function Chat({
6468
/>
6569
{bottom ? (
6670
<View
67-
style={[styles.spacer, Platform.OS !== 'android' && { height: 80 }]}
71+
style={[styles.spacer, Platform.OS !== 'android' && { height: 90 }]}
6872
/>
6973
) : null}
70-
</View>
74+
</KeyboardAvoidingView>
7175
);
7276
}
7377

ios/RCTTabViewViewManager.mm

+1
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ - (UIView *)view
2222
RCT_EXPORT_VIEW_PROPERTY(selectedPage, NSString)
2323
RCT_EXPORT_VIEW_PROPERTY(tabViewStyle, NSString)
2424
RCT_EXPORT_VIEW_PROPERTY(icons, NSArray<RCTImageSource *>);
25+
RCT_EXPORT_VIEW_PROPERTY(config, NSDictionary)
2526

2627
@end

ios/TabViewImpl.swift

+18-12
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import React
77
*/
88
class TabViewProps: ObservableObject {
99
@Published var children: [UIView]?
10+
@Published var config: TabViewConfig?
1011
@Published var items: TabData?
1112
@Published var selectedPage: String?
12-
@Published var tabViewStyle: String?
1313
@Published var icons: [Int: UIImage] = [:]
1414
}
1515

@@ -38,50 +38,56 @@ struct TabViewImpl: View {
3838
let child = props.children?[safe: index] ?? UIView()
3939
let tabData = props.items?.tabs[safe: index]
4040
let icon = props.icons[index]
41+
4142
RepresentableView(view: child)
4243
.frame(maxWidth: .infinity, maxHeight: .infinity)
4344
.tabItem {
44-
TabItem(icon: icon, sfSymbol: tabData?.sfSymbol, title: tabData?.title)
45+
TabItem(
46+
title: tabData?.title,
47+
icon: icon,
48+
sfSymbol: tabData?.sfSymbol,
49+
labeled: props.config?.labeled
50+
)
4551
}
4652
.tag(tabData?.key)
4753
.tabBadge(tabData?.badge)
4854
}
49-
.getTabViewStyle(name: props.tabViewStyle ?? "")
5055
.onChange(of: props.selectedPage ?? "") { newValue in
5156
onSelect(newValue)
5257
}
5358
}
59+
.getSidebarAdaptable(enabled: props.config?.sidebarAdaptable ?? false)
5460
}
5561
}
5662

5763
struct TabItem: View {
64+
var title: String?
5865
var icon: UIImage?
5966
var sfSymbol: String?
60-
var title: String?
67+
var labeled: Bool?
6168

6269
var body: some View {
6370
if let icon {
6471
Image(uiImage: icon)
6572
} else if let sfSymbol, !sfSymbol.isEmpty {
6673
Image(systemName: sfSymbol)
6774
}
68-
Text(title ?? "")
75+
if (labeled != false) {
76+
Text(title ?? "")
77+
}
6978
}
7079
}
7180

7281
extension View {
7382
@ViewBuilder
74-
func getTabViewStyle(name: String) -> some View {
75-
switch name {
76-
case "automatic":
77-
self.tabViewStyle(.automatic)
78-
case "sidebarAdaptable":
79-
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, *) {
83+
func getSidebarAdaptable(enabled: Bool) -> some View {
84+
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, *) {
85+
if (enabled) {
8086
self.tabViewStyle(.sidebarAdaptable)
8187
} else {
8288
self
8389
}
84-
default:
90+
} else {
8591
self
8692
}
8793
}

0 commit comments

Comments
 (0)