mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
fix rare bottom sheet interaction bug
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user