Skip to content

Commit 8dd3a80

Browse files
committed
feat: use ViewPager
1 parent a95f6b2 commit 8dd3a80

File tree

7 files changed

+254
-105
lines changed

7 files changed

+254
-105
lines changed

apps/example/src/Examples/ThreeTabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Albums } from '../Screens/Albums';
55
import { Contacts } from '../Screens/Contacts';
66

77
export default function ThreeTabs() {
8-
const [index, setIndex] = useState(0);
8+
const [index, setIndex] = useState(1);
99
const [routes] = useState([
1010
{
1111
key: 'article',

packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt

Lines changed: 107 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
package com.rcttabview
22

33
import android.annotation.SuppressLint
4-
import android.content.Context
54
import android.content.res.ColorStateList
65
import android.graphics.Color
7-
import android.graphics.Typeface
86
import android.graphics.drawable.ColorDrawable
97
import android.graphics.drawable.Drawable
108
import android.os.Build
9+
import android.transition.TransitionManager
1110
import android.util.Log
11+
import android.util.Size
1212
import android.util.TypedValue
1313
import android.view.Choreographer
1414
import android.view.Gravity
@@ -18,30 +18,36 @@ import android.view.View
1818
import android.view.ViewGroup
1919
import android.widget.FrameLayout
2020
import android.widget.TextView
21-
import androidx.appcompat.content.res.AppCompatResources
21+
import androidx.viewpager2.widget.ViewPager2
2222
import coil3.ImageLoader
2323
import coil3.asDrawable
24+
import coil3.request.ImageRequest
25+
import coil3.svg.SvgDecoder
26+
import com.facebook.react.bridge.ReactContext
2427
import com.facebook.react.bridge.ReadableArray
2528
import com.facebook.react.common.assets.ReactFontManager
2629
import com.facebook.react.modules.core.ReactChoreographer
2730
import com.facebook.react.views.text.ReactTypefaceUtils
2831
import com.google.android.material.bottomnavigation.BottomNavigationView
29-
import coil3.request.ImageRequest
30-
import coil3.svg.SvgDecoder
3132
import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO
3233
import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED
3334
import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED
35+
import com.google.android.material.transition.platform.MaterialFadeThrough
3436

35-
36-
class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
37+
class ReactBottomNavigationView(context: ReactContext) : FrameLayout(context) {
38+
private val reactContext: ReactContext = context
3739
private val bottomNavigation = BottomNavigationView(context)
38-
private val layoutHolder = FrameLayout(context)
39-
private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
40-
private var isLayoutEnqueued = false
41-
var items: MutableList<TabInfo>? = null
40+
private val viewPager = ViewPager2(context)
41+
val viewPagerAdapter = ViewPagerAdapter()
42+
4243
var onTabSelectedListener: ((key: String) -> Unit)? = null
4344
var onTabLongPressedListener: ((key: String) -> Unit)? = null
4445
var onNativeLayoutListener: ((width: Double, height: Double) -> Unit)? = null
46+
var disablePageTransitions = false
47+
var items: MutableList<TabInfo>? = null
48+
49+
private var selectedItem: String? = null
50+
private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
4551
private var activeTintColor: Int? = null
4652
private var inactiveTintColor: Int? = null
4753
private val checkedStateSet = intArrayOf(android.R.attr.state_checked)
@@ -50,80 +56,86 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
5056
private var fontSize: Int? = null
5157
private var fontFamily: String? = null
5258
private var fontWeight: Int? = null
59+
private var lastReportedSize: Size? = null
60+
61+
private val imageLoader = ImageLoader.Builder(context)
62+
.components {
63+
add(SvgDecoder.Factory())
64+
}
65+
.build()
5366

5467
init {
55-
val layoutHolderFrameLayout = LayoutParams(
56-
LayoutParams.MATCH_PARENT,
57-
LayoutParams.MATCH_PARENT
68+
viewPager.adapter = viewPagerAdapter
69+
viewPager.isUserInputEnabled = false
70+
71+
viewPager.id = View.generateViewId()
72+
addView(
73+
viewPager, LayoutParams(
74+
LayoutParams.MATCH_PARENT,
75+
LayoutParams.MATCH_PARENT
76+
)
5877
)
59-
addView(layoutHolder, layoutHolderFrameLayout)
6078

61-
val bottomNavParams = LayoutParams(
79+
addView(bottomNavigation, LayoutParams(
6280
LayoutParams.MATCH_PARENT,
6381
LayoutParams.WRAP_CONTENT
6482
).apply {
6583
gravity = Gravity.BOTTOM
66-
}
67-
68-
addView(bottomNavigation, bottomNavParams)
84+
})
6985

7086
post {
71-
this.addOnLayoutChangeListener { _, left, top, right, bottom,
72-
oldLeft, oldTop, oldRight, oldBottom ->
87+
addOnLayoutChangeListener { _, left, top, right, bottom,
88+
_, _, _, _ ->
7389
val newWidth = right - left
7490
val newHeight = bottom - top
75-
val oldWidth = oldRight - oldLeft
76-
val oldHeight = oldBottom - oldTop
77-
if (newWidth != oldWidth || newHeight != oldHeight) {
78-
val availableHeight = height - bottomNavigation.height
79-
val displayDensity = context.resources.displayMetrics.density
8091

81-
val dpWidth = (width / displayDensity).toDouble()
82-
val dpHeight = (availableHeight / displayDensity).toDouble()
92+
if (newWidth != lastReportedSize?.width || newHeight != lastReportedSize?.height) {
93+
// We subtract bottom navigation height from the screen height
94+
// This should be refactored for adaptive navigation
95+
val availableHeight = viewPager.height - bottomNavigation.height
96+
97+
val dpWidth = Utils.convertPixelsToDp(context, viewPager.width)
98+
val dpHeight = Utils.convertPixelsToDp(context, availableHeight)
8399

84100
onNativeLayoutListener?.invoke(dpWidth, dpHeight)
101+
lastReportedSize = Size(newWidth, newHeight)
85102
}
86103
}
87104
}
88105
}
89106

107+
fun setSelectedItem(value: String) {
108+
selectedItem = value
109+
items?.indexOfFirst { it.key == value }?.let {
110+
setSelectedIndex(it)
111+
}
112+
}
113+
90114
override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams?) {
91-
if (child === layoutHolder || child === bottomNavigation) {
115+
if (child === viewPager || child === bottomNavigation) {
92116
super.addView(child, index, params)
93117
} else {
94-
layoutHolder.addView(child, params)
118+
viewPagerAdapter.addChild(child, index)
119+
val itemKey = items?.get(index)?.key
120+
if (selectedItem == itemKey) {
121+
setSelectedIndex(index)
122+
}
95123
}
96124
}
97125

98-
private val imageLoader = ImageLoader.Builder(context)
99-
.components {
100-
add(SvgDecoder.Factory())
101-
}
102-
.build()
103-
104126
private val layoutCallback = Choreographer.FrameCallback {
105-
isLayoutEnqueued = false
106127
measure(
107128
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
108129
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
109130
)
110131
layout(left, top, right, bottom)
111132
}
112133

113-
private fun onTabLongPressed(item: MenuItem) {
114-
val longPressedItem = items?.firstOrNull { it.title == item.title }
115-
longPressedItem?.let {
116-
onTabLongPressedListener?.invoke(longPressedItem.key)
117-
emitHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
118-
}
119-
}
120-
121134
override fun requestLayout() {
122135
super.requestLayout()
123136
@Suppress("SENSELESS_COMPARISON") // layoutCallback can be null here since this method can be called in init
124137

125-
if (!isLayoutEnqueued && layoutCallback != null) {
126-
isLayoutEnqueued = true
138+
if (layoutCallback != null) {
127139
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
128140
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
129141
ReactChoreographer
@@ -135,24 +147,38 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
135147
}
136148
}
137149

138-
private fun onTabSelected(item: MenuItem) {
139-
if (isLayoutEnqueued) {
140-
return
150+
private fun setSelectedIndex(itemId: Int) {
151+
bottomNavigation.selectedItemId = itemId
152+
if (!disablePageTransitions) {
153+
val fadeThrough = MaterialFadeThrough()
154+
TransitionManager.beginDelayedTransition(this, fadeThrough)
141155
}
156+
viewPager.setCurrentItem(itemId, false)
157+
}
158+
159+
private fun onTabSelected(item: MenuItem) {
142160
val selectedItem = items?.first { it.title == item.title }
143161
selectedItem?.let {
144162
onTabSelectedListener?.invoke(selectedItem.key)
145163
emitHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
146164
}
147165
}
148166

167+
private fun onTabLongPressed(item: MenuItem) {
168+
val longPressedItem = items?.firstOrNull { it.title == item.title }
169+
longPressedItem?.let {
170+
onTabLongPressedListener?.invoke(longPressedItem.key)
171+
emitHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
172+
}
173+
}
174+
149175
fun updateItems(items: MutableList<TabInfo>) {
150176
this.items = items
151177
items.forEachIndexed { index, item ->
152178
val menuItem = getOrCreateItem(index, item.title)
153179
menuItem.isVisible = !item.hidden
154180
if (iconSources.containsKey(index)) {
155-
getDrawable(iconSources[index]!!) {
181+
getDrawable(iconSources[index]!!) {
156182
menuItem.icon = it
157183
}
158184
}
@@ -165,7 +191,7 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
165191
bottomNavigation.removeBadge(index)
166192
}
167193
post {
168-
val itemView = findViewById<View>(menuItem.itemId)
194+
val itemView = bottomNavigation.findViewById<View>(menuItem.itemId)
169195
itemView?.let { view ->
170196
view.setOnLongClickListener {
171197
onTabLongPressed(menuItem)
@@ -177,9 +203,10 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
177203
}
178204

179205
item.testID?.let { testId ->
180-
view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)?.apply {
206+
view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)
207+
?.apply {
181208
tag = testId
182-
}
209+
}
183210
}
184211
}
185212
updateTextAppearance()
@@ -209,7 +236,7 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
209236

210237
// Update existing item if exists.
211238
bottomNavigation.menu.findItem(idx)?.let { menuItem ->
212-
getDrawable(imageSource) {
239+
getDrawable(imageSource) {
213240
menuItem.icon = it
214241
}
215242
}
@@ -218,15 +245,17 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
218245

219246
fun setLabeled(labeled: Boolean?) {
220247
bottomNavigation.labelVisibilityMode = when (labeled) {
221-
false -> {
222-
LABEL_VISIBILITY_UNLABELED
223-
}
224-
true -> {
225-
LABEL_VISIBILITY_LABELED
226-
}
227-
else -> {
228-
LABEL_VISIBILITY_AUTO
229-
}
248+
false -> {
249+
LABEL_VISIBILITY_UNLABELED
250+
}
251+
252+
true -> {
253+
LABEL_VISIBILITY_LABELED
254+
}
255+
256+
else -> {
257+
LABEL_VISIBILITY_AUTO
258+
}
230259
}
231260
}
232261

@@ -253,13 +282,16 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
253282

254283
fun setBarTintColor(color: Int?) {
255284
// Set the color, either using the active background color or a default color.
256-
val backgroundColor = color ?: getDefaultColorFor(android.R.attr.colorPrimary) ?: return
285+
val backgroundColor =
286+
color ?: Utils.getDefaultColorFor(context, android.R.attr.colorPrimary) ?: return
257287

258288
// Apply the same color to both active and inactive states
259289
val colorDrawable = ColorDrawable(backgroundColor)
260290

261291
bottomNavigation.itemBackground = colorDrawable
262292
backgroundTintList = ColorStateList.valueOf(backgroundColor)
293+
// Set navigationBarColor for edge-to-edge.
294+
reactContext.currentActivity?.window?.navigationBarColor = backgroundColor
263295
}
264296

265297
fun setActiveTintColor(color: Int?) {
@@ -276,10 +308,6 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
276308
bottomNavigation.itemActiveIndicatorColor = color
277309
}
278310

279-
fun setHapticFeedback(enabled: Boolean) {
280-
hapticFeedbackEnabled = enabled
281-
}
282-
283311
fun setFontSize(size: Int) {
284312
fontSize = size
285313
updateTextAppearance()
@@ -296,22 +324,13 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
296324
updateTextAppearance()
297325
}
298326

299-
private fun getTypefaceStyle(weight: Int?) = when (weight) {
300-
700 -> Typeface.BOLD
301-
else -> Typeface.NORMAL
302-
}
303-
304-
fun setSelectedItemId(itemId: Int) {
305-
bottomNavigation.selectedItemId = itemId
306-
}
307-
308327
private fun updateTextAppearance() {
309328
if (fontSize != null || fontFamily != null || fontWeight != null) {
310329
val menuView = getChildAt(0) as? ViewGroup ?: return
311330
val size = fontSize?.toFloat()?.takeIf { it > 0 } ?: 12f
312331
val typeface = ReactFontManager.getInstance().getTypeface(
313332
fontFamily ?: "",
314-
getTypefaceStyle(fontWeight),
333+
Utils.getTypefaceStyle(fontWeight),
315334
context.assets
316335
)
317336

@@ -343,9 +362,13 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
343362
val currentItemTintColor = items?.find { it.title == item?.title }?.activeTintColor
344363

345364
// getDefaultColor will always return a valid color but to satisfy the compiler we need to check for null
346-
val colorPrimary = currentItemTintColor ?: activeTintColor ?: getDefaultColorFor(android.R.attr.colorPrimary) ?: return
365+
val colorPrimary = currentItemTintColor ?: activeTintColor ?: Utils.getDefaultColorFor(
366+
context,
367+
android.R.attr.colorPrimary
368+
) ?: return
347369
val colorSecondary =
348-
inactiveTintColor ?: getDefaultColorFor(android.R.attr.textColorSecondary) ?: return
370+
inactiveTintColor ?: Utils.getDefaultColorFor(context, android.R.attr.textColorSecondary)
371+
?: return
349372
val states = arrayOf(uncheckedStateSet, checkedStateSet)
350373
val colors = intArrayOf(colorSecondary, colorPrimary)
351374

@@ -355,17 +378,8 @@ class ReactBottomNavigationView(context: Context) : FrameLayout(context) {
355378
}
356379
}
357380

358-
private fun getDefaultColorFor(baseColorThemeAttr: Int): Int? {
359-
val value = TypedValue()
360-
if (!context.theme.resolveAttribute(baseColorThemeAttr, value, true)) {
361-
return null
362-
}
363-
val baseColor = AppCompatResources.getColorStateList(
364-
context, value.resourceId
365-
)
366-
return baseColor.defaultColor
381+
override fun onDetachedFromWindow() {
382+
super.onDetachedFromWindow()
383+
reactContext.currentActivity?.window?.navigationBarColor = Color.TRANSPARENT
367384
}
368385
}
369-
370-
371-

0 commit comments

Comments
 (0)