Skip to content

Commit 886fa63

Browse files
NateIsernclaude
andcommitted
Places map: fix sheet scroll, search focus, snap-to-top
- Revert the sticky-header wrapping that broke FlashList's flex layout so the list scrolls again (FlashList is now a direct child of BottomSheet, title + chips live in ListHeaderComponent). - Defer the programmatic snap from `handleSearchFocus` via `requestAnimationFrame` so Android's focus bookkeeping doesn't race the sheet animation and blur the TextInput after a tick. - Add `keyboardBehavior=extend` + `keyboardBlurBehavior=restore` + `android_keyboardInputMode=adjustResize` to BottomSheet so the keyboard stays alive through the snap. - Top snap point bumped to 100% so the sheet slides behind the notch when fully expanded (Google Maps UX). - Search pill uses `bg-surface` instead of `bg-background` so it's visually distinct from the sheet, and swaps its shadow for a hairline border while the sheet is fully expanded. - Chips row stops being a sibling of the list and goes back inside ListHeaderComponent so gorhom's scroll coordination keeps working. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e5d7b17 commit 886fa63

1 file changed

Lines changed: 62 additions & 38 deletions

File tree

app/map.tsx

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,13 @@ const MARKER_HITBOX = { width: 50, height: 50 } as const;
133133

