feat(mobile): implement custom tab bar and item pressable components on android native (#3613)

* feat(mobile): implement custom tab bar and item pressable components on android native

Adds native Android implementations for custom UI components previously available on iOS:

- **Custom Tab Bar:**
    - Implements `TabBarRootView` using `ViewPager2` and a custom `CustomTabBarView` (LinearLayout) to host RN-rendered tab items (`TabBarPortalView`).
    - Provides `TabScreenView` as a container for page content within Fragments managed by `TabBarPagerAdapter`.
    - Includes `TabBarModule`, `TabScreenModule`, and `TabBarPortalModule` for Expo autolinking and JS bridging (props: `selectedIndex`, event: `onTabIndexChange`, function: `switchTab`).
    - This approach mirrors the iOS version by avoiding the native `TabLayout` and allowing full customization of the tab bar via React Native components.

- **Item Pressable:**
    - Implements `ItemPressableView` extending `ExpoView`.
    * Provides native touch feedback using Android's foreground ripple (`selectableItemBackground`) by default.
    * Includes a prop `touchHighlight` to enable/disable feedback.
    * Fires `onItemPress` event on click/tap.
    * Includes `ItemPressableModule` for Expo integration.

* fix(mobile): Adjust content view `padding` to prevent overlap with Bottom Bar

* feat(mobile): implement native tab bar components and improve tab switching logic
This commit is contained in:
grtsinry43 2025-05-06 16:00:03 +08:00 committed by GitHub
parent 9873e9af0f
commit ad3ac4926a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 552 additions and 2 deletions

View File

@ -28,6 +28,11 @@ if (useManagedAndroidSdkVersions) {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
dependencies {
implementation "androidx.fragment:fragment:1.8.6"
implementation "androidx.viewpager2:viewpager2:1.1.0"
}
}
}

View File

@ -0,0 +1,18 @@
package expo.modules.follownative.itempressable
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ItemPressableModule : Module() {
override fun definition() = ModuleDefinition {
Name("ItemPressable")
View(ItemPressableView::class) {
Prop("touchHighlight") { view: ItemPressableView, enabled: Boolean ->
view.setTouchHighlight(enabled)
}
Events("onItemPress")
}
}
}

View File

@ -0,0 +1,86 @@
package expo.modules.follownative.itempressable
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.os.Build
import android.util.TypedValue
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import androidx.core.view.isNotEmpty
class ItemPressableView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
val onItemPress by EventDispatcher()
private var touchHighlightEnabled: Boolean = true
private var originalBackground: Drawable? = null
private var isPressedDown = false
private var rippleDrawable: RippleDrawable? = null
private val highlightColor = Color.argb(40, 0, 0, 0)
init {
isClickable = true
isFocusable = true
originalBackground = background
setupRipple()
}
fun setTouchHighlight(enabled: Boolean) {
touchHighlightEnabled = enabled
if (!enabled) {
foreground = null
} else {
setupRipple()
}
}
private fun setupRipple() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && touchHighlightEnabled) {
val typedValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, typedValue, true)
foreground = ContextCompat.getDrawable(context, typedValue.resourceId)
}
}
override fun performClick(): Boolean {
super.performClick() // Handles accessibility events, sound effects etc.
onItemPress(emptyMap()) // Dispatch the event (with no arg) to React Native
return true
}
init {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (isNotEmpty()) {
val child = getChildAt(0)
measureChild(child, widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(child.measuredWidth, child.measuredHeight)
} else {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (isNotEmpty()) {
val child = getChildAt(0)
// Layout the child to fill this container
child.layout(0, 0, r - l, b - t)
}
}
}

View File

@ -0,0 +1,25 @@
package expo.modules.follownative.tabbar
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.launch
class TabBarModule : Module() {
override fun definition() = ModuleDefinition {
Name("TabBarRoot")
View(TabBarRootView::class) {
Prop("selectedIndex") { view: TabBarRootView, index: Int ->
view.setSelectedIndex(index)
}
Events("onTabIndexChange")
}
AsyncFunction("switchTab") { view: TabBarRootView, index: Int ->
appContext.mainQueue.launch {
view.setSelectedIndex(index)
}
}
}
}

View File

@ -0,0 +1,14 @@
package expo.modules.follownative.tabbar
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class TabBarPortalModule : Module() {
override fun definition() = ModuleDefinition {
Name("TabBarPortal")
View(TabBarPortalView::class) {
}
}
}

View File

@ -0,0 +1,10 @@
package expo.modules.follownative.tabbar
import android.content.Context
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import androidx.core.view.isNotEmpty
class TabBarPortalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
}

View File

