fix rare bottom sheet interaction bug

This commit is contained in:
Boof2015
2026-08-11 19:28:29 -04:00
parent 441299254b
commit 5a58070371
5 changed files with 131 additions and 87 deletions
+72 -32
View File
@@ -1,4 +1,10 @@
import { Fragment, forwardRef } from 'react';
import {
Fragment,
createContext,
forwardRef,
useContext,
type PropsWithChildren,
} from 'react';
import {
Pressable as NativePressable,
StyleSheet,
@@ -8,6 +14,10 @@ import {
type View as NativeView,
type ViewStyle,
} from 'react-native';
import {
Pressable as GesturePressable,
type PressableProps as GesturePressableProps,
} from 'react-native-gesture-handler';
import { useColors } from '@/theme/themed';
import {
composePressedStyle,
@@ -30,6 +40,21 @@ export interface AppPressableProps extends Omit<PressableProps, 'android_ripple'
feedbackRadius?: number;
}
const AppPressableGestureContext = createContext(false);
/**
* Makes descendant AppPressables participate in Gesture Handler arbitration.
* Gorhom sheets need this because their pan/scroll gestures can otherwise keep
* winning contacts that React Native's responder-backed Pressable has claimed.
*/
export function AppPressableGestureScope({ children }: PropsWithChildren) {
return (
<AppPressableGestureContext.Provider value>
{children}
</AppPressableGestureContext.Provider>
);
}
/**
* The app-wide Android press surface. It preserves NativePressable semantics
* while replacing the platform ripple with a restrained wash or opacity shift.
@@ -47,48 +72,63 @@ export const AppPressable = forwardRef<NativeView, AppPressableProps>(
ref,
) {
const colors = useColors();
const useGesturePressable = useContext(AppPressableGestureContext);
const isDisabled = disabled === true;
const resolveStyle = (state: PressableStateCallbackType) =>
typeof style === 'function' ? style(state) : style;
const pressableStyle = (state: PressableStateCallbackType) =>
composePressedStyle(
resolveStyle(state),
resolvePressFeedback(feedback, state.pressed, isDisabled),
);
const pressableChildren = (state: PressableStateCallbackType) => {
const content = typeof children === 'function' ? children(state) : children;
const decision = resolvePressFeedback(feedback, state.pressed, isDisabled);
if (!decision.overlay) return content;
const flattened = StyleSheet.flatten(resolveStyle(state)) as
| (ViewStyle & FeedbackCornerSource)
| undefined;
const wash = (
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ backgroundColor: colors.glassHighlight },
resolveFeedbackCorners(flattened, feedbackRadius),
]}
/>
);
return decision.overlay === 'behind' ? (
<Fragment>{wash}{content}</Fragment>
) : (
<Fragment>{content}{wash}</Fragment>
);
};
if (useGesturePressable) {
const gestureProps = {
...rest,
ref,
disabled,
style: pressableStyle,
children: pressableChildren,
} as unknown as GesturePressableProps;
return <GesturePressable {...gestureProps} />;
}
return (
<NativePressable
{...rest}
ref={ref}
disabled={disabled}
style={(state) =>
composePressedStyle(
resolveStyle(state),
resolvePressFeedback(feedback, state.pressed, isDisabled),
)
}
style={pressableStyle}
>
{(state) => {
const content = typeof children === 'function' ? children(state) : children;
const decision = resolvePressFeedback(feedback, state.pressed, isDisabled);
if (!decision.overlay) return content;
const flattened = StyleSheet.flatten(resolveStyle(state)) as
| (ViewStyle & FeedbackCornerSource)
| undefined;
const wash = (
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ backgroundColor: colors.glassHighlight },
resolveFeedbackCorners(flattened, feedbackRadius),
]}
/>
);
return decision.overlay === 'behind' ? (
<Fragment>{wash}{content}</Fragment>
) : (
<Fragment>{content}{wash}</Fragment>
);
}}
{pressableChildren}
</NativePressable>
);
},
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Linking,
Pressable,
StyleSheet,
View,
} from 'react-native';
@@ -126,57 +125,56 @@ export function ArtistImageSearchSheet({
<View style={styles.results}>
{candidates.map((candidate) => (
<AppPressable
key={candidate.id}
style={styles.candidate}
onPress={() => void choose(candidate)}
accessibilityRole="button"
accessibilityLabel={`Use Deezer image for ${candidate.name}`}
>
<Image
source={{ uri: candidate.imageUrl }}
style={styles.thumbnail}
contentFit="cover"
transition={100}
/>
<View style={styles.candidateText}>
<Text variant="body" numberOfLines={1}>{candidate.name}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{formatFans(candidate.fanCount)}
</Text>
</View>
<View key={candidate.id} style={styles.candidate}>
<AppPressable
style={styles.candidateChoice}
onPress={() => void choose(candidate)}
accessibilityRole="button"
accessibilityLabel={`Use Deezer image for ${candidate.name}`}
>
<Image
source={{ uri: candidate.imageUrl }}
style={styles.thumbnail}
contentFit="cover"
transition={100}
/>
<View style={styles.candidateText}>
<Text variant="body" numberOfLines={1}>{candidate.name}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{formatFans(candidate.fanCount)}
</Text>
</View>
{selectedId === candidate.id ? (
<ActivityIndicator size="small" color={colors.accent} />
) : (
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
)}
</AppPressable>
{candidate.linkUrl ? (
<Pressable
<AppPressable
feedback="none"
style={styles.providerLink}
hitSlop={8}
onPress={(event) => {
event.stopPropagation();
void openDeezerLink(candidate.linkUrl!);
}}
onPress={() => void openDeezerLink(candidate.linkUrl!)}
accessibilityRole="link"
accessibilityLabel={`Open ${candidate.name} on Deezer`}
>
<Ionicons name="open-outline" size={17} color={colors.textSecondary} />
</Pressable>
</AppPressable>
) : null}
{selectedId === candidate.id ? (
<ActivityIndicator size="small" color={colors.accent} />
) : (
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
)}
</AppPressable>
</View>
))}
</View>
<Pressable
<AppPressable
feedback="none"
style={styles.attribution}
onPress={() => void openDeezerLink('https://www.deezer.com/')}
accessibilityRole="link"
>
<Text variant="caption" color={colors.textSecondary}>Images and artist data from Deezer</Text>
<Ionicons name="open-outline" size={14} color={colors.textSecondary} />
</Pressable>
</AppPressable>
</AppSheet>
);
}
@@ -235,6 +233,13 @@ const useStyles = createThemedStyles((colors) => ({
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.glassBorder,
},
candidateChoice: {
flex: 1,
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
thumbnail: {
width: 58,
height: 58,
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { TextInput, View } from 'react-native';
import { View } from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { SLEEP_TIMER_PRESETS, formatSleepTimerStatus, normalizeSleepTimerMinutes } from '@/audio/sleepTimerState';
import { supportsNativePauseAtEndOfItem } from '@/audio/trackPlayerExtensions';
@@ -90,7 +91,7 @@ export function SleepTimerControls() {
</View>
<View style={styles.customRow}>
<TextInput
<BottomSheetTextInput
value={customMinutes}
onChangeText={setCustomMinutes}
editable={available}
+6 -2
View File
@@ -19,7 +19,11 @@ import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
import {
AppPressable,
AppPressableGestureScope,
SCROLL_PRESS_DELAY,
} from '@/components/AppPressable';
import { formatDuration } from '@/lib/format';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
@@ -164,7 +168,7 @@ export function RemoteQueueSheet({ onClose, embedded = false }: RemoteQueueSheet
handleComponent={renderSheetHandle}
handleIndicatorStyle={styles.handle}
>
{content}
<AppPressableGestureScope>{content}</AppPressableGestureScope>
</BottomSheet>
);
}
+10 -16
View File
@@ -4,7 +4,6 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Pressable as GesturePressable } from 'react-native-gesture-handler';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
@@ -17,7 +16,11 @@ import {
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY } from '@/components/AppPressable';
import {
AppPressable,
AppPressableGestureScope,
SCROLL_PRESS_DELAY,
} from '@/components/AppPressable';
import { playHaptic } from '@/lib/haptics';
import { useSceneBottomInset } from '@/navigation/useShellLayout';
@@ -61,13 +64,13 @@ export function AppSheet({
contentContainerStyle={[styles.content, { paddingBottom: Math.max(insets.bottom, sceneBottomInset) + spacing.md }]}
showsVerticalScrollIndicator={false}
>
{children}
<AppPressableGestureScope>{children}</AppPressableGestureScope>
</BottomSheetScrollView>
) : (
<BottomSheetView
style={[styles.content, { paddingBottom: Math.max(insets.bottom, sceneBottomInset) + spacing.md }]}
>
{children}
<AppPressableGestureScope>{children}</AppPressableGestureScope>
</BottomSheetView>
)}
</BottomSheet>
@@ -131,15 +134,9 @@ export function AppSheetItem({
return (
<View style={styles.itemRow}>
{/*
This must participate in Gesture Handler's native arbitration. A core
Pressable works in a fresh sheet, but after the Library dock has owned
several horizontal pans BottomSheetScrollView can keep winning every
later contact, leaving the row visibly tapped but never pressed.
*/}
<GesturePressable
<AppPressable
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
style={styles.item}
onPress={handlePress}
accessibilityRole={selectable ? 'radio' : 'button'}
accessibilityState={selectable ? { selected } : undefined}
@@ -158,7 +155,7 @@ export function AppSheetItem({
) : null}
</View>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</GesturePressable>
</AppPressable>
{trailing}
</View>
);
@@ -203,9 +200,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
backgroundColor: colors.glassHighlight,
},
itemLabel: {
flexShrink: 1,
},