Navigation display logic

This commit is contained in:
minibits-cash
2026-07-15 15:15:03 +02:00
parent d92a89f7ce
commit 714a3bc3d0
9 changed files with 133 additions and 30 deletions
+20 -1
View File
@@ -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 (
<View style={[$containerStyle, { backgroundColor, paddingBottom }, $containerInsets]}>
{/* Rendered only when opted in, so screens outside a navigator (error, loading)
never call useFocusEffect and hit "Couldn't find navigation object". */}
{hideTabBar && <TabBarHider />}
<KeyboardAvoidingView
behavior={isIos ? "padding" : undefined}
keyboardVerticalOffset={keyboardOffset}
+40 -7
View File
@@ -1,6 +1,10 @@
import React, { useContext, useEffect, useState } from 'react'
import { LayoutChangeEvent, View, ViewStyle } from 'react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
BottomTabBarHeightCallbackContext,
type BottomTabBarProps,
@@ -13,7 +17,14 @@ import { ScanIcon } from '../components/ScanIcon'
import { translate } from '../i18n'
import { useStores } from '../models'
import { spacing, useThemeColor } from '../theme'
import { tabBarHiddenProgress } from './tabBarVisibility'
import {
revealTabBar,
tabBarHiddenProgress,
useIsTabBarForcedHidden,
} from './tabBarVisibility'
/** Matches the forced-hide fade to the scroll-hide timing in tabBarVisibility. */
const HIDE_DURATION = 200
/** Slightly smaller than the spacing.large icons the bar used when it was full width. */
export const TAB_ICON_SIZE = verticalScale(21)
@@ -55,7 +66,17 @@ export function FloatingTabBar(props: BottomTabBarProps) {
// Scan is a screen inside WalletStack, so the tab state only reports that
// WalletNavigator is focused. Look one level down for the screen actually shown.
const focusedRoute = state.routes[state.index]
const isScanFocused = getFocusedRouteNameFromRoute(focusedRoute) === 'Scan'
const focusedNestedName = getFocusedRouteNameFromRoute(focusedRoute)
const isScanFocused = focusedNestedName === 'Scan'
// Any navigation — tab switch, push, or Back — reveals the bar, so a screen left
// scrolled down never returns to a screen that still has it parked off-screen.
// The tab bar re-renders on every navigation, so this key tracks the visible
// screen across both the tab state and the nested stack.
const focusKey = `${focusedRoute.key}:${focusedNestedName ?? ''}`
useEffect(() => {
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])
+53 -9
View File
@@ -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<number>) {
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 => {
+3 -2
View File
@@ -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 (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToReceiveTo
+1 -1
View File
@@ -646,7 +646,7 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro
const isFeePickable = feeOptions.length > 1
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader mint={selectedMint} unit={unitRef.current} />
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<View style={$amountContainer}>
+2 -2
View File
@@ -297,7 +297,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
}
return (
<Screen contentContainerStyle={$screen}>
<Screen contentContainerStyle={$screen} contentUnderTabBar>
<Header
title={urDecoderProgress > 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,
+3 -4
View File
@@ -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 (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={mintBalanceToSendFrom ? mintsStore.findByUrl(mintBalanceToSendFrom?.mintUrl) : undefined}
unit={unitRef.current}
+3 -2
View File
@@ -762,7 +762,8 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
}
const onMintBalanceCancel = async function () {
dispatch({ type: 'HIDE_MINT_SELECTOR' })
return gotoWallet()
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
}
const sendAsNostrDM = async function () {
@@ -951,7 +952,7 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToTopup
+8 -2
View File
@@ -603,6 +603,12 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
}
const onMintBalanceCancel = async function () {
return gotoWallet()
//dispatch({ type: 'HIDE_MINT_SELECTOR' })
}
const gotoWallet = function() {
resetState()
navigation.dispatch(
@@ -927,7 +933,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Screen preset="fixed" contentContainerStyle={$screen} hideTabBar>
<MintHeader
mint={
mintBalanceToTransferFrom
@@ -1054,7 +1060,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
requiredCapability={mint => mint.supportsMelt!('bolt11', unitRef.current)}
unsupportedReason={translate('mintSelector_noPayoutSupport')}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={gotoWallet}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={transfer}
/>
)}