Using Expo 55. Android. I have a swipe button (like iOS swipe button) on True Sheet. It has 3 detents: low, default, full. Only in full detent swipe button can be swiped, the rest would revert itself to its original position half way, or won't register at all when it reaches the end. Claude had to create this patch for it to work.
import { LoadingView } from '@/components/common/LoadingView';
import { PlatformIcon } from '@/components/common/PlatformIcon';
import MaskedView from '@react-native-masked-view/masked-view';
import { SystemColor } from '@/design';
import { color } from '@/design/colors';
import { tokens } from '@/design/tokens';
import { typography } from '@/design/typography';
import * as Haptics from 'expo-haptics';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { getAppLocale } from '@/i18n';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Animated, Platform, Text, View } from 'react-native';
const TRACK_HEIGHT = 62;
const TRACK_EDGE_INSET = 2;
const TRACK_HORIZONTAL_PADDING = TRACK_EDGE_INSET;
const TRACK_VERTICAL_PADDING = TRACK_EDGE_INSET;
const HANDLE_SIZE = TRACK_HEIGHT - (TRACK_EDGE_INSET * 2);
const HANDLE_BORDER_RADIUS = HANDLE_SIZE / 2;
const PROGRESS_VERTICAL_INSET = TRACK_VERTICAL_PADDING;
const TRACK_RIGHT_INSET = 2;
const HANDLE_SPRING_SPEED = 24;
const HANDLE_SUBMIT_SPRING_SPEED = 28;
const HANDLE_SPRING_BOUNCINESS = 0;
const TRACK_DIM_MAX_OPACITY = 0.09;
const SUBMIT_FLASH_MAX_OPACITY = 0.14;
const SUBMIT_FLASH_IN_DURATION_MS = 150;
const SUBMIT_FLASH_OUT_DURATION_MS = 100;
const GRADIENT_LABEL_FADE_END_RATIO = 0.78;
const LIGHT_LABEL_FADE_START_RATIO = 0.18;
const SUBMIT_CALLBACK_DELAY_MS = 180;
const SUCCESS_ICON_DURATION_MS = 220;
const FALLBACK_SUCCESS_DELAY_MS = 900;
const BUTTON_CONTENT_MIN_HEIGHT = Math.max(
24,
typeof typography.h2.lineHeight === 'number' ? typography.h2.lineHeight : 24
);
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
export interface SwipeActionButtonProps {
label: string;
onSwipeComplete: () => void;
loading?: boolean;
variant?: 'primary' | 'tertiary';
danger?: boolean;
accessibilityLabel?: string;
onSwipeInteractionChange?: (isInteracting: boolean) => void;
}
export function SwipeActionButton({
label,
onSwipeComplete,
loading = false,
variant = 'primary',
danger = false,
accessibilityLabel,
onSwipeInteractionChange,
}: SwipeActionButtonProps) {
const locale = getAppLocale();
const needsComplexScriptPadding = locale === 'km' || locale === 'my';
const scriptVerticalInset = needsComplexScriptPadding ? (locale === 'my' ? 4 : 2) : 0;
const labelTextStyle = locale === 'my'
? {
fontFamily: Platform.select({
ios: 'Myanmar Sangam MN',
android: 'sans-serif',
default: undefined,
}),
fontWeight: tokens.fontWeight.regular,
}
: undefined;
const translateX = useRef(new Animated.Value(0)).current;
const handleScale = useRef(new Animated.Value(1)).current;
const submitFlash = useRef(new Animated.Value(0)).current;
const dragStartX = useRef(0);
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDraggingRef = useRef(false);
const interactionNotifiedRef = useRef(false);
const didTriggerEndHapticRef = useRef(false);
const loadingSeenRef = useRef(false);
const submittedRef = useRef(false);
const [isSubmittingVisual, setIsSubmittingVisual] = useState(false);
const [showSuccessIcon, setShowSuccessIcon] = useState(false);
const [trackWidth, setTrackWidth] = useState(0);
const maxTranslate = Math.max(
trackWidth - HANDLE_SIZE - (TRACK_HORIZONTAL_PADDING * 2) - TRACK_RIGHT_INSET,
0
);
const progressLeftInset = TRACK_HORIZONTAL_PADDING;
const restingProgressWidth = HANDLE_SIZE;
const progressMax = Math.max(maxTranslate, 1);
const gradientFadeEnd = Math.max(progressMax * GRADIENT_LABEL_FADE_END_RATIO, 1);
const lightFadeStart = progressMax * LIGHT_LABEL_FADE_START_RATIO;
const isPrimary = variant === 'primary';
const gradientLabelOpacity = translateX.interpolate({
inputRange: [0, gradientFadeEnd],
outputRange: [1, 0],
extrapolate: 'clamp',
});
const lightLabelOpacity = translateX.interpolate({
inputRange: [lightFadeStart, progressMax],
outputRange: [0, 1],
extrapolate: 'clamp',
});
const progressWidth = translateX.interpolate({
inputRange: [0, progressMax],
outputRange: [
restingProgressWidth,
maxTranslate + restingProgressWidth,
],
extrapolate: 'clamp',
});
const dimOpacity = translateX.interpolate({
inputRange: [0, progressMax],
outputRange: [0, TRACK_DIM_MAX_OPACITY],
extrapolate: 'clamp',
});
const submitFlashOpacity = submitFlash.interpolate({
inputRange: [0, 1],
outputRange: [0, SUBMIT_FLASH_MAX_OPACITY],
extrapolate: 'clamp',
});
const animateHandle = useCallback((value: number) => {
Animated.spring(translateX, {
toValue: value,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}).start();
}, [translateX]);
const clearSubmitTimers = useCallback(() => {
if (submitTimeoutRef.current) {
clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = null;
}
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
if (fallbackSuccessTimeoutRef.current) {
clearTimeout(fallbackSuccessTimeoutRef.current);
fallbackSuccessTimeoutRef.current = null;
}
}, []);
const setSwipeInteractionState = useCallback((isInteracting: boolean) => {
if (interactionNotifiedRef.current === isInteracting) return;
interactionNotifiedRef.current = isInteracting;
onSwipeInteractionChange?.(isInteracting);
}, [onSwipeInteractionChange]);
const fireHaptic = useCallback((style: Haptics.ImpactFeedbackStyle) => {
Promise.resolve(Haptics.impactAsync(style)).catch(() => undefined);
}, []);
useEffect(() => {
if (!loading && !isSubmittingVisual && !showSuccessIcon && !submittedRef.current) {
handleScale.setValue(1);
submitFlash.setValue(0);
animateHandle(0);
}
}, [animateHandle, handleScale, isSubmittingVisual, loading, maxTranslate, showSuccessIcon, submitFlash]);
const transitionToSuccess = useCallback(() => {
if (!submittedRef.current) return;
clearSubmitTimers();
setIsSubmittingVisual(false);
setShowSuccessIcon(true);
loadingSeenRef.current = false;
Animated.timing(submitFlash, {
toValue: 0,
duration: SUBMIT_FLASH_OUT_DURATION_MS,
useNativeDriver: false,
}).start();
successTimeoutRef.current = setTimeout(() => {
setShowSuccessIcon(false);
submittedRef.current = false;
loadingSeenRef.current = false;
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.spring(handleScale, {
toValue: 1,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
]).start();
}, SUCCESS_ICON_DURATION_MS);
}, [clearSubmitTimers, handleScale, submitFlash, translateX]);
const resetFromInterruptedGesture = useCallback(() => {
clearSubmitTimers();
submittedRef.current = false;
loadingSeenRef.current = false;
isDraggingRef.current = false;
didTriggerEndHapticRef.current = false;
setSwipeInteractionState(false);
setIsSubmittingVisual(false);
setShowSuccessIcon(false);
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.spring(handleScale, {
toValue: 1,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.timing(submitFlash, {
toValue: 0,
duration: SUBMIT_FLASH_OUT_DURATION_MS,
useNativeDriver: false,
}),
]).start();
}, [clearSubmitTimers, handleScale, setSwipeInteractionState, submitFlash, translateX]);
useEffect(() => {
if (!isSubmittingVisual) return;
if (loading) {
loadingSeenRef.current = true;
return;
}
if (loadingSeenRef.current) {
transitionToSuccess();
return;
}
fallbackSuccessTimeoutRef.current = setTimeout(() => {
transitionToSuccess();
}, FALLBACK_SUCCESS_DELAY_MS);
return () => {
if (fallbackSuccessTimeoutRef.current) {
clearTimeout(fallbackSuccessTimeoutRef.current);
fallbackSuccessTimeoutRef.current = null;
}
};
}, [isSubmittingVisual, loading, transitionToSuccess]);
useEffect(() => {
return () => {
clearSubmitTimers();
didTriggerEndHapticRef.current = false;
setSwipeInteractionState(false);
};
}, [clearSubmitTimers, setSwipeInteractionState]);
const handleRelease = useCallback((dx: number) => {
isDraggingRef.current = false;
didTriggerEndHapticRef.current = false;
setSwipeInteractionState(false);
if (loading || submittedRef.current || isSubmittingVisual) {
return;
}
const clampedX = clamp(dx, 0, maxTranslate);
if (maxTranslate > 0 && clampedX >= maxTranslate) {
submittedRef.current = true;
loadingSeenRef.current = false;
setIsSubmittingVisual(true);
setShowSuccessIcon(false);
clearSubmitTimers();
const settleTranslateX = clampedX >= (maxTranslate - 1) ? clampedX : maxTranslate;
Animated.parallel([
Animated.spring(translateX, {
toValue: settleTranslateX,
useNativeDriver: false,
speed: HANDLE_SUBMIT_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.timing(submitFlash, {
toValue: 1,
duration: SUBMIT_FLASH_IN_DURATION_MS,
useNativeDriver: false,
}),
]).start();
submitTimeoutRef.current = setTimeout(() => {
onSwipeComplete();
}, SUBMIT_CALLBACK_DELAY_MS);
return;
}
clearSubmitTimers();
submittedRef.current = false;
loadingSeenRef.current = false;
setIsSubmittingVisual(false);
setShowSuccessIcon(false);
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.spring(handleScale, {
toValue: 1,
useNativeDriver: false,
speed: HANDLE_SPRING_SPEED,
bounciness: HANDLE_SPRING_BOUNCINESS,
}),
Animated.timing(submitFlash, {
toValue: 0,
duration: SUBMIT_FLASH_OUT_DURATION_MS,
useNativeDriver: false,
}),
]).start();
}, [
clearSubmitTimers,
handleScale,
isSubmittingVisual,
loading,
maxTranslate,
onSwipeComplete,
submitFlash,
setSwipeInteractionState,
translateX,
]);
const handleGrant = useCallback((event: any) => {
if (loading || submittedRef.current) return;
isDraggingRef.current = true;
didTriggerEndHapticRef.current = false;
setSwipeInteractionState(true);
fireHaptic(Haptics.ImpactFeedbackStyle.Light);
dragStartX.current = event?.nativeEvent?.pageX ?? 0;
handleScale.stopAnimation(() => {
handleScale.setValue(1);
});
translateX.stopAnimation();
}, [fireHaptic, handleScale, loading, setSwipeInteractionState, translateX]);
const handleMove = useCallback((event: any) => {
if (loading || submittedRef.current) return;
const pageX = event?.nativeEvent?.pageX ?? dragStartX.current;
const dx = pageX - dragStartX.current;
const clampedDx = clamp(dx, 0, maxTranslate);
translateX.setValue(clampedDx);
if (
maxTranslate > 0 &&
clampedDx >= maxTranslate &&
!didTriggerEndHapticRef.current
) {
didTriggerEndHapticRef.current = true;
fireHaptic(Haptics.ImpactFeedbackStyle.Heavy);
}
}, [fireHaptic, loading, maxTranslate, translateX]);
const handleResponderEnd = useCallback((event: any) => {
const pageX = event?.nativeEvent?.pageX ?? dragStartX.current;
handleRelease(pageX - dragStartX.current);
}, [handleRelease]);
const handleResponderTerminate = useCallback(() => {
if (isSubmittingVisual) return;
resetFromInterruptedGesture();
}, [isSubmittingVisual, resetFromInterruptedGesture]);
const labelLineHeight = BUTTON_CONTENT_MIN_HEIGHT + (scriptVerticalInset * 2);
const sharedLabelTextStyle = {
...typography.h2,
...(labelTextStyle || {}),
textAlign: 'center' as const,
lineHeight: labelLineHeight,
paddingTop: scriptVerticalInset,
paddingBottom: scriptVerticalInset,
};
const renderLabel = () => {
if (!isPrimary) {
return (
<Text
numberOfLines={1}
style={{
...sharedLabelTextStyle,
color: danger ? SystemColor('systemRed') : SystemColor('label'),
}}
>
{label}
</Text>
);
}
const gradientTextMask = (
<Text
numberOfLines={1}
style={{
...sharedLabelTextStyle,
color: 'black',
}}
>
{label}
</Text>
);
const gradientLayer = (
<MaskedView
testID="swipe-action-label-gradient"
style={{ minHeight: labelLineHeight }}
maskElement={(
<View style={{ minHeight: labelLineHeight, justifyContent: 'center' }}>
{gradientTextMask}
</View>
)}
>
<LinearGradient
colors={color.brand.colors}
start={color.brand.start}
end={color.brand.end}
>
<View style={{ opacity: 0, minHeight: labelLineHeight, justifyContent: 'center' }}>
{gradientTextMask}
</View>
</LinearGradient>
</MaskedView>
);
return (
<View
style={{
minHeight: labelLineHeight,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
}}
>
<Animated.View
testID="swipe-action-label-gradient-layer"
style={{ opacity: gradientLabelOpacity }}
>
{gradientLayer}
</Animated.View>
<Animated.Text
testID="swipe-action-label-light"
numberOfLines={1}
style={{
...sharedLabelTextStyle,
color: '#fff',
position: 'absolute',
left: 0,
right: 0,
opacity: lightLabelOpacity,
}}
>
{label}
</Animated.Text>
</View>
);
};
const iconColor = SystemColor('lightText');
const renderHandleIcon = () => {
if (loading || isSubmittingVisual) {
return <LoadingView size="small" color={iconColor as any} />;
}
if (showSuccessIcon) {
return (
<PlatformIcon
name="checkmark"
androidName="check"
size={24}
color={iconColor as any}
/>
);
}
return (
<PlatformIcon
name="chevron-forward-outline"
androidName="chevron-right"
size={24}
color={iconColor as any}
/>
);
};
return (
<GlassView
testID="swipe-action-track"
accessibilityRole="button"
accessibilityLabel={accessibilityLabel || label}
glassEffectStyle="regular"
style={{
width: '100%',
minHeight: TRACK_HEIGHT,
borderRadius: tokens.radius.full,
backgroundColor: !isLiquidGlassAvailable() ? SystemColor('secondarySystemFill') : undefined,
borderColor: SystemColor('separator'),
borderWidth: !isLiquidGlassAvailable() ? 0.5 : 0,
justifyContent: 'center',
paddingHorizontal: TRACK_HORIZONTAL_PADDING,
paddingVertical: TRACK_VERTICAL_PADDING,
overflow: 'hidden',
}}
onLayout={(event) => {
const width = event.nativeEvent.layout.width;
if (width !== trackWidth) {
setTrackWidth(width);
}
}}
>
<Animated.View
testID="swipe-action-progress"
pointerEvents="none"
style={{
position: 'absolute',
top: PROGRESS_VERTICAL_INSET,
bottom: PROGRESS_VERTICAL_INSET,
left: progressLeftInset,
width: progressWidth,
borderRadius: tokens.radius.full,
opacity: 1,
overflow: 'hidden',
}}
>
<LinearGradient
colors={color.brand.colors}
start={color.brand.start}
end={color.brand.end}
style={{ flex: 1 }}
/>
</Animated.View>
<Animated.View
testID="swipe-action-dim"
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#000',
opacity: dimOpacity,
}}
/>
<Animated.View
testID="swipe-action-submit-flash"
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
opacity: submitFlashOpacity,
}}
/>
<Animated.View
testID="swipe-action-label"
pointerEvents="none"
style={{
position: 'absolute',
left: tokens.spacing.lg,
right: tokens.spacing.lg,
alignItems: 'center',
justifyContent: 'center',
}}
>
{renderLabel()}
</Animated.View>
<Animated.View
testID="swipe-action-handle"
onStartShouldSetResponder={() => !loading && !submittedRef.current}
onMoveShouldSetResponder={() => !loading && !submittedRef.current}
onResponderTerminationRequest={() => !isDraggingRef.current}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={handleResponderEnd}
onResponderTerminate={handleResponderTerminate}
style={{
width: HANDLE_SIZE,
height: HANDLE_SIZE,
borderRadius: HANDLE_BORDER_RADIUS,
transform: [{ scale: handleScale }, { translateX }],
}}
>
<View
testID="swipe-action-handle-glass"
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: HANDLE_BORDER_RADIUS,
overflow: 'hidden',
}}
>
{renderHandleIcon()}
</View>
</Animated.View>
</GlassView>
);
}
Before submitting a new issue
Bug Summary
Using Expo 55. Android. I have a swipe button (like iOS swipe button) on True Sheet. It has 3 detents: low, default, full. Only in full detent swipe button can be swiped, the rest would revert itself to its original position half way, or won't register at all when it reaches the end. Claude had to create this patch for it to work.
Affected Platforms
Library Version
3.10.1
Environment Info
Steps to Reproduce
See sample code below
Repro
NA
Additional Context
Here's the patch:
Here's my swipe button: