From a0ab95ac07f8c00ff23fe74024863b83a71c5835 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 7 Sep 2026 23:32:15 +0200 Subject: [PATCH] Fill the header colour to the screen bottom until the keyboard is real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/components/AmountEntryLayout.tsx | 34 ++++++++++++++++++++++++---- src/utils/useKeyboardTop.ts | 20 +++++++++++++--- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 04fa98c0..8cf7bc27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AmountEntryLayout.tsx b/src/components/AmountEntryLayout.tsx index 7d5ed45a..e8a522b8 100644 --- a/src/components/AmountEntryLayout.tsx +++ b/src/components/AmountEntryLayout.tsx @@ -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, diff --git a/src/utils/useKeyboardTop.ts b/src/utils/useKeyboardTop.ts index ac988520..5fa7eab0 100644 --- a/src/utils/useKeyboardTop.ts +++ b/src/utils/useKeyboardTop.ts @@ -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)