Fill the header colour to the screen bottom until the keyboard is real

The colour reached only as far as the ASSUMED keyboard height, so for the few
hundred milliseconds before the keyboard opened it stopped mid-screen and drew a
hard line across it.

"Where the colour reaches" and "where the amount is centred" are now separate
values, because they want different answers to the same uncertainty. The amount
is placed once, against the estimate, for the layout it is about to be in —
moving it as the keyboard arrives would shift it under the user. The colour has
no reason to stop at a keyboard that is not there yet, so it fills the screen and
closes up to the keyboard's edge as the keyboard rises, in step with it.

useKeyboardTop now says which of the two it is reporting: the value it delivers
on mount is a remembered or assumed height, not a keyboard on screen, and
closing the colour against that was the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-09-07 23:32:15 +02:00
co-authored by Claude Opus 5
parent edfd9419e4
commit a0ab95ac07
3 changed files with 47 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.4.3-beta.20",
"version": "0.4.3-beta.21",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
+29 -5
View File
@@ -127,8 +127,23 @@ export function useAmountEntry({
/** 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. */
/**
* Best estimate of where the keyboard's top edge will be — the bottom of the area the
* amount is centred in. Starts as an assumption (or what the last keyboard of the
* session measured) so the amount is placed once, for the layout it is about to be in,
* rather than moving under the user as the keyboard arrives.
*/
const keyboardTop = useSharedValue(0)
/**
* How far down the header colour reaches.
*
* The same as `keyboardTop` once a keyboard has actually been seen, but the screen
* BOTTOM before that — the colour has no reason to stop at a keyboard that is not there
* yet, and stopping short of one drew a hard line across the middle of the screen for
* the few hundred milliseconds before the keyboard opened. It closes up to the
* keyboard's edge as the keyboard rises, in step with it.
*/
const visibleBottom = useSharedValue(spacing.screenHeight)
/**
* Centre of the whole visible amount block — amount, converted value, swap hint and
* any caption — relative to the top of the animated area.
@@ -149,12 +164,21 @@ export function useAmountEntry({
isMeasured.value = withTiming(1, {duration: 150})
}
useKeyboardTop((top, duration) => {
useKeyboardTop((top, duration, isEstimate) => {
// Only the OPEN position is recorded. Following the keyboard down would swing the
// amount block towards the bottom of the screen at exactly the moment the screen is
// collapsing, which reads as the two animations fighting.
// amount block towards the bottom of the screen, and reopen the colour to full height,
// at exactly the moment the screen is collapsing — two animations fighting.
if (top >= spacing.screenHeight) return
// A withTiming() result carries its own start state, so the two values get one each
// rather than sharing a single animation object.
keyboardTop.value = duration > 0 ? withTiming(top, {duration}) : top
// The colour closes up only for a keyboard that is actually on screen. Closing it
// against the mount-time estimate is what drew the line across the middle.
if (!isEstimate) {
visibleBottom.value = duration > 0 ? withTiming(top, {duration}) : top
}
})
useEffect(() => {
@@ -234,7 +258,7 @@ export function useAmountEntry({
}))
const $animatedBackdropStyle = useAnimatedStyle(() => {
const visibleHeight = keyboardTop.value - wrapperTop.value
const visibleHeight = visibleBottom.value - wrapperTop.value
const expandedHeight = Math.max(COLLAPSED_HEADER_HEIGHT, visibleHeight)
return {
opacity: isMeasured.value,
+17 -3
View File
@@ -24,7 +24,16 @@ let lastKeyboardTop: number | null = null
export const DEFAULT_KEYBOARD_ANIMATION_DURATION = 280
export type KeyboardTopHandler = (top: number, duration: number) => void
/**
* `isEstimate` marks the value reported on mount — a remembered or assumed height, not a
* keyboard that is on screen. Callers that place content can use it right away; callers
* that draw to the keyboard's edge should wait for the real thing.
*/
export type KeyboardTopHandler = (
top: number,
duration: number,
isEstimate: boolean,
) => void
/**
* Calls `onChange` with the window Y of the keyboard's top edge whenever it moves, plus
@@ -43,6 +52,7 @@ export function useKeyboardTop(onChange: KeyboardTopHandler) {
handlerRef.current(
lastKeyboardTop ?? screenBottom * (1 - ASSUMED_KEYBOARD_RATIO),
0,
true,
)
// iOS reports `will` events ahead of the movement and carries a duration; Android
@@ -53,11 +63,15 @@ export function useKeyboardTop(onChange: KeyboardTopHandler) {
const onShow = (e: KeyboardEvent) => {
const top = e.endCoordinates.screenY
if (top > 0) lastKeyboardTop = top
handlerRef.current(top, e.duration || DEFAULT_KEYBOARD_ANIMATION_DURATION)
handlerRef.current(top, e.duration || DEFAULT_KEYBOARD_ANIMATION_DURATION, false)
}
const onHide = (e: KeyboardEvent) => {
handlerRef.current(screenBottom, e?.duration || DEFAULT_KEYBOARD_ANIMATION_DURATION)
handlerRef.current(
screenBottom,
e?.duration || DEFAULT_KEYBOARD_ANIMATION_DURATION,
false,
)
}
const showSub = Keyboard.addListener(showEvent, onShow)