134134
// Snap points for the sheet. Index 0 = 15% peek (mostly map). Index 1 =
135135
// 62% mid — the Google-Maps-style "selected place" height where the hero
136-
// image, facts, and action row are visible without forcing the sheet to
137-
// fill the whole screen. Index 2 = 92% expanded (list fills the viewport
138-
// up to the search pill — `topInset` caps it so the pill stays visible).
139-
const SHEET_SNAP_POINTS: Array<string | number> = ["15%", "62%", "92%"];
136+
// image, facts, and action row are visible. Index 2 = 100% — the sheet
137+
// fills the screen all the way up behind the notch, sliding under the
138+
// floating search pill. At that snap the list content gains a top
139+
// padding equal to the pill height + safe area so rows never render
140+
// underneath the pill. Magnetic snapping pulls a flick past mid straight
141+
// to the top.
142+
const SHEET_SNAP_POINTS: Array<string | number> = ["15%", "62%", "100%"];
140143
// Detail mode and list mode both use the mid snap so the camera padding
141144
// stays consistent — the only difference is the rendered children.
142145
const SHEET_INDEX_DETAIL = 1;
@@ -341,13 +344,15 @@ export default function MapScreen() {
341344
const [userLocation, setUserLocation] = useState<UserCoords | null>(null);
342345

343346
// Tapping the search field expands the sheet to its top snap so the
344-
// filtered list fills the screen. On blur with an empty query we snap
345-
// back to mid so the map is visible again. The search pill is always
346-
// visible on top of the sheet; at the expanded snap the list gains a
347-
// top padding via `listContentContainerStyle` so rows aren't hidden
348-
// behind the pill.
347+
// filtered list fills the screen. We defer the snap one frame via
348+
// `requestAnimationFrame` — snapping synchronously inside the
349+
// `onFocus` handler fights with React Native's own focus bookkeeping
350+
// on Android and the TextInput loses focus a beat later. Queuing the
351+
// snap for the next frame lets focus settle first.
349352
const handleSearchFocus = useCallback(() => {
350-
sheetRef.current?.snapToIndex(SHEET_INDEX_SEARCH);
353+
requestAnimationFrame(() => {
354+
sheetRef.current?.snapToIndex(SHEET_INDEX_SEARCH);
355+
});
351356
}, []);
352357

353358
const handleSearchBlur = useCallback(() => {
@@ -636,6 +641,20 @@ export default function MapScreen() {
636641
[insets.top],
637642
);
638643

644+
// When the sheet reaches its fully-expanded snap, swap the pill's drop
645+
// shadow for a hairline border. A shadow against the sheet (which is now
646+
// flush with the pill) reads as grime; a border separates the pill from
647+
// the sheet cleanly.
648+
const pillSurfaceStyle = useMemo(() => {
649+
if (sheetIndex >= SHEET_INDEX_SEARCH) {
650+
return {
651+
borderWidth: StyleSheet.hairlineWidth,
652+
borderColor: theme.colors.border,
653+
};
654+
}
655+
return styles.floatingBar;
656+
}, [sheetIndex, theme.colors.border]);
657+
639658
const renderPlaceRow = useCallback(
640659
({ item }: { item: { place: Place; km: number | null } }) => (
641660
<PlaceRow
@@ -702,12 +721,12 @@ export default function MapScreen() {
702721

703722
<View pointerEvents="box-none" style={searchPillBaseStyle}>
704723
<View
705-
className="flex-row items-center bg-background rounded-full pl-2 pr-3 h-14"
706-
style={styles.floatingBar}
724+
className="flex-row items-center bg-surface rounded-full pl-2 pr-3 h-14"
725+
style={pillSurfaceStyle}
707726
>
708727
<Pressable
709728
onPress={() => router.back()}
710-
className="w-11 h-11 items-center justify-center rounded-full active:bg-surface"
729+
className="w-11 h-11 items-center justify-center rounded-full active:opacity-70"
711730
accessibilityRole="button"
712731
accessibilityLabel={t("common.back")}
713732
hitSlop={8}
@@ -736,7 +755,7 @@ export default function MapScreen() {
736755
{query.length > 0 ? (
737756
<Pressable
738757
onPress={() => setQuery("")}
739-
className="w-8 h-8 items-center justify-center rounded-full active:bg-surface"
758+
className="w-8 h-8 items-center justify-center rounded-full active:opacity-70"
740759
accessibilityRole="button"
741760
accessibilityLabel={t("common.clear")}
742761
hitSlop={6}
@@ -760,7 +779,7 @@ export default function MapScreen() {
760779
<View pointerEvents="box-none" style={styles.fabContainer}>
761780
<Pressable
762781
onPress={handleLocateMe}
763-
className="w-12 h-12 rounded-full bg-background items-center justify-center active:opacity-80"
782+
className="w-12 h-12 rounded-full bg-surface items-center justify-center active:opacity-80"
764783
style={styles.fab}
765784
accessibilityRole="button"
766785
accessibilityLabel={t("map.locateMe.accessibility")}
@@ -778,6 +797,10 @@ export default function MapScreen() {
778797
snapPoints={SHEET_SNAP_POINTS}
779798
index={1}
780799
enablePanDownToClose={false}
800+
enableDynamicSizing={false}
801+
keyboardBehavior="extend"
802+
keyboardBlurBehavior="restore"
803+
android_keyboardInputMode="adjustResize"
781804
onChange={handleSheetChange}
782805
animatedIndex={sheetAnimatedIndex}
783806
backgroundStyle={sheetBackgroundStyle}
@@ -790,38 +813,39 @@ export default function MapScreen() {
790813
onClose={handleCloseDetail}
791814
/>
792815
) : (
793-
<View className="flex-1">
794-
{/* Sticky top block (spacer grows with the drag, then title,
795-
then filter chips). Renders outside the FlashList so it
796-
stays pinned while the list scrolls beneath it. */}
797-
<Animated.View style={headerSpacerStyle} />
798-
<View className="px-5 pt-4 pb-2">
799-
<Text className="text-foreground text-lg font-semibold">
800-
{t("map.nearYou")}
801-
</Text>
802-
<Text className="text-muted-foreground text-xs mt-0.5">
803-
{filteredPlaces.length}{" "}
804-
{filteredPlaces.length === 1
805-
? t("map.resultOne")
806-
: t("map.resultOther")}
807-
</Text>
808-
</View>
809-
<CategoryFilterRow
810-
value={categoryFilter}
811-
onChange={handleCategoryFilter}
812-
/>
816+
<>
813817
<FlashList
814818
renderScrollComponent={BottomSheetFlashListScrollable}
815819
data={placesWithDistance}
816820
keyExtractor={(item) => item.place.id}
817821
contentContainerStyle={listContentContainerStyle}
822+
ListHeaderComponent={
823+
<View>
824+
<Animated.View style={headerSpacerStyle} />
825+
<View className="px-5 pt-4 pb-2">
826+
<Text className="text-foreground text-lg font-semibold">
827+
{t("map.nearYou")}
828+
</Text>
829+
<Text className="text-muted-foreground text-xs mt-0.5">
830+
{filteredPlaces.length}{" "}
831+
{filteredPlaces.length === 1
832+
? t("map.resultOne")
833+
: t("map.resultOther")}
834+
</Text>
835+
</View>
836+
<CategoryFilterRow
837+
value={categoryFilter}
838+
onChange={handleCategoryFilter}
839+
/>
840+
</View>
841+
}
818842
ListEmptyComponent={
819843
<EmptyState icon="map-marker-off" title={t("map.noResults")} />
820844
}
821845
renderItem={renderPlaceRow}
822846
/>
823847
<FloatingHandle />
824-
</View>
848+
</>
825849
)}
826850
</BottomSheet>
827851

@@ -846,8 +870,8 @@ export default function MapScreen() {
846870

847871
// ---------------------------------------------------------------------------
848872
// CategoryFilterRow — horizontal scrollable chip row that filters the list
849-
// by PlaceCategory. Rendered inside the FlashList header so it scrolls with
850-
// the content.
873+
// by PlaceCategory. Rendered as a sibling of the FlashList (not inside it)
874+
// so it stays sticky at the top of the sheet while the list scrolls under.
851875
// ---------------------------------------------------------------------------
852876

853877
interface CategoryFilterRowProps {

0 commit comments

Comments
 (0)