mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Navigation display logic
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user