diff --git a/apps/mobile/native/android/build.gradle b/apps/mobile/native/android/build.gradle index f32d44e31..951f16632 100644 --- a/apps/mobile/native/android/build.gradle +++ b/apps/mobile/native/android/build.gradle @@ -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" + } } } diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableModule.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableModule.kt new file mode 100644 index 000000000..7d2da30e7 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableModule.kt @@ -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") + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableView.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableView.kt new file mode 100644 index 000000000..b928964c5 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/itempressable/ItemPressableView.kt @@ -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) + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarModule.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarModule.kt new file mode 100644 index 000000000..3888f5e3f --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarModule.kt @@ -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) + } + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalModule.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalModule.kt new file mode 100644 index 000000000..273bc53be --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalModule.kt @@ -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) { + + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalView.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalView.kt new file mode 100644 index 000000000..78eabe167 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarPortalView.kt @@ -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) { + +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarRootView.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarRootView.kt new file mode 100644 index 000000000..9bf946c87 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabBarRootView.kt @@ -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() + private var pagerAdapter: FragmentStateAdapter? = null + private val onTabIndexChange by EventDispatcher>() + + 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) + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenFragment.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenFragment.kt new file mode 100644 index 000000000..8192de657 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenFragment.kt @@ -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 + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenModule.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenModule.kt new file mode 100644 index 000000000..05f8f8969 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenModule.kt @@ -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) { + + } + } +} diff --git a/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenView.kt b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenView.kt new file mode 100644 index 000000000..90be74f40 --- /dev/null +++ b/apps/mobile/native/android/src/main/java/expo/modules/follownative/tabbar/TabScreenView.kt @@ -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) { + +} diff --git a/apps/mobile/native/expo-module.config.json b/apps/mobile/native/expo-module.config.json index 5806f648b..11564a300 100644 --- a/apps/mobile/native/expo-module.config.json +++ b/apps/mobile/native/expo-module.config.json @@ -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" + ] } } diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.android.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.android.tsx new file mode 100644 index 000000000..c8f8ad7ea --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabBarPortal.android.tsx @@ -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("TabBarPortal") + +export const TabBarPortal = ({ children }: { children: React.ReactNode }) => { + const { tabHeightAtom } = useContext(BottomTabContext) + const setTabHeight = useSetAtom(tabHeightAtom) + return ( + // Use native view + + { + setTabHeight(e.nativeEvent.layout.height) + }} + > + {children} + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + }, +}) diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.android.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.android.tsx new file mode 100644 index 000000000..c8bd0f5f7 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabRoot.android.tsx @@ -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 = ({ 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 ( + { + setTabIndex(e.nativeEvent.index) + }, + [setTabIndex], + )} + selectedIndex={tabIndex} + > + {MapChildren} + + ) +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.android.tsx b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.android.tsx new file mode 100644 index 000000000..fc0bd2532 --- /dev/null +++ b/apps/mobile/src/lib/navigation/bottom-tab/TabScreen.android.tsx @@ -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("TabScreen") + +export const TabScreen: FC>> = ({ + 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 = {} + 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( + () => ({ + 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 ( + + + {shouldLoadReact && render && ( + + {children} + + + + )} + + + ) +} diff --git a/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx b/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx index 443e71b5f..3d3ba4bfd 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx @@ -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" >