Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 19 additions & 48 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {ApolloProvider} from '@apollo/client';
import {useEffect, useState} from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
StatusBar,
StyleSheet,
Expand All @@ -30,6 +29,7 @@ import {
useAuthHydrated,
} from './src/auth/tokenStore';
import {MapScreen} from './src/map/MapScreen';
import {Sheet} from './src/ui/Sheet';

function App() {
const isDarkMode = useColorScheme() === 'dark';
Expand Down Expand Up @@ -93,35 +93,26 @@ function AccountMenu({user}: {user: AuthUser}) {
]}>
<Text style={styles.avatarText}>{initial}</Text>
</Pressable>
<Modal
animationType="slide"
transparent
<Sheet
visible={open}
onRequestClose={() => setOpen(false)}>
<Pressable style={styles.sheetBackdrop} onPress={() => setOpen(false)}>
<View
style={[styles.sheet, {paddingBottom: safeAreaInsets.bottom + 12}]}
// Stop taps inside the sheet from dismissing it via the backdrop.
onStartShouldSetResponder={() => true}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetEmail} numberOfLines={1}>
{user.email}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => {
setOpen(false);
logout();
}}
style={({pressed}) => [
styles.sheetItem,
pressed && styles.sheetItemPressed,
]}>
<Text style={styles.sheetItemText}>Log out</Text>
</Pressable>
</View>
onClose={() => setOpen(false)}
scrimAccessibilityLabel="Close account menu">
<Text style={styles.sheetEmail} numberOfLines={1}>
{user.email}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => {
setOpen(false);
logout();
}}
style={({pressed}) => [
styles.sheetItem,
pressed && styles.sheetItemPressed,
]}>
<Text style={styles.sheetItemText}>Log out</Text>
</Pressable>
</Modal>
</Sheet>
</>
);
}
Expand Down Expand Up @@ -155,26 +146,6 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
sheetBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingTop: 8,
paddingHorizontal: 8,
},
sheetHandle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d0d0d0',
marginBottom: 12,
},
sheetEmail: {
fontSize: 12,
color: '#666',
Expand Down
10 changes: 3 additions & 7 deletions src/map/ElementDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
ActivityIndicator,
Image,
Modal,
Pressable,
ScrollView,
StyleSheet,
Expand All @@ -13,6 +12,7 @@ import {
type ElementDetailQuery,
useElementDetailQuery,
} from '../graphql/__generated__/types';
import {Sheet} from '../ui/Sheet';

type Props = {
elementId: string | null;
Expand All @@ -28,17 +28,13 @@ export function ElementDetailModal({elementId, onClose}: Props) {
});

return (
<Modal
animationType="slide"
visible={elementId !== null}
onRequestClose={onClose}
presentationStyle="fullScreen">
<Sheet visible={elementId !== null} onClose={onClose} variant="fullscreen">
<ModalContents
element={data?.element ?? null}
loading={loading}
onClose={onClose}
/>
</Modal>
</Sheet>
);
}

Expand Down
150 changes: 150 additions & 0 deletions src/ui/Sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {type ReactNode, useEffect, useRef, useState} from 'react';
import {
Animated,
BackHandler,
Pressable,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

type SheetVariant = 'bottom' | 'fullscreen';

type Props = {
visible: boolean;
onClose: () => void;
/** `bottom` (default): dim scrim + bottom-anchored card. `fullscreen`: opaque full-screen. */
variant?: SheetVariant;
/** Tap the scrim to close (bottom variant only). Defaults to true. */
dismissOnScrimPress?: boolean;
/** Accessibility label for the scrim's close affordance (bottom variant). */
scrimAccessibilityLabel?: string;
children: ReactNode;
};

/**
* In-tree modal sheet. Rendered as an absolute overlay (not a React Native
* `Modal`) so it inherits the app's edge-to-edge window and draws behind the
* status and navigation bars. RN's `Modal` renders in a separate window whose
* `statusBarTranslucent`/`navigationBarTranslucent` props don't reliably do
* this on RN 0.85 + Android 15, which left scrims and sheets stopping at the
* system bars.
*
* Owns the slide animation, hardware-back handling, and pointer-event gating;
* callers supply the content (and their own safe-area padding for fullscreen).
*/
export function Sheet({
visible,
onClose,
variant = 'bottom',
dismissOnScrimPress = true,
scrimAccessibilityLabel = 'Close',
children,
}: Props) {
const safeAreaInsets = useSafeAreaInsets();
const {height} = useWindowDimensions();
const anim = useRef(new Animated.Value(0)).current;
const [sheetHeight, setSheetHeight] = useState(0);

useEffect(() => {
Animated.timing(anim, {
toValue: visible ? 1 : 0,
duration: variant === 'fullscreen' ? 250 : 220,
useNativeDriver: true,
}).start();
}, [visible, variant, anim]);

// The hardware back button closes the sheet (RN Modal used to handle this).
useEffect(() => {
if (!visible) {
return;
}
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
onClose();
return true;
});
return () => sub.remove();
}, [visible, onClose]);

if (variant === 'fullscreen') {
const translateY = anim.interpolate({
inputRange: [0, 1],
outputRange: [height, 0],
});
return (
<Animated.View
style={[
StyleSheet.absoluteFill,
styles.fullscreen,
{transform: [{translateY}]},
]}
pointerEvents={visible ? 'auto' : 'none'}>
{children}
</Animated.View>
);
}

const translateY = anim.interpolate({
inputRange: [0, 1],
outputRange: [sheetHeight || 320, 0],
});
return (
<View
style={StyleSheet.absoluteFill}
pointerEvents={visible ? 'auto' : 'none'}>
<AnimatedPressable
accessibilityLabel={scrimAccessibilityLabel}
style={[styles.scrim, {opacity: anim}]}
onPress={dismissOnScrimPress ? onClose : undefined}
/>
<Animated.View
style={[styles.sheetWrap, {transform: [{translateY}]}]}
pointerEvents="box-none">
<View
onLayout={e => setSheetHeight(e.nativeEvent.layout.height)}
style={[styles.card, {paddingBottom: safeAreaInsets.bottom + 12}]}>
<View style={styles.handle} />
{children}
</View>
</Animated.View>
</View>
);
}

const styles = StyleSheet.create({
fullscreen: {
backgroundColor: '#ffffff',
},
scrim: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
},
sheetWrap: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
card: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingTop: 8,
paddingHorizontal: 8,
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d0d0d0',
marginBottom: 12,
},
});
Loading