dpv007 commited on
Commit
c0cd69c
Β·
1 Parent(s): bb27193

Custom media swiper with pinch-to-zoom and custom video player

Browse files
keystone-app/app.json CHANGED
@@ -27,8 +27,9 @@
27
  "expo-video"
28
  ],
29
  "extra": {
 
30
  "eas": {
31
- "projectId": "8267e9d9-6b78-41b2-a1af-2c779a8c4f97"
32
  }
33
  },
34
  "android": {
@@ -36,7 +37,16 @@
36
  "adaptiveIcon": {
37
  "foregroundImage": "./assets/android-icon-foreground.png",
38
  "backgroundColor": "#f0ebe3"
39
- }
40
- }
 
 
 
 
 
 
 
 
 
41
  }
42
  }
 
27
  "expo-video"
28
  ],
29
  "extra": {
30
+ "router": {},
31
  "eas": {
32
+ "projectId": "1d22c3df-76f0-4fc1-baf6-5132dfcef14b"
33
  }
34
  },
35
  "android": {
 
37
  "adaptiveIcon": {
38
  "foregroundImage": "./assets/android-icon-foreground.png",
39
  "backgroundColor": "#f0ebe3"
40
+ },
41
+ "permissions": [
42
+ "android.permission.READ_EXTERNAL_STORAGE",
43
+ "android.permission.WRITE_EXTERNAL_STORAGE",
44
+ "android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
45
+ "android.permission.ACCESS_MEDIA_LOCATION",
46
+ "android.permission.READ_MEDIA_IMAGES",
47
+ "android.permission.READ_MEDIA_VIDEO"
48
+ ]
49
+ },
50
+ "owner": "dpv007"
51
  }
52
  }
keystone-app/components/FullScreenSwiper.tsx CHANGED
@@ -4,11 +4,11 @@ import {
4
  StyleSheet,
5
  FlatList,
6
  Platform,
7
- PanResponder,
8
  Animated,
9
  useWindowDimensions,
10
  } from 'react-native';
11
  import { Image } from 'expo-image';
 
12
  import CustomVideoPlayer from './CustomVideoPlayer';
13
 
14
  interface FullScreenSwiperProps {
@@ -18,134 +18,138 @@ interface FullScreenSwiperProps {
18
  onIndexChange: (index: number) => void;
19
  }
20
 
21
- /* ── ZoomableImage with Pinch-to-Zoom & Swipe-Down-to-Close ── */
 
 
 
 
 
22
  function ZoomableImage({
23
  uri, thumbUri, width, height, onSwipeDown, onZoomChange
24
  }: {
25
  uri: string; thumbUri?: string; width: number; height: number;
26
  onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
27
  }) {
28
- const scale = useRef(new Animated.Value(1)).current;
 
 
 
 
 
 
 
29
  const translateY = useRef(new Animated.Value(0)).current;
30
- const baseDistance = useRef(0);
31
- const currentScale = useRef(1);
32
- const lastComputedScale = useRef(1);
33
 
34
- // Refs so PanResponder always sees latest callbacks
 
 
 
 
35
  const onSwipeDownRef = useRef(onSwipeDown);
36
  const onZoomChangeRef = useRef(onZoomChange);
37
  useEffect(() => { onSwipeDownRef.current = onSwipeDown; }, [onSwipeDown]);
38
  useEffect(() => { onZoomChangeRef.current = onZoomChange; }, [onZoomChange]);
39
 
40
- const panResponder = useRef(PanResponder.create({
41
- onStartShouldSetPanResponder: (e) => e.nativeEvent.touches.length >= 2,
42
-
43
- onMoveShouldSetPanResponder: (e, gs) => {
44
- if (e.nativeEvent.touches.length >= 2) return true;
45
- return Math.abs(gs.dy) > 12 && Math.abs(gs.dy) > Math.abs(gs.dx) * 1.5;
46
- },
47
-
48
- // Capture BEFORE FlatList gets it
49
- onMoveShouldSetPanResponderCapture: (e, gs) => {
50
- if (e.nativeEvent.touches.length >= 2) return true;
51
- return Math.abs(gs.dy) > 12 && Math.abs(gs.dy) > Math.abs(gs.dx) * 1.5;
52
- },
53
-
54
- onPanResponderGrant: (e) => {
55
- const { touches } = e.nativeEvent;
56
- if (touches.length >= 2) {
57
- const dx = touches[0].pageX - touches[1].pageX;
58
- const dy = touches[0].pageY - touches[1].pageY;
59
- baseDistance.current = Math.sqrt(dx * dx + dy * dy);
60
- onZoomChangeRef.current(true);
61
- }
62
- },
63
-
64
- onPanResponderMove: (e, gs) => {
65
- const { touches } = e.nativeEvent;
66
- if (touches.length >= 2) {
67
- if (baseDistance.current === 0) {
68
- // Second finger just appeared mid-gesture β€” init pinch
69
- const dx = touches[0].pageX - touches[1].pageX;
70
- const dy = touches[0].pageY - touches[1].pageY;
71
- baseDistance.current = Math.sqrt(dx * dx + dy * dy);
72
- onZoomChangeRef.current(true);
73
- Animated.timing(translateY, { toValue: 0, duration: 80, useNativeDriver: true }).start();
74
- } else {
75
- const dx = touches[0].pageX - touches[1].pageX;
76
- const dy = touches[0].pageY - touches[1].pageY;
77
- const dist = Math.sqrt(dx * dx + dy * dy);
78
- const newScale = Math.max(0.5, Math.min(5, currentScale.current * (dist / baseDistance.current)));
79
- scale.setValue(newScale);
80
- lastComputedScale.current = newScale;
81
- }
82
- } else if (touches.length <= 1 && baseDistance.current === 0) {
83
- // Single-finger vertical drag β†’ swipe-down-to-close
84
- translateY.setValue(gs.dy);
85
- }
86
- },
87
-
88
- onPanResponderRelease: (_, gs) => {
89
- if (baseDistance.current > 0) {
90
- // Was pinch-zooming
91
- const finalScale = lastComputedScale.current;
92
- baseDistance.current = 0;
93
- if (finalScale < 1.15) {
94
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, bounciness: 0 }).start();
95
- currentScale.current = 1;
96
- lastComputedScale.current = 1;
97
- } else {
98
- currentScale.current = finalScale;
99
- }
100
- onZoomChangeRef.current(false);
101
  } else {
102
- // Was swiping down
103
- if (gs.dy > 100) {
104
- onSwipeDownRef.current();
105
- } else {
106
- Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start();
107
- }
108
  }
109
- },
110
-
111
- onPanResponderTerminate: () => {
112
- baseDistance.current = 0;
113
- Animated.parallel([
114
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, bounciness: 0 }),
115
- Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0 }),
116
- ]).start();
117
- currentScale.current = 1;
118
- lastComputedScale.current = 1;
 
 
 
 
119
  onZoomChangeRef.current(false);
