mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Derive the header's bottom edge instead of measuring it
On Android the screen came up with its own background instead of the header colour and an invisible (but typable) amount field, righting itself only once something forced a re-layout. Everything in this layout is positioned against the top of the animated area, and that value came from measureInWindow on the wrapper. On Android that call can answer 0 for a view that is laid out but not yet attached, and there is no second chance — the wrapper's own layout does not change again, so its onLayout never fires again. The fade-in that hides the first-frame snap waited on that value, so it stayed at zero, and with it the opacity of the backdrop, the amount block and the content. The header is a status-bar inset plus a fixed row, and both numbers are known on the first render, so the offset is now computed from them — no measurement, no callback, correct on frame one. HEADER_HEIGHT is exported from Header for it. The fade now waits only on the amount block's own onLayout, and even that has a 400ms backstop: the fade is what makes the screen visible, so it must never be able to stay at zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
98b50df594
commit
edfd9419e4
@@ -1,5 +1,6 @@
|
||||
import React, {ReactNode, useEffect, useRef, useState} from 'react'
|
||||
import {ColorValue, Keyboard, Pressable, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
@@ -7,6 +8,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {spacing} from '../theme'
|
||||
import {HEADER_HEIGHT} from './Header'
|
||||
import {useKeyboardTop} from '../utils/useKeyboardTop'
|
||||
|
||||
// ─── Amount-entry layout ─────────────────────────────────────────────────────
|
||||
@@ -48,6 +50,16 @@ const FOCUS_SWAP_GRACE = 120
|
||||
*/
|
||||
const ENTRY_FOCUS_TIMEOUT = 1500
|
||||
|
||||
/**
|
||||
* How long the fade-in below waits for the amount block's first onLayout.
|
||||
*
|
||||
* A backstop, not a schedule — the layout arrives within a frame or two. But the fade is
|
||||
* what makes the screen visible at all, so anything that could keep it at zero would
|
||||
* leave the user looking at a blank screen with an invisible amount field on it. Better
|
||||
* to show the amount in the wrong place for one frame than to not show it.
|
||||
*/
|
||||
const MEASUREMENT_DEADLINE = 400
|
||||
|
||||
/**
|
||||
* How far the amount block has to travel to sit in the middle of what the keyboard leaves
|
||||
* visible. All three inputs are in the same space: `wrapperTop` and `keyboardTop` are
|
||||
@@ -99,34 +111,42 @@ export function useAmountEntry({
|
||||
|
||||
const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hasEverFocused = useRef<boolean>(false)
|
||||
const wrapperRef = useRef<View>(null)
|
||||
|
||||
/**
|
||||
* Window Y of the animated area, i.e. the header's bottom edge.
|
||||
*
|
||||
* Derived rather than measured. The header is a status-bar inset plus a fixed row, and
|
||||
* both numbers are known on the first render — whereas measureInWindow answers a frame
|
||||
* late on iOS and, on Android, can answer 0 for a view that is laid out but not yet
|
||||
* attached, with no second chance until something else forces a re-layout. Everything
|
||||
* here is positioned against this value, so "late or never" is not a good enough
|
||||
* guarantee: it left the screen blank.
|
||||
*/
|
||||
const insets = useSafeAreaInsets()
|
||||
const wrapperTop = useSharedValue(insets.top + HEADER_HEIGHT)
|
||||
|
||||
/** 0 = settled layout, 1 = amount fills the screen. */
|
||||
const entryProgress = useSharedValue(initiallyExpanded ? 1 : 0)
|
||||
/** Window Y of the keyboard's top edge — the bottom of the area we centre in. */
|
||||
const keyboardTop = useSharedValue(0)
|
||||
/** Window Y of the animated area (i.e. just below the screen's header). */
|
||||
const wrapperTop = useSharedValue(0)
|
||||
/**
|
||||
* Centre of the whole visible amount block — amount, converted value, swap hint and
|
||||
* any caption — relative to the top of the animated area.
|
||||
*/
|
||||
const amountCentre = useSharedValue(0)
|
||||
/**
|
||||
* 0 until the geometry above is known.
|
||||
* 0 until the amount block has been measured.
|
||||
*
|
||||
* onLayout and measureInWindow both report a frame AFTER the one they describe, so the
|
||||
* screen's first paint would place the amount in the settled position and then snap it
|
||||
* to the centre. Fading in over that gap costs nothing on a screen that is being pushed
|
||||
* in anyway, and removes the snap.
|
||||
* onLayout reports a frame AFTER the one it describes, so the screen's first paint would
|
||||
* place the amount in the settled position and then snap it to the centre. Fading in
|
||||
* over that gap costs nothing on a screen that is being pushed in anyway, and removes
|
||||
* the snap.
|
||||
*/
|
||||
const isMeasured = useSharedValue(0)
|
||||
|
||||
const settleMeasurement = function () {
|
||||
if (isMeasured.value !== 0) return
|
||||
if (wrapperTop.value > 0 && amountCentre.value > 0) {
|
||||
isMeasured.value = withTiming(1, {duration: 150})
|
||||
}
|
||||
isMeasured.value = withTiming(1, {duration: 150})
|
||||
}
|
||||
|
||||
useKeyboardTop((top, duration) => {
|
||||
@@ -137,6 +157,17 @@ export function useAmountEntry({
|
||||
keyboardTop.value = duration > 0 ? withTiming(top, {duration}) : top
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
wrapperTop.value = insets.top + HEADER_HEIGHT
|
||||
}, [insets.top, wrapperTop])
|
||||
|
||||
// See MEASUREMENT_DEADLINE: the fade is what makes the screen visible, so it is never
|
||||
// allowed to depend solely on a layout callback arriving.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(settleMeasurement, MEASUREMENT_DEADLINE)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
entryProgress.value = withTiming(isAmountEntry ? 1 : 0, {
|
||||
duration: ENTRY_ANIMATION_DURATION,
|
||||
@@ -213,14 +244,6 @@ export function useAmountEntry({
|
||||
}
|
||||
})
|
||||
|
||||
const onWrapperLayout = function () {
|
||||
// Window coordinates, because that is the space the keyboard reports itself in.
|
||||
wrapperRef.current?.measureInWindow((_x, y) => {
|
||||
if (y > 0) wrapperTop.value = y
|
||||
settleMeasurement()
|
||||
})
|
||||
}
|
||||
|
||||
const onAmountBlockLayout = function (y: number, height: number) {
|
||||
// The backdrop is absolute, so this block's offset within the header band is also its
|
||||
// offset within the animated area.
|
||||
@@ -244,8 +267,6 @@ export function useAmountEntry({
|
||||
},
|
||||
/** Consumed by <AmountEntryLayout>. */
|
||||
layout: {
|
||||
wrapperRef,
|
||||
onWrapperLayout,
|
||||
onAmountBlockLayout,
|
||||
$animatedAmountStyle,
|
||||
$animatedContentStyle,
|
||||
@@ -275,11 +296,7 @@ export function AmountEntryLayout(props: AmountEntryLayoutProps) {
|
||||
const {isAmountEntry, layout} = entry
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={layout.wrapperRef}
|
||||
style={$animationWrapper}
|
||||
onLayout={layout.onWrapperLayout}
|
||||
>
|
||||
<View style={$animationWrapper}>
|
||||
{/* The header colour as a layer of its own, so it can grow to cover everything the
|
||||
keyboard leaves visible without the amount block's position depending on how tall
|
||||
it currently is. */}
|
||||
|
||||
@@ -324,8 +324,17 @@ function HeaderAction(props: HeaderActionProps) {
|
||||
return <View style={[$actionFillerContainer, { backgroundColor }]} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of the header's own row, below the status-bar inset.
|
||||
*
|
||||
* Exported because a screen that positions content against the header's bottom edge can
|
||||
* work it out from this plus the top inset, rather than measuring the header at runtime —
|
||||
* a measurement that is a frame late at best.
|
||||
*/
|
||||
export const HEADER_HEIGHT = 56
|
||||
|
||||
const $wrapper: ViewStyle = {
|
||||
height: 56,
|
||||
height: HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
|
||||
Reference in New Issue
Block a user