@ -0,0 +1,130 @@
package expo.modules.follownative.tabbar
import android.content.Context
import android.util.Log
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class TabBarRootView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
override val shouldUseAndroidLayout: Boolean = true
private val container = FrameLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
internal val viewPager: ViewPager2 = ViewPager2(context).apply {
id = View.generateViewId()
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
offscreenPageLimit = 1
isUserInputEnabled = false
}
private val tabFragments = mutableListOf<TabScreenFragment>()
private var pagerAdapter: FragmentStateAdapter? = null
private val onTabIndexChange by EventDispatcher<Map<String, Int>>()
init {
super.addView(container)
container.addView(viewPager)
setupAdapter()
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
onTabIndexChange(mapOf("index" to position))
}
})
}
private fun setupAdapter() {
val activity = appContext.activityProvider?.currentActivity as? FragmentActivity
if (activity != null) {
pagerAdapter = object : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = tabFragments.size
override fun createFragment(position: Int): Fragment = tabFragments[position]
}
viewPager.adapter = pagerAdapter
} else {
Log.e("TabBarRootView", "FragmentActivity is null. Cannot set up ViewPager2 adapter.")
}
}
fun addTabScreenView(screenView: TabScreenView, index: Int) {
val fragment = TabScreenFragment.newInstance(screenView)
if (index >= tabFragments.size) {
tabFragments.add(fragment)
} else {
tabFragments.add(index, fragment)
}
pagerAdapter?.notifyItemInserted(index)
}
fun removeTabScreenView(screenView: TabScreenView) {
val index = tabFragments.indexOfFirst { it.containsView(screenView) }
if (index != -1) {
tabFragments.removeAt(index)
pagerAdapter?.notifyItemRemoved(index)
}
}
fun addPortalView(portalView: TabBarPortalView) {
val layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
(64 * resources.displayMetrics.density).toInt()
).apply {
gravity = Gravity.BOTTOM
}
portalView.layoutParams = layoutParams
container.addView(portalView)
viewPager.layoutParams = (viewPager.layoutParams as FrameLayout.LayoutParams).apply {
bottomMargin = (64 * resources.displayMetrics.density).toInt()
}
}
fun removePortalView(portalView: TabBarPortalView) {
container.removeView(portalView)
}
fun setSelectedIndex(index: Int) {
if (index >= 0 && index < tabFragments.size) {
viewPager.setCurrentItem(index, false)
}
}
override fun addView(child: View?, index: Int) {
if (child is TabScreenView) {
addTabScreenView(child, index)
} else if (child is TabBarPortalView) {
addPortalView(child)
} else {
super.addView(child, index)
}
}
override fun removeView(child: View?) {
if (child is TabScreenView) {
removeTabScreenView(child)
} else if (child is TabBarPortalView) {
removePortalView(child)
} else {
super.removeView(child)
}
}
}

View File

@ -0,0 +1,33 @@
package expo.modules.follownative.tabbar
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
class TabScreenFragment : Fragment() {
private var screenView: TabScreenView? = null
override fun onCreateView(inflater: android.view.LayoutInflater, container: android.view.ViewGroup?, savedInstanceState: android.os.Bundle?): View? {
if (screenView?.parent != null) {
(screenView?.parent as? android.view.ViewGroup)?.removeView(screenView)
}
return screenView
}
fun containsView(view: TabScreenView): Boolean {
return screenView == view
}
companion object {
fun newInstance(view: TabScreenView): TabScreenFragment {
val fragment = TabScreenFragment()
fragment.screenView = view
return fragment
}
}
}

View File

@ -0,0 +1,14 @@
package expo.modules.follownative.tabbar
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class TabScreenModule : Module() {
override fun definition() = ModuleDefinition {
Name("TabScreen")
View(TabScreenView::class) {
}
}
}

View File

@ -0,0 +1,10 @@
package expo.modules.follownative.tabbar
import android.content.Context
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
class TabScreenView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
}

View File

@ -17,6 +17,11 @@
]
},
"android": {
"modules": []
"modules": [
"expo.modules.follownative.tabbar.TabBarModule",
"expo.modules.follownative.tabbar.TabScreenModule",
"expo.modules.follownative.tabbar.TabBarPortalModule",
"expo.modules.follownative.itempressable.ItemPressableModule"
]
}
}

View File

@ -0,0 +1,35 @@
import { requireNativeView } from "expo"
import { useSetAtom } from "jotai"
import { useContext } from "react"
import type { ViewProps } from "react-native"
import { StyleSheet, View } from "react-native"
import { BottomTabContext } from "./BottomTabContext"
const TabBarPortalNative = requireNativeView<ViewProps>("TabBarPortal")
export const TabBarPortal = ({ children }: { children: React.ReactNode }) => {
const { tabHeightAtom } = useContext(BottomTabContext)
const setTabHeight = useSetAtom(tabHeightAtom)
return (
// Use native view
<TabBarPortalNative style={styles.container}>
<View
onLayout={(e) => {
setTabHeight(e.nativeEvent.layout.height)
}}
>
{children}
</View>
</TabBarPortalNative>
)
}
const styles = StyleSheet.create({
container: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
})

View File