120
- },
121
- })).current;
 
 
 
 
 
 
 
122
 
123
  return (
124
- <Animated.View
125
- style={{
126
- width, height,
127
- justifyContent: 'center',
128
- alignItems: 'center',
129
- transform: [{ scale }, { translateY }],
130
- }}
131
- {...panResponder.panHandlers}
 
132
  >
133
- <Image
134
- source={{ uri }}
135
- placeholder={thumbUri ? { uri: thumbUri } : undefined}
136
- style={{ width: '100%', height: '100%' }}
137
- contentFit="contain"
138
- cachePolicy="memory-disk"
139
- transition={200}
140
- backgroundColor="transparent"
141
- allowDownscaling={false}
142
- />
143
- </Animated.View>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  );
145
  }
146
 
147
 
148
- /* ── FullScreenSwiper ──────────────────────────────────────── */
 
 
149
  export default function FullScreenSwiper({
150
  assets, initialIndex, onClose, onIndexChange
151
  }: FullScreenSwiperProps) {
@@ -153,7 +157,6 @@ export default function FullScreenSwiper({
153
  const [activeIndex, setActiveIndex] = useState(initialIndex);
154
  const [scrollEnabled, setScrollEnabled] = useState(true);
155
 
156
- // Keep onIndexChange ref fresh for the viewability callback
157
  const onIndexChangeRef = useRef(onIndexChange);
158
  useEffect(() => { onIndexChangeRef.current = onIndexChange; }, [onIndexChange]);
159
 
 
4
  StyleSheet,
5
  FlatList,
6
  Platform,
 
7
  Animated,
8
  useWindowDimensions,
9
  } from 'react-native';
10
  import { Image } from 'expo-image';
11
+ import { PinchGestureHandler, PanGestureHandler, State } from 'react-native-gesture-handler';
12
  import CustomVideoPlayer from './CustomVideoPlayer';
13
 
14
  interface FullScreenSwiperProps {
 
18
  onIndexChange: (index: number) => void;
19
  }
20
 
21
+ /* ─────────────────────────────────────────────────────────────
22
+ ZoomableImage
23
+ β€’ Pinch-to-zoom via PinchGestureHandler (native multi-touch)
24
+ β€’ Swipe-down-to-close via PanGestureHandler (vertical only)
25
+ β€’ Both run on the native thread for 60 fps
26
+ ───────────────────────────────────────────────────────────── */
27
  function ZoomableImage({
28
  uri, thumbUri, width, height, onSwipeDown, onZoomChange
29
  }: {
30
  uri: string; thumbUri?: string; width: number; height: number;
31
  onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
32
  }) {
33
+ /* ── Pinch zoom state ── */
34
+ const baseScaleAnim = useRef(new Animated.Value(1)).current;
35
+ const pinchScaleAnim = useRef(new Animated.Value(1)).current;
36
+ const baseScaleRef = useRef(1);
37
+ // displayScale = baseScale Γ— pinchScale β€” computed on native thread
38
+ const displayScale = Animated.multiply(baseScaleAnim, pinchScaleAnim);
39
+
40
+ /* ── Swipe-down state ── */
41
  const translateY = useRef(new Animated.Value(0)).current;
 
 
 
42
 
43
+ /* ── Handler refs for simultaneousHandlers ── */
44
+ const pinchRef = useRef(null);
45
+ const panRef = useRef(null);
46
+
47
+ /* ── Callback refs (prevent stale closures) ── */
48
  const onSwipeDownRef = useRef(onSwipeDown);
49
  const onZoomChangeRef = useRef(onZoomChange);
50
  useEffect(() => { onSwipeDownRef.current = onSwipeDown; }, [onSwipeDown]);
51
  useEffect(() => { onZoomChangeRef.current = onZoomChange; }, [onZoomChange]);
52
 
53
+ /* ── Pinch handlers ── */
54
+ const onPinchEvent = Animated.event(
55
+ [{ nativeEvent: { scale: pinchScaleAnim } }],
56
+ { useNativeDriver: true }
57
+ );
58
+
59
+ const onPinchStateChange = (e: any) => {
60
+ if (e.nativeEvent.state === State.ACTIVE) {
61
+ onZoomChangeRef.current(true); // disable FlatList scroll
62
+ }
63
+ if (e.nativeEvent.oldState === State.ACTIVE) {
64
+ onZoomChangeRef.current(false); // re-enable FlatList scroll
65
+ const newBase = baseScaleRef.current * e.nativeEvent.scale;
66
+ if (newBase < 1.15) {
67
+ // Snap back to 1Γ—
68
+ baseScaleRef.current = 1;
69
+ baseScaleAnim.setValue(1);
70
+ Animated.spring(pinchScaleAnim, {
71
+ toValue: 1, useNativeDriver: true, bounciness: 0,
72
+ }).start();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  } else {
74
+ // Keep the zoom level
75
+ const clamped = Math.min(5, newBase);
76
+ baseScaleRef.current = clamped;
77
+ baseScaleAnim.setValue(clamped);
78
+ pinchScaleAnim.setValue(1);
 
79
  }
80
+ }
81
+ };
82
+
83
+ /* ── Pan (swipe-down) handlers ── */
84
+ const onPanEvent = Animated.event(
85
+ [{ nativeEvent: { translationY: translateY } }],
86
+ { useNativeDriver: true }
87
+ );
88
+
89
+ const onPanStateChange = (e: any) => {
90
+ if (e.nativeEvent.state === State.ACTIVE) {
91
+ onZoomChangeRef.current(true);
92
+ }
93
+ if (e.nativeEvent.oldState === State.ACTIVE) {
94
  onZoomChangeRef.current(false);
95
+ if (e.nativeEvent.translationY > 100) {
96
+ onSwipeDownRef.current();
97
+ } else {
98
+ Animated.spring(translateY, {
99
+ toValue: 0, useNativeDriver: true, bounciness: 0,
100
+ }).start();
101
+ }
102
+ }
103
+ };
104
 
105
  return (
106
+ <PanGestureHandler
107
+ ref={panRef}
108
+ simultaneousHandlers={pinchRef}
109
+ minPointers={1}
110
+ maxPointers={1}
111
+ activeOffsetY={20}
112
+ failOffsetX={[-15, 15]}
113
+ onGestureEvent={onPanEvent}
114
+ onHandlerStateChange={onPanStateChange}
115
  >
116
+ <Animated.View style={{ flex: 1 }}>
117
+ <PinchGestureHandler
118
+ ref={pinchRef}
119
+ simultaneousHandlers={panRef}
120
+ onGestureEvent={onPinchEvent}
121
+ onHandlerStateChange={onPinchStateChange}
122
+ >
123
+ <Animated.View
124
+ style={{
125
+ width,
126
+ height,
127
+ justifyContent: 'center',
128
+ alignItems: 'center',
129
+ transform: [{ scale: displayScale }, { translateY }],
130
+ }}
131
+ >
132
+ <Image
133
+ source={{ uri }}
134
+ placeholder={thumbUri ? { uri: thumbUri } : undefined}
135
+ style={{ width: '100%', height: '100%' }}
136
+ contentFit="contain"
137
+ cachePolicy="memory-disk"
138
+ transition={200}
139
+ backgroundColor="transparent"
140
+ allowDownscaling={false}
141
+ />
142
+ </Animated.View>
143
+ </PinchGestureHandler>
144
+ </Animated.View>
145
+ </PanGestureHandler>
146
  );
147
  }
148
 
149
 
150
+ /* ─────────────────────────────────────────────────────────────
151
+ FullScreenSwiper
152
+ ───────────────────────────────────────────────────────────── */
153
  export default function FullScreenSwiper({
154
  assets, initialIndex, onClose, onIndexChange
155
  }: FullScreenSwiperProps) {
 
157
  const [activeIndex, setActiveIndex] = useState(initialIndex);
158
  const [scrollEnabled, setScrollEnabled] = useState(true);
159
 
 
160
  const onIndexChangeRef = useRef(onIndexChange);
161
  useEffect(() => { onIndexChangeRef.current = onIndexChange; }, [onIndexChange]);
162