From 714a3bc3d031e7cdda4d26613df0209db83ba931 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 15 Jul 2026 15:15:03 +0200 Subject: [PATCH] Navigation display logic --- src/components/Screen.tsx | 21 +++++++- src/navigation/FloatingTabBar.tsx | 47 ++++++++++++++--- src/navigation/tabBarVisibility.ts | 62 +++++++++++++++++++---- src/screens/CashuPaymentRequestScreen.tsx | 5 +- src/screens/OnchainTransferScreen.tsx | 2 +- src/screens/ScanScreen.tsx | 4 +- src/screens/SendScreen.tsx | 7 ++- src/screens/TopupScreen.tsx | 5 +- src/screens/TransferScreen.tsx | 10 +++- 9 files changed, 133 insertions(+), 30 deletions(-) diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 4eb52b30..80c718d8 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -17,7 +17,7 @@ import { } from "react-native" import { useThemeColor, spacing } from "../theme" -import { useTabBarInset } from "../navigation/tabBarVisibility" +import { useHideTabBar, useTabBarInset } from "../navigation/tabBarVisibility" import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle" interface BaseScreenProps { @@ -47,6 +47,12 @@ interface BaseScreenProps { * by `useTabBarInset()` so the last item stays reachable. */ contentUnderTabBar?: boolean + /** + * Hide the floating tab bar while this screen is focused, reclaiming its space. + * The reserved bottom room collapses to the safe-area inset. Use case-by-case, + * typically for a non-scrolling screen that wants the full height. + */ + hideTabBar?: boolean /** * By how much should we offset the keyboard? Defaults to 0. */ @@ -194,6 +200,15 @@ function ScreenWithScrolling(props: ScreenProps) { +/** + * Engages the tab-bar hide while mounted. Kept as a child so the useFocusEffect it + * relies on is only invoked when a screen actually opts in. + */ +function TabBarHider() { + useHideTabBar() + return null +} + export function Screen(props: ScreenProps) { const { backgroundColor = useThemeColor('background'), @@ -201,6 +216,7 @@ export function Screen(props: ScreenProps) { keyboardOffset = 0, safeAreaEdges, contentUnderTabBar = false, + hideTabBar = false, } = props const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges) @@ -209,6 +225,9 @@ export function Screen(props: ScreenProps) { return ( + {/* Rendered only when opted in, so screens outside a navigator (error, loading) + never call useFocusEffect and hit "Couldn't find navigation object". */} + {hideTabBar && } { + revealTabBar() + }, [focusKey]) const gotoScan = () => { // ScanScreen requires a unit. WalletScreen restores its mint-unit tab from @@ -128,6 +149,17 @@ export function FloatingTabBar(props: BottomTabBarProps) { /** What screens reserve: the bar's footprint plus the gap above it. */ const occupiedHeight = travel + TAB_BAR_TOP_GAP + // A screen can hide the bar entirely while focused. It then reserves only the + // safe-area inset instead of the bar's footprint, so it reclaims the room. + const forcedHidden = useIsTabBarForcedHidden() + const forcedProgress = useSharedValue(0) + + useEffect(() => { + forcedProgress.value = withTiming(forcedHidden ? 1 : 0, { + duration: HIDE_DURATION, + }) + }, [forcedHidden, forcedProgress]) + // The bar takes no part in keyboard avoidance. It is absolutely positioned at the // bottom of a window that the IME overlays rather than resizes (Android runs // edge-to-edge; iOS avoids the keyboard inside `Screen`), so the keyboard simply @@ -135,13 +167,14 @@ export function FloatingTabBar(props: BottomTabBarProps) { // since `keyboardDidHide` is not reliably emitted when the window never resizes. useEffect(() => { if (barHeight === 0) return - setTabBarHeight?.(occupiedHeight) - }, [setTabBarHeight, occupiedHeight, barHeight]) + setTabBarHeight?.(forcedHidden ? insets.bottom : occupiedHeight) + }, [setTabBarHeight, forcedHidden, insets.bottom, occupiedHeight, barHeight]) - // Parks the bar fully below the screen edge. + // Parks the bar fully below the screen edge — from scroll, or a forced hide. const $animatedBar = useAnimatedStyle(() => { + const progress = Math.max(tabBarHiddenProgress.value, forcedProgress.value) return { - transform: [{ translateY: tabBarHiddenProgress.value * travel }], + transform: [{ translateY: progress * travel }], } }, [travel]) diff --git a/src/navigation/tabBarVisibility.ts b/src/navigation/tabBarVisibility.ts index a730beee..a8ad4815 100644 --- a/src/navigation/tabBarVisibility.ts +++ b/src/navigation/tabBarVisibility.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext } from 'react' +import { useCallback, useContext, useSyncExternalStore } from 'react' import { useFocusEffect } from '@react-navigation/native' import { BottomTabBarHeightContext } from '@react-navigation/bottom-tabs' import { @@ -37,6 +37,56 @@ export function showTabBar() { tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION }) } +/** + * Force the bar back into view from the JS thread, regardless of its current + * state. Called on every focus change so that navigating — including Back to a + * screen with no scroll handler — always brings the bar back. + */ +export function revealTabBar() { + isHidden.value = false + tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION }) +} + +// Screens can force the bar out of view entirely while focused (e.g. to give a +// non-scrolling screen the full height). A count rather than a boolean keeps the +// state correct when a focus transition briefly overlaps two hiding screens. +let forcedHiddenCount = 0 +const forcedHiddenListeners = new Set<() => void>() + +function setForcedHidden(delta: number) { + forcedHiddenCount = Math.max(0, forcedHiddenCount + delta) + forcedHiddenListeners.forEach(listener => listener()) +} + +/** Reactive read of whether any focused screen is forcing the bar hidden. */ +export function useIsTabBarForcedHidden(): boolean { + return useSyncExternalStore( + listener => { + forcedHiddenListeners.add(listener) + return () => forcedHiddenListeners.delete(listener) + }, + () => forcedHiddenCount > 0, + ) +} + +/** + * Hide the floating tab bar for as long as the calling screen is focused, and + * restore it on blur. Use case-by-case, for screens that want the full height. + * While hidden, the bar reports only the safe-area inset as its height, so screens + * reserve just that instead of the bar's footprint — see `useTabBarInset`. + * + * `enabled` may be toggled; the hook is always called so the rules of hooks hold. + */ +export function useHideTabBar(enabled = true) { + useFocusEffect( + useCallback(() => { + if (!enabled) return + setForcedHidden(1) + return () => setForcedHidden(-1) + }, [enabled]), + ) +} + /** * Vertical space the floating tab bar occupies, including its bottom offset. * Returns 0 outside of the tabs navigator, and while the keyboard hides the bar. @@ -56,14 +106,8 @@ export function useTabBarInset(): number { * Pass `scrollY` to also track the offset, e.g. to drive an `AnimatedHeader`. */ export function useTabBarScrollHandler(scrollY?: SharedValue) { - useFocusEffect( - useCallback(() => { - // A screen can be blurred while scrolled down, which leaves the bar parked. - isHidden.value = false - tabBarHiddenProgress.value = withTiming(0, { duration: SHOW_DURATION }) - }, []), - ) - + // Revealing on focus change is handled centrally in FloatingTabBar, so a screen + // blurred mid-scroll doesn't leave the bar parked — see revealTabBar. return useAnimatedScrollHandler( { onScroll: event => { diff --git a/src/screens/CashuPaymentRequestScreen.tsx b/src/screens/CashuPaymentRequestScreen.tsx index 974f5dd7..83ed1520 100644 --- a/src/screens/CashuPaymentRequestScreen.tsx +++ b/src/screens/CashuPaymentRequestScreen.tsx @@ -334,7 +334,8 @@ const onMintBalanceSelect = (balance: MintBalance) => { } const onMintBalanceCancel = () => { - dispatch({ type: 'HIDE_MINT_SELECTOR' }) + return gotoWallet() + //dispatch({ type: 'HIDE_MINT_SELECTOR' }) } const onMintBalanceConfirm = async () => { @@ -412,7 +413,7 @@ const inputText = useThemeColor("text") return ( - + 1 return ( - + diff --git a/src/screens/ScanScreen.tsx b/src/screens/ScanScreen.tsx index 74a2bb68..c6de3721 100644 --- a/src/screens/ScanScreen.tsx +++ b/src/screens/ScanScreen.tsx @@ -297,7 +297,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) { } return ( - +
0 ? `Progress ${urDecoderProgress} %`: 'Scan QR code'} titleStyle={{fontFamily: typography.primary?.medium}} @@ -355,7 +355,7 @@ const $addressInput: TextStyle = { const $bottomContainer: ViewStyle = { position: 'absolute', - bottom: 0, + bottom: spacing.extraLarge * 3, left: 0, right: 0, flex: 1, diff --git a/src/screens/SendScreen.tsx b/src/screens/SendScreen.tsx index 66f115ee..f55fc32a 100644 --- a/src/screens/SendScreen.tsx +++ b/src/screens/SendScreen.tsx @@ -1180,9 +1180,8 @@ export const SendScreen = observer(function SendScreen({ route }: Props) { } - const onMintBalanceCancel = async function () { - resetState() - gotoWallet() + const onMintBalanceCancel = async function () { + return gotoWallet() } /* @@ -1481,7 +1480,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) { return ( - + + + mint.supportsMelt!('bolt11', unitRef.current)} unsupportedReason={translate('mintSelector_noPayoutSupport')} onMintBalanceSelect={onMintBalanceSelect} - onCancel={gotoWallet} + onCancel={onMintBalanceCancel} onMintBalanceConfirm={transfer} /> )}