Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
71 changes: 68 additions & 3 deletions projects/app/src/client/ui/PageSheetModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import type { PressableProps } from "react-native";
import { Modal, Platform, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Reanimated, {
Easing,
Extrapolation,
Expand All @@ -13,8 +14,10 @@ import Reanimated, {
withSpring,
withTiming,
} from "react-native-reanimated";
import { runOnJS } from "react-native-worklets";

import { useEventCallback } from "../hooks/useEventCallback";
import { hapticImpactIfMobile } from "../hooks/hapticImpactIfMobile";
import { ReanimatedPressable } from "./ReanimatedPressable";

export type PageSheetChild = (options: { dismiss: () => void }) => ReactNode;
Expand Down Expand Up @@ -214,6 +217,8 @@ const IosImpl = ({ onDismiss, children }: ImplProps) => {
[onDismiss],
);

// iOS pageSheet already supports native pull-to-dismiss,
// so we rely on that instead of adding custom gestures
return (
<Modal
animationType="slide"
Expand Down Expand Up @@ -243,20 +248,80 @@ const IosImpl = ({ onDismiss, children }: ImplProps) => {
};

const DefaultImpl = ({ children, onDismiss }: ImplProps) => {
const translateY = useSharedValue(0);
const [dismissing, setDismissing] = useState(false);

const api = useMemo(
() => ({
dismiss: onDismiss,
dismiss: () => {
setDismissing(true);
},
}),
[onDismiss],
[],
);

const handleDismiss = useEventCallback(() => {
hapticImpactIfMobile();
onDismiss();
});

// Pan gesture for pull-to-dismiss
const panGesture = Gesture.Pan()
.activeOffsetY(10) // Only activate when dragging down more than 10px
.onUpdate((event) => {
// Only allow downward movement
if (event.translationY > 0) {
// Add resistance effect - movement becomes harder as you drag further
const resistance = Math.min(event.translationY / 3, 200);
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic numbers 3 and 200 should be extracted as named constants to improve code readability and maintainability. Consider defining RESISTANCE_FACTOR = 3 and MAX_RESISTANCE_OFFSET = 200 at the top of the component.

Suggested change
const resistance = Math.min(event.translationY / 3, 200);
const resistance = Math.min(event.translationY / RESISTANCE_FACTOR, MAX_RESISTANCE_OFFSET);

Copilot uses AI. Check for mistakes.
translateY.set(resistance);
}
})
.onEnd((event) => {
const shouldDismiss =
event.translationY > 100 || // Dragged far enough
(event.translationY > 40 && event.velocityY > 800); // Or sufficient velocity
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dismissal thresholds 100, 40, and 800 should be extracted as named constants for better maintainability. Consider defining DISMISS_DISTANCE_THRESHOLD = 100, VELOCITY_DISMISS_DISTANCE_THRESHOLD = 40, and VELOCITY_THRESHOLD = 800 to make the gesture behavior more configurable and self-documenting.

Suggested change
(event.translationY > 40 && event.velocityY > 800); // Or sufficient velocity
event.translationY > DISMISS_DISTANCE_THRESHOLD || // Dragged far enough
(event.translationY > VELOCITY_DISMISS_DISTANCE_THRESHOLD && event.velocityY > VELOCITY_THRESHOLD); // Or sufficient velocity

Copilot uses AI. Check for mistakes.

if (shouldDismiss) {
// Animate off screen (screen height + padding)
translateY.set(
withTiming(600, { duration: 250 }, () => {
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The animation value 600 appears in multiple places (lines 287 and 301) and should be extracted as a named constant like DISMISS_ANIMATION_DISTANCE = 600 to avoid duplication and make it easier to adjust if needed.

Suggested change
withTiming(600, { duration: 250 }, () => {
withTiming(DISMISS_ANIMATION_DISTANCE, { duration: 250 }, () => {

Copilot uses AI. Check for mistakes.
runOnJS(handleDismiss)();
}),
);
} else {
// Snap back to original position
translateY.set(withSpring(0, { damping: 15, stiffness: 400 }));
}
});

// Handle dismissing state
useEffect(() => {
if (dismissing) {
translateY.set(
withTiming(600, { duration: 250 }, () => {
runOnJS(handleDismiss)();
}),
);
}
}, [dismissing, handleDismiss, translateY]);

const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.get() }],
};
});

return (
<Modal
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={api.dismiss}
>
<View className={`flex-1 bg-bg`}>{children(api)}</View>
<GestureDetector gesture={panGesture}>
<Reanimated.View className={`flex-1 bg-bg`} style={animatedStyle}>
{children(api)}
</Reanimated.View>
</GestureDetector>
</Modal>
);
};
Expand Down
29 changes: 24 additions & 5 deletions projects/app/src/client/ui/WikiHanziWordModal.demo.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { WikiHanziWordModal } from "@/client/ui/WikiHanziWordModal";
import { useState } from "react";
import { RectButton } from "./RectButton";

export default () => {
const [showModal, setShowModal] = useState(false);

return (
<WikiHanziWordModal
devUiSnapshotMode
hanziWord={`你好:hello`}
onDismiss={() => null}
/>
<>
<RectButton
variant="filled"
onPress={() => {
setShowModal(true);
}}
>
Open Wiki Modal (Test Pull-Down Gesture)
</RectButton>

{showModal && (
<WikiHanziWordModal
devUiSnapshotMode={false}
hanziWord={`你好:hello`}
onDismiss={() => {
setShowModal(false);
}}
/>
)}
</>
);
};
Loading