Bottom-sheet primitive + TokenPicker/receive migrations (polish wave C)#34
Merged
epicexcelsior merged 11 commits intoMay 10, 2026
Merged
Conversation
PR anonmesh#29 removed the H_PAD constant when moving PendingCosigns into WalletScreen's grid (parent now owns horizontal padding) but left one reference at line 57. Result: upstream/v3 fails tsc on a fresh clone. Drop the stale paddingHorizontal entry from the wrap View style array and consolidate the two duplicate @expo/vector-icons imports while in the file.
…tive Add @gorhom/bottom-sheet@^5 as the canonical bottom-sheet engine. Wraps it as components/primitives/BottomSheet.tsx exposing our visual chrome (handle indicator, glass surface, primary border accent, backdrop tied to drag progress) with a declarative visible/onClose API consumers already know. What gorhom buys: - Pan-from-anywhere on the sheet (Apple convention; not handle-only) - Native-thread gesture + animation (no JS lag under load) - Scroll-to-dismiss handoff for sheets with internal scroll (BottomSheetScrollView/FlatList re-exported from this module) - Flick-to-dismiss with velocity projection - Snap points (omit prop = dynamic content-fit sizing) - Keyboard-interactive layout for sheets with TextInput - Modal portal — render anywhere in the tree, sheet floats above Wired BottomSheetModalProvider inside GestureHandlerRootView in the root layout so every screen can present sheets without local provider. BottomSheetHandleBar exported as a standalone bar for surfaces that can't move to AppBottomSheet yet (route-style modals like the receive screen). Visual matches the gorhom handle indicator.
Two surfaces migrate off bespoke gesture/animation scaffolding onto the canonical primitives in this PR: TokenPicker (components/send/TokenPicker.tsx): - Wraps content in <AppBottomSheet visible onClose>; drops Modal, GestureHandlerRootView, GestureDetector, Animated.View, Pressable backdrop, in-line grab bar, panGesture, dismiss callback, DISMISS_DISTANCE/DISMISS_VELOCITY constants, sheetStyle/translateY, useGlass. - Result: -120 LOC; Apple-feel pan-from-anywhere, scroll-handoff if it ever grows scrollable content, keyboard-interactive for free. Receive route (app/receive.tsx): - Was using legacy PanResponder (JS-thread → laggy under load on Seeker). Replaced with react-native-gesture-handler Pan + Reanimated worklet writes — translateY now updates on the UI thread so the sheet follows the finger 1:1. - Visual handle swapped to <BottomSheetHandleBar/> from the primitive module so the receive route matches the gorhom-rendered indicator used elsewhere. - Velocity threshold corrected (was 0.8 in PanResponder dy/ms units; gesture-handler uses px/s — now 800 to match other surfaces). - Inline GrabHandle helper deleted. - Receive stays a Stack.Screen route (presentation: 'modal') for now; conversion to a true gorhom BottomSheetModal mounted from the wallet bento is a future refactor.
Dynamic content sizing in @gorhom/bottom-sheet v5 has a measurement
race on first present — BottomSheetView reports 0 height before
children mount, the modal opens at height 0 then never measures again.
TokenPicker presented but rendered invisible.
Lock to a single 85% snap point by default with enableDynamicSizing
disabled. Consumers can pass snapPoint='60%' etc. for a different
height. index={0} explicit so present() always opens to the first
(and only, by default) snap.
Future work: revisit dynamic sizing with onLayout callback once we
have a sheet that genuinely needs content-fit height (TxDetailModal
post-merge — variable content based on tx fields).
Gorhom's BottomSheetModal was firing present() but rendering invisible in our setup — likely a v5 dynamic-sizing/portal interaction with Reanimated 4 + expo-router. Couldn't reproduce the working state from the docs in our tree. Pivoting to a primitive built on react-native-gesture-handler + Reanimated directly so we control every piece and can debug end-to-end. What the new AppBottomSheet does: - Native RN Modal as the platform overlay (transparent + animationType=none). - gesture-handler Pan with activeOffsetY([12, screenHeight]) so the sheet only claims the gesture on downward intent past 12pt — inner Pressables/inputs still receive their taps. - Reanimated worklet writes translateY directly during drag so the sheet follows the finger 1:1 on the UI thread (no JS lag). - Spring physics damping 22 / stiffness 250 (open) and 320 (back) for a snappy Apple-adjacent feel. - Velocity-aware dismiss: drag past 120pt or fling > 800px/s closes; short release springs back. - Backdrop opacity interpolates 0 → 0.7 with translateY progress so the dim fades naturally as the sheet leaves. - Internal mounted state lags the visible prop on close so the slide- down animation finishes before the Modal unmounts (without it the sheet would just snap off-screen). - Split dismiss-pattern: dismiss-Pressable is flex:1 above the sheet (no overlap with sheet area), backdrop dim is a separate pointerEvents:none Animated.View. Same fix pattern as TxDetailModal post-debug — avoids absoluteFill-sibling responder claims that silently swallow nested DepthButton/Pressable taps. Removed BottomSheetModalProvider from app/_layout.tsx since it's no longer needed (and may have been interfering with the wallet-screen QR button's native Modal — to be verified on rebuild). Removed @gorhom/bottom-sheet from package.json. Removed the BottomSheetScrollView/BottomSheetFlatList re-exports from the primitives index since they were gorhom-specific. AppBottomSheet + BottomSheetHandleBar remain — same public API as before.
Two bugs from the previous primitive build:
1. activeOffsetY([12, SCREEN_HEIGHT]) — array form means "activate
when Y translation is OUTSIDE this range", i.e. < 12 OR > screen
height. The < 12 case fires on UPWARD pan, not downward — exactly
the opposite of what a pull-down dismiss needs. Switched to
activeOffsetY(12) (single positive number = downward past 12pt
activates).
2. withSpring(0, { damping: 22, stiffness: 250 }) over SCREEN_HEIGHT
travel was underdamped (ratio ~0.7) so the sheet overshot past
the rest position and oscillated — "shoots way up, slow." Apple's
modal sheet open isn't a spring; it's a timing curve. Switched
open + close to withTiming with Easing.out/in cubic (320ms / 220ms).
Spring kept only for snap-back after a partial interactive drag —
short distance there, no overshoot risk, feels natural.
Receive screen still uses single-number activeOffsetY (was already
correct), so it gestures down. Lag user reported there is the native
modal back-animation conflicting with our local dismiss animation —
deferring that fix to a follow-up since it requires changing the
route's presentation config.
…blue line Two more bugs from device smoke: 1. TokenPicker still couldn't gesture down even with activeOffsetY(12) because RN Modal renders contents in a SEPARATE NATIVE VIEW TREE (Window/Dialog on Android, separate UIWindow on iOS). The app-root GestureHandlerRootView at app/_layout.tsx does NOT propagate into that tree, so GestureDetector inside the modal saw no gestures. Documented in react-native-gesture-handler README under "Using inside Modal". Added a GestureHandlerRootView wrapping the Modal contents — gesture context now lives inside the modal's native tree where children can find it. Receive screen worked because it's a Stack.Screen route (same gesture-handler context as the rest of the app), not an RN Modal. 2. "Weird blue line at top" of the sheet was borderTopWidth: 1 with borderColor defaulting to colors.borderStrong, which is rgba(0,229,255,0.28) — cyan. Switched default to colors.border (subtle) and dropped width to StyleSheet.hairlineWidth so the divider is barely visible, not a feature accent. Also tightened the docstring to reflect the actual behavior (timing for open/close, spring only for snap-back) and pulled the GestureHandlerRootView wrap requirement up as critical wiring so future maintainers see it before they break it. Standard primitive: every new sheet in the app should use AppBottomSheet. No per-screen ad-hoc gesture/animation rewrites.
Two changes targeting the 'tiny bit of lag/jitter' user reported on
the receive screen pan-to-dismiss:
1. Sequence the dismiss animations. Previously: pan releases past
threshold → translateY = withTiming(800) AND router.back() fire
simultaneously. Our local timing animates inner content down to
800px while the native back-animation slides the WHOLE screen
from full position to off-screen — two transforms compound into
visible jitter.
Now: withTiming completion callback fires router.back. By the
time native back kicks in, our inner is already off-screen, so
the native animation runs invisibly. One animation per frame.
2. renderToHardwareTextureAndroid + collapsable={false} on the outer
Animated.View. The receive screen has a non-trivial subtree
(QR svg, segmented control, two action buttons, an input). On
Android, transforming that subtree per-frame asks the GPU to
recomposite the whole layer. The hardware-texture hint caches
the view as a GPU texture so per-frame translateY is cheap.
collapsable={false} prevents Android's view-tree optimizer from
collapsing the wrapper layer (which would defeat the texture
cache).
No behavior change — same dismiss threshold (120pt or 800px/s),
same spring snap-back. Just smoother on Android.
User flagged: after our pan-to-dismiss slides receive content off- screen smoothly, a black screen still slid down behind it. Cause: the Stack-screen route container has a default dark background. After our inner Animated.View timing reached 800px, the container itself was still full-screen with its own (dark) background. router.back then fired native back animation — sliding the empty dark container down over the wallet screen. Visual: extra black flash. Switch presentation from 'modal' to 'transparentModal'. Container background is transparent → after our inner content slides off, the empty container is invisible → native back animation runs over a transparent layer → user sees wallet screen behind during back, no black flash. Receive content keeps its own backgroundColor, so the visible area looks identical during entry and use.
User reported a 'little black box at bottom that waits' after swipe-
down dismiss, even with transparentModal. Cause: the native back
animation was still slid the empty Stack-screen container down behind
our animation, and any height mismatch (window-height translate vs
screen-height container) left a sliver of content visible at the
bottom edge.
Solid architecture: receive owns ALL motion via Reanimated; the route
itself runs zero native animations.
Three changes:
1. app/_layout.tsx receive Stack.Screen options:
- presentation: 'modal' → 'transparentModal' (transparent container)
- animation: 'slide_from_bottom' → 'none' (no native enter/exit)
- contentStyle: { backgroundColor: 'transparent' } (the screen's
own content layer is also transparent)
The route is invisible at every phase; only our own Animated.View
has a backgroundColor and only it ever moves.
2. app/receive.tsx initialization:
- dragY initial value: 0 → SCREEN_HEIGHT (start off-screen)
- useEffect on mount: withTiming(0, ease-out cubic, 320ms) — slides
the content UP from off-screen into rest position. Replaces the
native slide_from_bottom we just disabled.
- Dismiss timing already sequenced via callback — slides DOWN to
SCREEN_HEIGHT then fires router.back which now unmounts instantly
(animation:'none'), invisible to the user.
3. SCREEN_HEIGHT source: Dimensions.get('window') → Dimensions.get('screen')
in BOTH receive.tsx AND BottomSheet.tsx primitive. 'window' excludes
status bar; 'screen' is the full device extent. For Android edge-to-
edge content under the gesture nav, 'window' translate left a
sliver below the safe area = the 'little black box that waits'.
Result: from the user's POV, taps Receive → screen slides up, can pan
freely, swipes down → screen slides down, gone. No native back
animation, no empty container, no black flash, no sliver. Single
animation source per surface — receive owns its frame, BottomSheet
primitive owns its frame, identical curves and timings.
10 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces a new AppBottomSheet primitive and migrates the TokenPicker and Receive modal interactions to a gesture-driven, Reanimated-based presentation to improve drag-to-dismiss smoothness and consistency.
Changes:
- Added
components/primitives/BottomSheet.tsxexportingAppBottomSheetandBottomSheetHandleBar, and surfaced them viacomponents/primitives/index.ts. - Migrated
components/send/TokenPicker.tsxto render withinAppBottomSheet(removing its bespoke Modal + gesture/animation scaffolding). - Updated the
/receiveroute to use gesture-handler + Reanimated timing for enter/exit, and adjusted stack options totransparentModal+animation: 'none'to avoid competing native transitions.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| mobile_app/components/send/TokenPicker.tsx | Replaces custom modal/gesture sheet with AppBottomSheet usage. |
| mobile_app/components/primitives/index.ts | Exports the new bottom-sheet primitive and handle bar. |
| mobile_app/components/primitives/BottomSheet.tsx | Adds a reusable modal + RNGH/Reanimated bottom-sheet implementation. |
| mobile_app/components/nodes/PendingCosigns.tsx | Consolidates icon imports and removes stale horizontal padding reference. |
| mobile_app/app/receive.tsx | Replaces PanResponder with RNGH Gesture.Pan() + Reanimated-driven enter/exit and dismiss sequencing. |
| mobile_app/app/_layout.tsx | Switches receive to transparentModal with animation: 'none' to rely on Receive’s custom animation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… through animated dismiss BottomSheet.tsx Backdrop opacity interpolation now clamps. A fast flick can park translateY past SCREEN_HEIGHT before the close timing settles; without clamping, opacity extrapolates negative. Reanimated/RN tolerate it today but the contract is undefined. receive.tsx Extract animateAndDismiss helper that runs the same withTiming(SCREEN_HEIGHT, TIMING_CLOSE) → router.back sequence the gesture's onEnd already used. The header X button now calls this helper instead of router.back() directly. Without this, header-X dismiss triggered the route's animation:'none' on a still-on-screen content view, producing a black flash. All non-gesture dismiss paths now share one animation curve.
Merged
14 tasks
epicexcelsior
added a commit
to epicexcelsior/anon0mesh
that referenced
this pull request
May 11, 2026
Two bugs uncovered during Seeker smoke of the TxDetailModal migration: 1. Backdrop "slides up" with the sheet on open. Root cause: backdrop opacity was interpolated directly from `translateY`, so the dim cross-faded synchronously with the slide. Visually that reads as "dim rising with the sheet" rather than the iOS-standard "sheet rises onto already-dim background". Fix: give the backdrop its own `backdropOpacity` shared value. On open, animate it over 200ms with no delay (leading the 320ms sheet slide). On close, animate both in lockstep over 220ms. During interactive drag, the Pan's onUpdate mirrors translateY to backdropOpacity so dismissal still feels coupled to the gesture. 2. Pan-from-body unresponsive. Root cause: `activeOffsetY(12)` is too permissive a window for Pressable children to compete in via RN's responder system. Press claims the touch, drags through the first ~10pt before Pressable cancels (movement > pressRectOffset), and by then gesture-handler often can't pick up the in-progress drag cleanly. No `activeOffsetX` constraint also meant diagonal flicks could ambiguously route between Pan and child taps. Fix: lower `activeOffsetY` 12 → 5 (claim before Pressable's cancel window), add `activeOffsetX([-20, 20])` to constrain to vertical motion, and add `shouldCancelWhenOutside(false)` so a drag that crosses the sheet bounds during interaction doesn't get cancelled. Affects every consumer of AppBottomSheet: TokenPicker (already on upstream/v3 from PR anonmesh#34) and TxDetailModal (new in this PR). No behavior changes for callers — same props, same lifecycle.
epicexcelsior
added a commit
that referenced
this pull request
May 11, 2026
…imitive gesture/dim bugs (#41) * refactor(tx-detail): migrate TxDetailModal to AppBottomSheet The transaction-detail modal previously used the bespoke RN Modal + dismissArea pattern that predates the AppBottomSheet primitive landed in #34. The recipe in BottomSheet.tsx's docstring lists this surface as a planned consumer; this PR makes the swap. Behavior changes for users: - Pull-to-dismiss gesture (1:1 finger tracking on the UI thread) with velocity-based release. - Backdrop dim now fades smoothly with drag progress instead of the binary slide-in/out of the previous implementation. - Apple-modal-curve open/close timing. The modal's contents are unchanged: the same header with status pill, the same six DetailRow entries, the same Explorer + Close action pair. Existing inner ScrollView is dropped; AppBottomSheet's maxHeight 90% is sufficient for the detail panel's natural height across all real-world variants (memo + mint extras included). This is the second consumer of AppBottomSheet after TokenPicker, strengthening the primitive as the single source of truth for sheet-style surfaces. * fix(bottom-sheet): leading backdrop fade + more sensitive pan activation Two bugs uncovered during Seeker smoke of the TxDetailModal migration: 1. Backdrop "slides up" with the sheet on open. Root cause: backdrop opacity was interpolated directly from `translateY`, so the dim cross-faded synchronously with the slide. Visually that reads as "dim rising with the sheet" rather than the iOS-standard "sheet rises onto already-dim background". Fix: give the backdrop its own `backdropOpacity` shared value. On open, animate it over 200ms with no delay (leading the 320ms sheet slide). On close, animate both in lockstep over 220ms. During interactive drag, the Pan's onUpdate mirrors translateY to backdropOpacity so dismissal still feels coupled to the gesture. 2. Pan-from-body unresponsive. Root cause: `activeOffsetY(12)` is too permissive a window for Pressable children to compete in via RN's responder system. Press claims the touch, drags through the first ~10pt before Pressable cancels (movement > pressRectOffset), and by then gesture-handler often can't pick up the in-progress drag cleanly. No `activeOffsetX` constraint also meant diagonal flicks could ambiguously route between Pan and child taps. Fix: lower `activeOffsetY` 12 → 5 (claim before Pressable's cancel window), add `activeOffsetX([-20, 20])` to constrain to vertical motion, and add `shouldCancelWhenOutside(false)` so a drag that crosses the sheet bounds during interaction doesn't get cancelled. Affects every consumer of AppBottomSheet: TokenPicker (already on upstream/v3 from PR #34) and TxDetailModal (new in this PR). No behavior changes for callers — same props, same lifecycle. * fix(bottom-sheet): kill snap-back jitter — timing curve instead of spring Snap-back after a partial drag previously used a spring (damping 22, stiffness 320). That spring is mathematically underdamped — damping ratio ≈ 0.61 against critical 35.78 — so the system overshoots the rest position and oscillates on settle. After short drags (e.g. user pulls down 20pt then releases without dismissing) there's no perceptual cover for the wobble, and it reads as jitter. Replace with a deterministic timing curve (260ms ease-out exponential) for both translateY and backdropOpacity. No overshoot, no oscillation, no visible jitter on settle. This is what production sheet libraries (gorhom/bottom-sheet, etc.) use for snap-back for exactly this reason. Affects every consumer of AppBottomSheet — TokenPicker and TxDetailModal (this PR). Same props, same lifecycle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an
AppBottomSheetprimitive built onreact-native-gesture-handler+react-native-reanimated(both already linked) and migrates the first two H-lane surfaces onto it. Apple-feel pan-from-anywhere, native-thread translateY, flick-to-dismiss with velocity threshold.Note on engine choice: an earlier draft of this PR adopted
@gorhom/bottom-sheetv5. That added a heavy dependency (snap-point engine, scrollable wrappers, BottomSheetModalProvider plumbing) for behavior we already had via raw RNGH + Reanimated. Walked back in84dfd6e— the primitive now wraps a native RNModaldirectly. Same Apple feel, no new package. PR title/body originally claimed gorhom; updated to match what's actually shipped.Supersedes #33 — that PR deduped onto a weaker handle-only primitive; this lays the foundation for an honest standardization.
Files
New
mobile_app/components/primitives/BottomSheet.tsx— exportsAppBottomSheet(the full sheet) andBottomSheetHandleBar(standalone grab indicator for route-style modals like the receive screen). Built on RNModal+ RNGHGesture.Pan()+ ReanimateduseSharedValue/withTiming/withSpring. Single source of truth for app sheet behavior — pan from anywhere, native-thread 1:1 finger tracking, dismiss past 120pt distance OR 800px/s velocity, spring snap-back on partial drag, backdrop opacity interpolated against drag progress (clamped). Internal mounted state lagsvisibleprop on close so the slide-down completes before unmount.mobile_app/components/primitives/index.ts— surfaces the new exports.Migrated
mobile_app/components/send/TokenPicker.tsx— drops ~120 LOC of bespoke gesture/animation scaffolding, now renders insideAppBottomSheet. Same UX but pan-from-anywhere (was handle-only).mobile_app/app/receive.tsx— was using legacyPanResponder(JS-thread, laggy on Seeker). Replaced withGesture.Pan()+ Reanimated worklet writes — UI-thread translateY updates so the sheet follows the finger 1:1. Velocity threshold corrected (was0.8PanResponder dy/ms units →800gesture-handler px/s). Receive stays aStack.Screenroute; a future PR can convert it to a true sheet mounted from the wallet bento.mobile_app/app/_layout.tsx— receive route switched topresentation: 'transparentModal'+animation: 'none'so the screen owns its own enter/exit animation. Eliminates the empty-container black flash on dismiss.Why split TokenPicker (sheet) and receive (route + gesture)
Receive is mounted as a
Stack.Screenwith route-level presentation inapp/_layout.tsx; converting it to a child sheet of the wallet bento would mean re-plumbing everyrouter.push('/receive')caller. Out of scope. The minimum-viable Apple-feel fix is the gesture-thread upgrade alone.TokenPicker is already a declarative
visible-prop sheet, so it migrates cleanly.Drive-by
Carries the same
fix(nodes): drop stale H_PAD referencecherry-pick the other open wallet-lane PRs do —tsc --noEmiterrors onupstream/v3without it. No-ops on whichever PR merges first.Validation
npx tsc --noEmitcleannpm run lintcleannpm run validate:tier0:servicespassnode ./scripts/validate-tier0-config.mjspassTest plan
TokenPicker (
/send/recipient→ tap token chip)Receive route (wallet → Receive action)
router.back()Future work (not this PR)
Apply
AppBottomSheetto:TxDetailModal— post Improve wallet activity: SPL symbols, decimals, tap-to-detail #31 mergeExportWalletModal(recovery key reveal) — post Recovery export hardening: screen-capture failsafe + inline error surface + Solana Pay locale comma #30 mergescreens/WalletScreen.tsx) — needs Djason heads-up/receiveroute to a sheet mounted from the wallet bento — architecturalEach is a small, scoped follow-up PR using the primitive.