@ -0,0 +1,49 @@
import { requireNativeView } from "expo"
import { useAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import * as React from "react"
import { useCallback, useContext, useMemo } from "react"
import type { NativeSyntheticEvent, ViewProps } from "react-native"
import { StyleSheet } from "react-native"
import { BottomTabContext } from "./BottomTabContext"
import { TabScreen } from "./TabScreen"
const TabBarRoot = requireNativeView<
{
onTabIndexChange: (e: NativeSyntheticEvent<{ index: number }>) => void
selectedIndex: number
} & ViewProps
>("TabBarRoot")
export const TabRoot: FC<PropsWithChildren> = ({ children }) => {
const { currentIndexAtom } = useContext(BottomTabContext)
const [tabIndex, setTabIndex] = useAtom(currentIndexAtom)
const MapChildren = useMemo(() => {
let cnt = 0
return React.Children.map(children, (child) => {
if (typeof child === "object" && child && "type" in child && child.type === TabScreen) {
return React.cloneElement(child, {
tabScreenIndex: cnt++,
})
}
return child
})
}, [children])
return (
<TabBarRoot
style={StyleSheet.absoluteFill}
onTabIndexChange={useCallback(
(e) => {
setTabIndex(e.nativeEvent.index)
},
[setTabIndex],
)}
selectedIndex={tabIndex}
>
{MapChildren}
</TabBarRoot>
)
}

View File

@ -0,0 +1,116 @@
import { requireNativeView } from "expo"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect, useMemo } from "react"
import type { ViewProps } from "react-native"
import { StyleSheet } from "react-native"
import { WrappedScreenItem } from "../WrappedScreenItem"
import { BottomTabContext } from "./BottomTabContext"
import { LifecycleEvents, ScreenNameRegister } from "./shared"
import type { TabScreenContextType } from "./TabScreenContext"
import { TabScreenContext } from "./TabScreenContext"
import type { TabScreenComponent, TabScreenProps } from "./types"
const TabScreenNative = requireNativeView<ViewProps>("TabScreen")
export const TabScreen: FC<PropsWithChildren<Omit<TabScreenProps, "tabScreenIndex">>> = ({
children,
identifier,
...props
}) => {
const { tabScreenIndex } = props as any as TabScreenProps
const {
loadedableIndexAtom,
currentIndexAtom,
tabScreensAtom: tabScreens,
} = useContext(BottomTabContext)
const setTabScreens = useSetAtom(tabScreens)
const mergedProps = useMemo(() => {
const propsFromChildren: Partial<TabScreenProps> = {}
if (children && typeof children === "object") {
const childType = (children as any).type as TabScreenComponent
if ("tabBarIcon" in childType) {
propsFromChildren.renderIcon = childType.tabBarIcon
}
if ("title" in childType) {
propsFromChildren.title = childType.title
}
if ("lazy" in childType) {
propsFromChildren.lazy = childType.lazy
}
if ("identifier" in childType && typeof childType.identifier === "string") {
propsFromChildren.identifier = childType.identifier
}
}
return {
...propsFromChildren,
...props,
identifier: identifier ?? propsFromChildren.identifier,
}
}, [children, props, identifier])
useEffect(() => {
setTabScreens((prev) => [
...prev,
{
...mergedProps,
tabScreenIndex,
},
])
return () => {
setTabScreens((prev) =>
prev.filter((tabScreen) => tabScreen.tabScreenIndex !== tabScreenIndex),
)
}
}, [mergedProps, setTabScreens, tabScreenIndex])
const currentSelectedIndex = useAtomValue(currentIndexAtom)
const isSelected = useMemo(
() => currentSelectedIndex === tabScreenIndex,
[currentSelectedIndex, tabScreenIndex],
)
const [loadedableIndexSet, setLoadedableIndex] = useAtom(loadedableIndexAtom)
const isLoadedBefore = loadedableIndexSet.has(tabScreenIndex)
useEffect(() => {
if (isSelected) {
setLoadedableIndex((prev) => {
prev.add(tabScreenIndex)
return new Set(prev)
})
}
}, [setLoadedableIndex, tabScreenIndex, isSelected])
const ctxValue = useMemo<TabScreenContextType>(
() => ({
tabScreenIndex,
identifierAtom: atom(mergedProps.identifier ?? ""),
titleAtom: atom(mergedProps.title),
}),
[tabScreenIndex, mergedProps.title, mergedProps.identifier],
)
const shouldLoadReact = mergedProps.lazy ? isSelected || isLoadedBefore : true
const render = __DEV__ ? isSelected : true
return (
<TabScreenNative style={StyleSheet.absoluteFill}>
<TabScreenContext.Provider value={ctxValue}>
{shouldLoadReact && render && (
<WrappedScreenItem screenId={`tab-screen-${tabScreenIndex}`}>
{children}
<ScreenNameRegister />
<LifecycleEvents isSelected={isSelected} />
</WrappedScreenItem>
)}
</TabScreenContext.Provider>
</TabScreenNative>
)
}

View File

@ -35,7 +35,7 @@ export function Settings() {
ref={scrollViewRef}
style={{ paddingTop: insets.top }}
className="bg-system-grouped-background flex-1"
contentViewClassName="-mt-24"
contentViewClassName="-mt-24 pb-36"
>
<UserHeaderBanner scrollY={screenContext.reAnimatedScrollY} userId={whoami?.id} />