zkit-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/README.md +267 -0
  2. package/dist/ComponentLibProvider.d.ts +15 -0
  3. package/dist/ComponentLibProvider.d.ts.map +1 -0
  4. package/dist/ComponentLibProvider.js +18 -0
  5. package/dist/assets/animations/spinner.json +1 -0
  6. package/dist/assets/icons/check.svg +4 -0
  7. package/dist/assets/icons/toast/error.webp +0 -0
  8. package/dist/assets/icons/toast/success.webp +0 -0
  9. package/dist/assets/icons/toast/warning.webp +0 -0
  10. package/dist/config.d.ts +16 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +42 -0
  13. package/dist/i18n/I18nProvider.d.ts +11 -0
  14. package/dist/i18n/I18nProvider.d.ts.map +1 -0
  15. package/dist/i18n/I18nProvider.js +84 -0
  16. package/dist/i18n/locale.d.ts +7 -0
  17. package/dist/i18n/locale.d.ts.map +1 -0
  18. package/dist/i18n/locale.js +87 -0
  19. package/dist/i18n/locales/de.d.ts +51 -0
  20. package/dist/i18n/locales/de.d.ts.map +1 -0
  21. package/dist/i18n/locales/de.js +62 -0
  22. package/dist/i18n/locales/en-US.d.ts +51 -0
  23. package/dist/i18n/locales/en-US.d.ts.map +1 -0
  24. package/dist/i18n/locales/en-US.js +62 -0
  25. package/dist/i18n/locales/index.d.ts +9 -0
  26. package/dist/i18n/locales/index.d.ts.map +1 -0
  27. package/dist/i18n/locales/index.js +23 -0
  28. package/dist/i18n/locales/ja.d.ts +51 -0
  29. package/dist/i18n/locales/ja.d.ts.map +1 -0
  30. package/dist/i18n/locales/ja.js +62 -0
  31. package/dist/i18n/locales/zh-CN.d.ts +51 -0
  32. package/dist/i18n/locales/zh-CN.d.ts.map +1 -0
  33. package/dist/i18n/locales/zh-CN.js +62 -0
  34. package/dist/i18n/locales/zh-TW.d.ts +51 -0
  35. package/dist/i18n/locales/zh-TW.d.ts.map +1 -0
  36. package/dist/i18n/locales/zh-TW.js +62 -0
  37. package/dist/i18n/types.d.ts +11 -0
  38. package/dist/i18n/types.d.ts.map +1 -0
  39. package/dist/i18n/types.js +2 -0
  40. package/dist/i18n/useI18n.d.ts +2 -0
  41. package/dist/i18n/useI18n.d.ts.map +1 -0
  42. package/dist/i18n/useI18n.js +41 -0
  43. package/dist/index.d.ts +57 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +90 -0
  46. package/dist/services/ActionDialogService/ActionDialog.d.ts +4 -0
  47. package/dist/services/ActionDialogService/ActionDialog.d.ts.map +1 -0
  48. package/dist/services/ActionDialogService/ActionDialog.js +531 -0
  49. package/dist/services/ActionDialogService/index.d.ts +4 -0
  50. package/dist/services/ActionDialogService/index.d.ts.map +1 -0
  51. package/dist/services/ActionDialogService/index.js +8 -0
  52. package/dist/services/ActionDialogService/service.d.ts +7 -0
  53. package/dist/services/ActionDialogService/service.d.ts.map +1 -0
  54. package/dist/services/ActionDialogService/service.js +309 -0
  55. package/dist/services/ActionDialogService/shared.d.ts +17 -0
  56. package/dist/services/ActionDialogService/shared.d.ts.map +1 -0
  57. package/dist/services/ActionDialogService/shared.js +167 -0
  58. package/dist/services/ActionDialogService/types.d.ts +160 -0
  59. package/dist/services/ActionDialogService/types.d.ts.map +1 -0
  60. package/dist/services/ActionDialogService/types.js +2 -0
  61. package/dist/services/CardToastService/index.d.ts +115 -0
  62. package/dist/services/CardToastService/index.d.ts.map +1 -0
  63. package/dist/services/CardToastService/index.js +899 -0
  64. package/dist/services/ImagePreviewService/index.d.ts +179 -0
  65. package/dist/services/ImagePreviewService/index.d.ts.map +1 -0
  66. package/dist/services/ImagePreviewService/index.js +1383 -0
  67. package/dist/services/LoadingService/index.d.ts +146 -0
  68. package/dist/services/LoadingService/index.d.ts.map +1 -0
  69. package/dist/services/LoadingService/index.js +827 -0
  70. package/dist/services/PermissionPurposeDialogService/index.d.ts +99 -0
  71. package/dist/services/PermissionPurposeDialogService/index.d.ts.map +1 -0
  72. package/dist/services/PermissionPurposeDialogService/index.js +360 -0
  73. package/dist/services/PickerService/index.d.ts +128 -0
  74. package/dist/services/PickerService/index.d.ts.map +1 -0
  75. package/dist/services/PickerService/index.js +250 -0
  76. package/dist/theme/ThemeProvider.d.ts +15 -0
  77. package/dist/theme/ThemeProvider.d.ts.map +1 -0
  78. package/dist/theme/ThemeProvider.js +61 -0
  79. package/dist/theme/defaultTheme.d.ts +3 -0
  80. package/dist/theme/defaultTheme.d.ts.map +1 -0
  81. package/dist/theme/defaultTheme.js +28 -0
  82. package/dist/theme/mergeTheme.d.ts +3 -0
  83. package/dist/theme/mergeTheme.d.ts.map +1 -0
  84. package/dist/theme/mergeTheme.js +19 -0
  85. package/dist/theme/types.d.ts +17 -0
  86. package/dist/theme/types.d.ts.map +1 -0
  87. package/dist/theme/types.js +2 -0
  88. package/dist/theme/useTheme.d.ts +2 -0
  89. package/dist/theme/useTheme.d.ts.map +1 -0
  90. package/dist/theme/useTheme.js +46 -0
  91. package/dist/ui/Accordion/index.d.ts +99 -0
  92. package/dist/ui/Accordion/index.d.ts.map +1 -0
  93. package/dist/ui/Accordion/index.js +777 -0
  94. package/dist/ui/AddressCascader/index.d.ts +58 -0
  95. package/dist/ui/AddressCascader/index.d.ts.map +1 -0
  96. package/dist/ui/AddressCascader/index.js +710 -0
  97. package/dist/ui/BetweenTime/index.d.ts +82 -0
  98. package/dist/ui/BetweenTime/index.d.ts.map +1 -0
  99. package/dist/ui/BetweenTime/index.js +702 -0
  100. package/dist/ui/Button/index.d.ts +93 -0
  101. package/dist/ui/Button/index.d.ts.map +1 -0
  102. package/dist/ui/Button/index.js +832 -0
  103. package/dist/ui/Checkbox/index.d.ts +103 -0
  104. package/dist/ui/Checkbox/index.d.ts.map +1 -0
  105. package/dist/ui/Checkbox/index.js +694 -0
  106. package/dist/ui/DatePicker/index.d.ts +5 -0
  107. package/dist/ui/DatePicker/index.d.ts.map +1 -0
  108. package/dist/ui/DatePicker/index.js +157 -0
  109. package/dist/ui/DatePicker/types.d.ts +99 -0
  110. package/dist/ui/DatePicker/types.d.ts.map +1 -0
  111. package/dist/ui/DatePicker/types.js +2 -0
  112. package/dist/ui/DatePicker/utils.d.ts +32 -0
  113. package/dist/ui/DatePicker/utils.d.ts.map +1 -0
  114. package/dist/ui/DatePicker/utils.js +316 -0
  115. package/dist/ui/LinkedScroll/index.d.ts +78 -0
  116. package/dist/ui/LinkedScroll/index.d.ts.map +1 -0
  117. package/dist/ui/LinkedScroll/index.js +470 -0
  118. package/dist/ui/LoadingSpinner/index.d.ts +8 -0
  119. package/dist/ui/LoadingSpinner/index.d.ts.map +1 -0
  120. package/dist/ui/LoadingSpinner/index.js +122 -0
  121. package/dist/ui/Picker/actionBar.d.ts +14 -0
  122. package/dist/ui/Picker/actionBar.d.ts.map +1 -0
  123. package/dist/ui/Picker/actionBar.js +28 -0
  124. package/dist/ui/Picker/index.d.ts +5 -0
  125. package/dist/ui/Picker/index.d.ts.map +1 -0
  126. package/dist/ui/Picker/index.js +412 -0
  127. package/dist/ui/Picker/types.d.ts +80 -0
  128. package/dist/ui/Picker/types.d.ts.map +1 -0
  129. package/dist/ui/Picker/types.js +2 -0
  130. package/dist/ui/Picker/utils.d.ts +27 -0
  131. package/dist/ui/Picker/utils.d.ts.map +1 -0
  132. package/dist/ui/Picker/utils.js +182 -0
  133. package/dist/ui/Radio/index.d.ts +95 -0
  134. package/dist/ui/Radio/index.d.ts.map +1 -0
  135. package/dist/ui/Radio/index.js +633 -0
  136. package/dist/ui/Sheet/index.d.ts +12 -0
  137. package/dist/ui/Sheet/index.d.ts.map +1 -0
  138. package/dist/ui/Sheet/index.js +832 -0
  139. package/dist/ui/Sheet/nativeBottom.d.ts +52 -0
  140. package/dist/ui/Sheet/nativeBottom.d.ts.map +1 -0
  141. package/dist/ui/Sheet/nativeBottom.js +442 -0
  142. package/dist/ui/Sheet/nativeBottom.web.d.ts +18 -0
  143. package/dist/ui/Sheet/nativeBottom.web.d.ts.map +1 -0
  144. package/dist/ui/Sheet/nativeBottom.web.js +46 -0
  145. package/dist/ui/Sheet/slots.d.ts +5 -0
  146. package/dist/ui/Sheet/slots.d.ts.map +1 -0
  147. package/dist/ui/Sheet/slots.js +40 -0
  148. package/dist/ui/Sheet/types.d.ts +131 -0
  149. package/dist/ui/Sheet/types.d.ts.map +1 -0
  150. package/dist/ui/Sheet/types.js +2 -0
  151. package/dist/ui/SliderCaptcha/index.d.ts +57 -0
  152. package/dist/ui/SliderCaptcha/index.d.ts.map +1 -0
  153. package/dist/ui/SliderCaptcha/index.js +586 -0
  154. package/dist/ui/Switch/index.d.ts +72 -0
  155. package/dist/ui/Switch/index.d.ts.map +1 -0
  156. package/dist/ui/Switch/index.js +671 -0
  157. package/dist/ui/Text/index.d.ts +41 -0
  158. package/dist/ui/Text/index.d.ts.map +1 -0
  159. package/dist/ui/Text/index.js +325 -0
  160. package/dist/ui/TextInput/TextInputPrimitive.d.ts +8 -0
  161. package/dist/ui/TextInput/TextInputPrimitive.d.ts.map +1 -0
  162. package/dist/ui/TextInput/TextInputPrimitive.js +54 -0
  163. package/dist/ui/TextInput/index.d.ts +89 -0
  164. package/dist/ui/TextInput/index.d.ts.map +1 -0
  165. package/dist/ui/TextInput/index.js +545 -0
  166. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.d.ts +39 -0
  167. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.d.ts.map +1 -0
  168. package/dist/ui/WheelColumn/ZKitWheelPickerNativeComponent.js +60 -0
  169. package/dist/ui/WheelColumn/constants.d.ts +2 -0
  170. package/dist/ui/WheelColumn/constants.d.ts.map +1 -0
  171. package/dist/ui/WheelColumn/constants.js +4 -0
  172. package/dist/ui/WheelColumn/index.d.ts +59 -0
  173. package/dist/ui/WheelColumn/index.d.ts.map +1 -0
  174. package/dist/ui/WheelColumn/index.js +547 -0
  175. package/ios/ZKitWheelPicker.h +18 -0
  176. package/ios/ZKitWheelPicker.mm +230 -0
  177. package/ios/ZKitWheelPickerLabel.h +10 -0
  178. package/ios/ZKitWheelPickerLabel.mm +31 -0
  179. package/ios/ZKitWheelPickerManager.h +5 -0
  180. package/ios/ZKitWheelPickerManager.mm +53 -0
  181. package/package.json +75 -0
  182. package/react-native.config.js +10 -0
  183. package/src/ComponentLibProvider.tsx +56 -0
  184. package/src/assets/animations/spinner.json +1 -0
  185. package/src/assets/icons/check.svg +4 -0
  186. package/src/assets/icons/toast/error.webp +0 -0
  187. package/src/assets/icons/toast/success.webp +0 -0
  188. package/src/assets/icons/toast/warning.webp +0 -0
  189. package/src/config.ts +65 -0
  190. package/src/i18n/I18nProvider.tsx +69 -0
  191. package/src/i18n/locale.ts +108 -0
  192. package/src/i18n/locales/de.ts +67 -0
  193. package/src/i18n/locales/en-US.ts +67 -0
  194. package/src/i18n/locales/index.ts +19 -0
  195. package/src/i18n/locales/ja.ts +67 -0
  196. package/src/i18n/locales/zh-CN.ts +67 -0
  197. package/src/i18n/locales/zh-TW.ts +67 -0
  198. package/src/i18n/types.ts +21 -0
  199. package/src/i18n/useI18n.ts +7 -0
  200. package/src/index.ts +395 -0
  201. package/src/services/ActionDialogService/ActionDialog.tsx +756 -0
  202. package/src/services/ActionDialogService/README.md +146 -0
  203. package/src/services/ActionDialogService/index.tsx +39 -0
  204. package/src/services/ActionDialogService/service.tsx +355 -0
  205. package/src/services/ActionDialogService/shared.ts +204 -0
  206. package/src/services/ActionDialogService/types.ts +197 -0
  207. package/src/services/CardToastService/README.md +128 -0
  208. package/src/services/CardToastService/index.tsx +1284 -0
  209. package/src/services/ImagePreviewService/README.md +67 -0
  210. package/src/services/ImagePreviewService/index.tsx +2148 -0
  211. package/src/services/LoadingService/README.md +128 -0
  212. package/src/services/LoadingService/index.tsx +1113 -0
  213. package/src/services/PermissionPurposeDialogService/README.md +80 -0
  214. package/src/services/PermissionPurposeDialogService/index.tsx +449 -0
  215. package/src/services/PickerService/README.md +154 -0
  216. package/src/services/PickerService/index.tsx +410 -0
  217. package/src/theme/ThemeProvider.tsx +40 -0
  218. package/src/theme/defaultTheme.ts +30 -0
  219. package/src/theme/mergeTheme.ts +16 -0
  220. package/src/theme/types.ts +36 -0
  221. package/src/theme/useTheme.ts +10 -0
  222. package/src/types/svg.d.ts +9 -0
  223. package/src/types/vant-area-data.d.ts +19 -0
  224. package/src/ui/Accordion/README.md +67 -0
  225. package/src/ui/Accordion/index.tsx +1221 -0
  226. package/src/ui/AddressCascader/README.md +144 -0
  227. package/src/ui/AddressCascader/index.tsx +1084 -0
  228. package/src/ui/BetweenTime/README.md +490 -0
  229. package/src/ui/BetweenTime/index.tsx +1001 -0
  230. package/src/ui/Button/README.md +186 -0
  231. package/src/ui/Button/index.tsx +1356 -0
  232. package/src/ui/Checkbox/README.md +211 -0
  233. package/src/ui/Checkbox/index.tsx +1231 -0
  234. package/src/ui/DatePicker/README.md +117 -0
  235. package/src/ui/DatePicker/index.tsx +295 -0
  236. package/src/ui/DatePicker/types.ts +115 -0
  237. package/src/ui/DatePicker/utils.ts +405 -0
  238. package/src/ui/LinkedScroll/README.md +55 -0
  239. package/src/ui/LinkedScroll/index.tsx +809 -0
  240. package/src/ui/LoadingSpinner/index.tsx +104 -0
  241. package/src/ui/Picker/README.md +190 -0
  242. package/src/ui/Picker/actionBar.tsx +70 -0
  243. package/src/ui/Picker/index.tsx +694 -0
  244. package/src/ui/Picker/types.ts +94 -0
  245. package/src/ui/Picker/utils.ts +239 -0
  246. package/src/ui/Radio/README.md +207 -0
  247. package/src/ui/Radio/index.tsx +1117 -0
  248. package/src/ui/Sheet/README.md +82 -0
  249. package/src/ui/Sheet/index.tsx +1163 -0
  250. package/src/ui/Sheet/nativeBottom.tsx +683 -0
  251. package/src/ui/Sheet/nativeBottom.web.tsx +40 -0
  252. package/src/ui/Sheet/slots.tsx +75 -0
  253. package/src/ui/Sheet/types.ts +195 -0
  254. package/src/ui/SliderCaptcha/README.md +61 -0
  255. package/src/ui/SliderCaptcha/index.tsx +816 -0
  256. package/src/ui/Switch/README.md +143 -0
  257. package/src/ui/Switch/index.tsx +1092 -0
  258. package/src/ui/Text/README.md +157 -0
  259. package/src/ui/Text/index.tsx +450 -0
  260. package/src/ui/TextInput/README.md +197 -0
  261. package/src/ui/TextInput/TextInputPrimitive.tsx +44 -0
  262. package/src/ui/TextInput/index.tsx +942 -0
  263. package/src/ui/WheelColumn/README.md +104 -0
  264. package/src/ui/WheelColumn/ZKitWheelPickerNativeComponent.tsx +68 -0
  265. package/src/ui/WheelColumn/constants.ts +1 -0
  266. package/src/ui/WheelColumn/index.tsx +826 -0
  267. package/zkit-ui.podspec +19 -0
@@ -0,0 +1,2148 @@
1
+ /**
2
+ * @file ImagePreviewService
3
+ *
4
+ * A full-screen image preview primitive plus a global imperative service.
5
+ * The declarative component owns the controlled/uncontrolled state model;
6
+ * the service only schedules one preview instance through ImagePreviewProvider.
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import { Feather } from '@expo/vector-icons';
11
+ import {
12
+ Modal,
13
+ Platform,
14
+ Pressable,
15
+ StyleSheet,
16
+ View,
17
+ useWindowDimensions,
18
+ type StyleProp,
19
+ type ViewProps,
20
+ type ViewStyle,
21
+ } from 'react-native';
22
+ import Animated, {
23
+ Easing,
24
+ Extrapolation,
25
+ ReduceMotion,
26
+ cancelAnimation,
27
+ interpolate,
28
+ useAnimatedReaction,
29
+ useAnimatedStyle,
30
+ useSharedValue,
31
+ withSpring,
32
+ withTiming,
33
+ type SharedValue,
34
+ } from 'react-native-reanimated';
35
+ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
36
+ import {
37
+ Image,
38
+ type ImageErrorEventData,
39
+ type ImageLoadEventData,
40
+ type ImageProps,
41
+ type ImageSource,
42
+ type ImageStyle,
43
+ } from 'expo-image';
44
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
45
+ import { scheduleOnRN } from 'react-native-worklets';
46
+ import { wp } from 'zkit-tools';
47
+ import { useI18n } from '../../i18n/useI18n';
48
+ import { useTheme } from '../../theme/useTheme';
49
+ import { LoadingSpinner } from '../../ui/LoadingSpinner';
50
+ import { Text } from '../../ui/Text';
51
+
52
+ const MIN_SCALE = 1;
53
+ const MAX_SCALE = 5;
54
+ const DOUBLE_TAP_SCALE = 2.5;
55
+ const SCALE_EPSILON = 0.02;
56
+ const TRANSLATE_EPSILON = wp(0.5);
57
+ const VELOCITY_THRESHOLD = 520;
58
+ const DISMISS_VELOCITY_THRESHOLD = 900;
59
+ const MODE_LOCK_DISTANCE = wp(8);
60
+ const EDGE_RESISTANCE = 0.34;
61
+ const TAP_SUPPRESSION_RELEASE_DURATION = 320;
62
+ const DEFAULT_RENDER_AHEAD = 1;
63
+ const MAX_RENDER_AHEAD = 2;
64
+ const DEFAULT_Z_INDEX = 9999;
65
+
66
+ const CLOSE_BUTTON_SIDE = wp(40);
67
+ const CLOSE_BUTTON_LEFT = wp(14);
68
+ const CLOSE_BUTTON_TOP = wp(8);
69
+ const CLOSE_ICON_SIZE = wp(22);
70
+ const CONTROL_HIT_SLOP = wp(12);
71
+ const CONTROL_RADIUS = wp(20);
72
+ const BOTTOM_CHROME_OFFSET = wp(24);
73
+ const BOTTOM_CHROME_GAP = wp(10);
74
+ const COUNTER_MIN_HEIGHT = wp(28);
75
+ const COUNTER_PADDING_HORIZONTAL = wp(12);
76
+ const COUNTER_RADIUS = wp(14);
77
+ const COUNTER_FONT_SIZE = 13;
78
+ const COUNTER_LINE_HEIGHT = 18;
79
+ const ERROR_CARD_MAX_WIDTH = wp(260);
80
+ const ERROR_CARD_PADDING_HORIZONTAL = wp(16);
81
+ const ERROR_CARD_PADDING_VERTICAL = wp(14);
82
+ const ERROR_CARD_RADIUS = wp(14);
83
+ const ERROR_ICON_SIZE = wp(24);
84
+ const ERROR_ICON_MARGIN_BOTTOM = wp(8);
85
+ const ERROR_TEXT_SIZE = 13;
86
+ const ERROR_TEXT_LINE_HEIGHT = 18;
87
+ const LOADING_SPINNER_SIZE = wp(42);
88
+
89
+ const SPRING_CONFIG = { damping: 28, stiffness: 280, mass: 0.62 };
90
+ const ENTER_TIMING = {
91
+ duration: 220,
92
+ easing: Easing.out(Easing.cubic),
93
+ reduceMotion: ReduceMotion.System,
94
+ };
95
+ const EXIT_TIMING = {
96
+ duration: 180,
97
+ easing: Easing.out(Easing.cubic),
98
+ reduceMotion: ReduceMotion.System,
99
+ };
100
+ const RESET_TIMING = {
101
+ duration: 220,
102
+ easing: Easing.out(Easing.cubic),
103
+ reduceMotion: ReduceMotion.System,
104
+ };
105
+ const CHROME_TIMING = {
106
+ duration: 160,
107
+ easing: Easing.out(Easing.cubic),
108
+ reduceMotion: ReduceMotion.System,
109
+ };
110
+ const ANDROID_REMOTE_ACCEPT_HEADER = 'image/jpeg,image/png,image/webp,*/*';
111
+
112
+ export type ImagePreviewSource = NonNullable<ImageProps['source']>;
113
+
114
+ export type ImagePreviewImageDescriptor = ImageSource & {
115
+ id?: string;
116
+ source?: ImagePreviewSource;
117
+ alt?: string;
118
+ accessibilityLabel?: string;
119
+ };
120
+
121
+ export type ImagePreviewImage = ImagePreviewSource | ImagePreviewImageDescriptor;
122
+
123
+ export type ImagePreviewTapBehavior = 'toggleChrome' | 'close' | 'none';
124
+
125
+ export type ImagePreviewCloseReason =
126
+ | 'api'
127
+ | 'back'
128
+ | 'closeButton'
129
+ | 'empty'
130
+ | 'replace'
131
+ | 'swipeDown'
132
+ | 'tap'
133
+ | 'unmount';
134
+
135
+ export type ImagePreviewChangeReason = 'api' | 'controlled' | 'swipe';
136
+
137
+ export type ImagePreviewOpenChangeReason = 'api' | ImagePreviewCloseReason;
138
+
139
+ export type ImagePreviewResolvedImage = {
140
+ key: string;
141
+ source: ImagePreviewSource;
142
+ uri?: string;
143
+ width?: number;
144
+ height?: number;
145
+ alt?: string;
146
+ accessibilityLabel?: string;
147
+ raw: ImagePreviewImage;
148
+ };
149
+
150
+ export type ImagePreviewOpenChangeMeta = {
151
+ reason: ImagePreviewOpenChangeReason;
152
+ value: number;
153
+ image: ImagePreviewResolvedImage | null;
154
+ total: number;
155
+ };
156
+
157
+ export type ImagePreviewChangeMeta = {
158
+ reason: ImagePreviewChangeReason;
159
+ previousValue: number;
160
+ value: number;
161
+ image: ImagePreviewResolvedImage | null;
162
+ total: number;
163
+ };
164
+
165
+ export type ImagePreviewResult = {
166
+ reason: ImagePreviewCloseReason;
167
+ value: number;
168
+ image: ImagePreviewResolvedImage | null;
169
+ total: number;
170
+ };
171
+
172
+ export type ImagePreviewPrefetchCachePolicy = 'disk' | 'memory' | 'memory-disk';
173
+
174
+ export type ImagePreviewInteractions = {
175
+ tapBehavior?: ImagePreviewTapBehavior;
176
+ pinchToZoom?: boolean;
177
+ doubleTapToZoom?: boolean;
178
+ swipeToChange?: boolean;
179
+ swipeDownToClose?: boolean;
180
+ backToClose?: boolean;
181
+ };
182
+
183
+ export type ImagePreviewColors = {
184
+ backdrop?: string;
185
+ controlBackground?: string;
186
+ controlForeground?: string;
187
+ counterBackground?: string;
188
+ counterText?: string;
189
+ errorBackground?: string;
190
+ errorText?: string;
191
+ loading?: string;
192
+ };
193
+
194
+ export type ImagePreviewLabels = {
195
+ preview: string;
196
+ close: string;
197
+ loadFailed: string;
198
+ counter: (current: number, total: number) => string;
199
+ image: (current: number, total: number) => string;
200
+ };
201
+
202
+ export type ImagePreviewImageProps = Omit<
203
+ ImageProps,
204
+ | 'accessibilityLabel'
205
+ | 'alt'
206
+ | 'children'
207
+ | 'contentFit'
208
+ | 'onError'
209
+ | 'onLoad'
210
+ | 'onLoadStart'
211
+ | 'recyclingKey'
212
+ | 'source'
213
+ | 'style'
214
+ >;
215
+
216
+ export type ImagePreviewRenderContext = {
217
+ open: boolean;
218
+ value: number;
219
+ image: ImagePreviewResolvedImage | null;
220
+ total: number;
221
+ close: (reason?: ImagePreviewCloseReason) => void;
222
+ setValue: (value: number) => void;
223
+ };
224
+
225
+ export type ImagePreviewRef = {
226
+ close: (reason?: ImagePreviewCloseReason) => void;
227
+ setValue: (value: number) => void;
228
+ getValue: () => number;
229
+ };
230
+
231
+ export type ImagePreviewProps = {
232
+ images: readonly ImagePreviewImage[];
233
+ open?: boolean;
234
+ defaultOpen?: boolean;
235
+ onOpenChange?: (open: boolean, meta: ImagePreviewOpenChangeMeta) => void;
236
+
237
+ value?: number;
238
+ defaultValue?: number;
239
+ onChange?: (value: number, meta: ImagePreviewChangeMeta) => void;
240
+ onClose?: (result: ImagePreviewResult) => void;
241
+
242
+ disabled?: boolean;
243
+ dismissible?: boolean;
244
+ interactions?: ImagePreviewInteractions;
245
+ closeButton?: boolean;
246
+ counter?: boolean;
247
+ renderAhead?: number;
248
+ prefetch?: boolean | 'adjacent' | 'all';
249
+ prefetchCachePolicy?: ImagePreviewPrefetchCachePolicy;
250
+
251
+ contentFit?: NonNullable<ImageProps['contentFit']>;
252
+ imageProps?: ImagePreviewImageProps;
253
+ imageStyle?: StyleProp<ImageStyle>;
254
+ rootStyle?: StyleProp<ViewStyle>;
255
+ colors?: ImagePreviewColors;
256
+ labels?: Partial<ImagePreviewLabels>;
257
+ modalProps?: Omit<React.ComponentProps<typeof Modal>, 'children' | 'onRequestClose' | 'transparent' | 'visible'>;
258
+
259
+ renderHeader?: (context: ImagePreviewRenderContext) => React.ReactNode;
260
+ renderFooter?: (context: ImagePreviewRenderContext) => React.ReactNode;
261
+ renderOverlay?: (context: ImagePreviewRenderContext) => React.ReactNode;
262
+
263
+ onImageLoad?: (
264
+ event: ImageLoadEventData,
265
+ meta: { value: number; image: ImagePreviewResolvedImage }
266
+ ) => void;
267
+ onImageError?: (
268
+ event: ImageErrorEventData,
269
+ meta: { value: number; image: ImagePreviewResolvedImage }
270
+ ) => void;
271
+
272
+ testID?: ViewProps['testID'];
273
+ accessibilityLabel?: ViewProps['accessibilityLabel'];
274
+ };
275
+
276
+ export type ImagePreviewOpenOptions = Omit<
277
+ ImagePreviewProps,
278
+ 'defaultOpen' | 'defaultValue' | 'onClose' | 'open' | 'value'
279
+ > & {
280
+ index?: number;
281
+ onClose?: (result: ImagePreviewResult) => void;
282
+ };
283
+
284
+ export type ImagePreviewServiceHandle = {
285
+ id: number;
286
+ result: Promise<ImagePreviewResult>;
287
+ close: (reason?: ImagePreviewCloseReason) => void;
288
+ setValue: (value: number) => void;
289
+ getSnapshot: () => ImagePreviewServiceSnapshot;
290
+ };
291
+
292
+ export type ImagePreviewServiceSnapshot = {
293
+ open: boolean;
294
+ value: number;
295
+ image: ImagePreviewResolvedImage | null;
296
+ total: number;
297
+ };
298
+
299
+ type ImageSize = {
300
+ width: number;
301
+ height: number;
302
+ };
303
+
304
+ type Viewport = {
305
+ width: number;
306
+ height: number;
307
+ };
308
+
309
+ type GestureMode = 'drag' | 'dismiss' | 'none' | 'pinch' | 'swipe' | 'undecided';
310
+
311
+ type ResolvedInteractions = Required<ImagePreviewInteractions>;
312
+
313
+ type ResolvedColors = Required<ImagePreviewColors>;
314
+
315
+ type ImagePreviewServiceRequest = ImagePreviewOpenOptions & {
316
+ id: number;
317
+ index: number;
318
+ result: Promise<ImagePreviewResult>;
319
+ resolve: (result: ImagePreviewResult) => void;
320
+ settled: boolean;
321
+ };
322
+
323
+ type ImagePreviewServiceController = {
324
+ open: (request: ImagePreviewServiceRequest) => void;
325
+ close: (reason: ImagePreviewCloseReason) => void;
326
+ setValue: (value: number) => void;
327
+ getSnapshot: () => ImagePreviewServiceSnapshot;
328
+ };
329
+
330
+ function isFiniteNumber(value: unknown): value is number {
331
+ return typeof value === 'number' && Number.isFinite(value);
332
+ }
333
+
334
+ function clampNumber(value: number, min: number, max: number) {
335
+ return Math.min(Math.max(value, min), max);
336
+ }
337
+
338
+ function normalizeIndex(index: number | undefined, total: number): number {
339
+ if (total <= 0) return 0;
340
+ if (!isFiniteNumber(index)) return 0;
341
+ return clampNumber(Math.round(index), 0, total - 1);
342
+ }
343
+
344
+ function clampRenderAhead(value: number | undefined) {
345
+ if (!isFiniteNumber(value)) return DEFAULT_RENDER_AHEAD;
346
+ return clampNumber(Math.round(value), 0, MAX_RENDER_AHEAD);
347
+ }
348
+
349
+ function clampV(value: number, min: number, max: number): number {
350
+ 'worklet';
351
+ return Math.min(Math.max(value, min), max);
352
+ }
353
+
354
+ function rubberClampV(value: number, min: number, max: number): number {
355
+ 'worklet';
356
+ if (value < min) return min + (value - min) * EDGE_RESISTANCE;
357
+ if (value > max) return max + (value - max) * EDGE_RESISTANCE;
358
+ return value;
359
+ }
360
+
361
+ function rubberClampAxis(value: number, max: number): number {
362
+ 'worklet';
363
+ if (max <= TRANSLATE_EPSILON) return 0;
364
+ return rubberClampV(value, -max, max);
365
+ }
366
+
367
+ function getViewportPanBounds(nextScale: number, viewportWidth: number, viewportHeight: number) {
368
+ 'worklet';
369
+ const delta = Math.max(0, nextScale - MIN_SCALE);
370
+ return {
371
+ maxX: (viewportWidth * delta) / 2,
372
+ maxY: (viewportHeight * delta) / 2,
373
+ };
374
+ }
375
+
376
+ function hasOwnKey<T extends string>(value: object, key: T): value is Record<T, unknown> {
377
+ return Object.prototype.hasOwnProperty.call(value, key);
378
+ }
379
+
380
+ function isSharedRefLike(value: object) {
381
+ return hasOwnKey(value, 'nativeRefType');
382
+ }
383
+
384
+ function isImageDescriptor(image: ImagePreviewImage): image is ImagePreviewImageDescriptor {
385
+ if (typeof image !== 'object' || image === null || Array.isArray(image)) return false;
386
+ if (isSharedRefLike(image)) return false;
387
+
388
+ return (
389
+ hasOwnKey(image, 'accessibilityLabel') ||
390
+ hasOwnKey(image, 'alt') ||
391
+ hasOwnKey(image, 'blurhash') ||
392
+ hasOwnKey(image, 'cacheKey') ||
393
+ hasOwnKey(image, 'headers') ||
394
+ hasOwnKey(image, 'height') ||
395
+ hasOwnKey(image, 'id') ||
396
+ hasOwnKey(image, 'source') ||
397
+ hasOwnKey(image, 'thumbhash') ||
398
+ hasOwnKey(image, 'uri') ||
399
+ hasOwnKey(image, 'width')
400
+ );
401
+ }
402
+
403
+ function getSourceUri(source: ImagePreviewSource | undefined): string | undefined {
404
+ if (typeof source === 'string') return source;
405
+ if (typeof source === 'number' || source == null) return undefined;
406
+ if (Array.isArray(source)) {
407
+ for (const item of source) {
408
+ const uri = getSourceUri(item as ImagePreviewSource);
409
+ if (uri) return uri;
410
+ }
411
+ return undefined;
412
+ }
413
+ return (source as ImageSource).uri;
414
+ }
415
+
416
+ function isRemoteUri(uri: string | undefined) {
417
+ return !!uri && /^https?:\/\//i.test(uri);
418
+ }
419
+
420
+ function normalizeImageSourceObject(source: ImageSource): ImageSource {
421
+ if (Platform.OS !== 'android' || !isRemoteUri(source.uri)) return source;
422
+
423
+ const headers = source.headers;
424
+ if (headers?.Accept || headers?.accept) return source;
425
+
426
+ return {
427
+ ...source,
428
+ headers: {
429
+ ...headers,
430
+ Accept: ANDROID_REMOTE_ACCEPT_HEADER,
431
+ },
432
+ };
433
+ }
434
+
435
+ function normalizeRenderableSource(source: ImagePreviewSource): ImagePreviewSource {
436
+ if (typeof source === 'string') {
437
+ return normalizeImageSourceObject({ uri: source });
438
+ }
439
+
440
+ if (Array.isArray(source)) {
441
+ return source.map((item) =>
442
+ typeof item === 'string'
443
+ ? normalizeImageSourceObject({ uri: item })
444
+ : normalizeImageSourceObject(item)
445
+ ) as ImagePreviewSource;
446
+ }
447
+
448
+ if (typeof source === 'object' && source !== null && !isSharedRefLike(source)) {
449
+ return normalizeImageSourceObject(source as ImageSource);
450
+ }
451
+
452
+ return source;
453
+ }
454
+
455
+ function getSourceSize(source: ImagePreviewSource | undefined): ImageSize | null {
456
+ if (!source || typeof source === 'string' || typeof source === 'number') return null;
457
+
458
+ if (Array.isArray(source)) {
459
+ for (const item of source) {
460
+ const size = getSourceSize(item as ImagePreviewSource);
461
+ if (size) return size;
462
+ }
463
+ return null;
464
+ }
465
+
466
+ const sourceObject = source as ImageSource;
467
+ const width = sourceObject.width;
468
+ const height = sourceObject.height;
469
+ if (isFiniteNumber(width) && isFiniteNumber(height) && width > 0 && height > 0) {
470
+ return { width, height };
471
+ }
472
+ return null;
473
+ }
474
+
475
+ function isRenderableSource(source: ImagePreviewSource | undefined): source is ImagePreviewSource {
476
+ if (source == null) return false;
477
+ if (typeof source === 'string') return source.trim().length > 0;
478
+ if (Array.isArray(source)) return source.length > 0;
479
+ return true;
480
+ }
481
+
482
+ function createSourceFromDescriptor(image: ImagePreviewImageDescriptor): ImagePreviewSource | undefined {
483
+ if (image.source !== undefined) return image.source;
484
+ const { accessibilityLabel, alt, id, source: _source, ...source } = image;
485
+ return source;
486
+ }
487
+
488
+ function getImageKey(source: ImagePreviewSource, image: ImagePreviewImage, index: number): string {
489
+ if (isImageDescriptor(image) && image.id) return image.id;
490
+
491
+ if (typeof source === 'string' || typeof source === 'number') return String(source);
492
+
493
+ if (Array.isArray(source)) {
494
+ const firstKey = source
495
+ .map((item) => getSourceUri(item as ImagePreviewSource))
496
+ .find((uri): uri is string => !!uri);
497
+ return firstKey ?? `image-${index}`;
498
+ }
499
+
500
+ const sourceObject = source as ImageSource;
501
+ return (
502
+ sourceObject.cacheKey ??
503
+ sourceObject.uri ??
504
+ sourceObject.blurhash ??
505
+ sourceObject.thumbhash ??
506
+ `image-${index}`
507
+ );
508
+ }
509
+
510
+ function normalizeImage(image: ImagePreviewImage, index: number): ImagePreviewResolvedImage | null {
511
+ const rawSource = isImageDescriptor(image) ? createSourceFromDescriptor(image) : image;
512
+ if (!isRenderableSource(rawSource)) return null;
513
+
514
+ const source = normalizeRenderableSource(rawSource);
515
+ const size = getSourceSize(source);
516
+ const descriptor = isImageDescriptor(image) ? image : null;
517
+
518
+ return {
519
+ key: getImageKey(rawSource, image, index),
520
+ source,
521
+ uri: getSourceUri(rawSource),
522
+ width: size?.width,
523
+ height: size?.height,
524
+ alt: descriptor?.alt,
525
+ accessibilityLabel: descriptor?.accessibilityLabel,
526
+ raw: image,
527
+ };
528
+ }
529
+
530
+ function normalizeImages(images: readonly ImagePreviewImage[]): ImagePreviewResolvedImage[] {
531
+ return images
532
+ .map((image, index) => normalizeImage(image, index))
533
+ .filter((image): image is ImagePreviewResolvedImage => image != null);
534
+ }
535
+
536
+ function resolveInteractions(interactions: ImagePreviewInteractions | undefined): ResolvedInteractions {
537
+ return {
538
+ tapBehavior: interactions?.tapBehavior ?? 'none',
539
+ pinchToZoom: interactions?.pinchToZoom ?? true,
540
+ doubleTapToZoom: interactions?.doubleTapToZoom ?? true,
541
+ swipeToChange: interactions?.swipeToChange ?? true,
542
+ swipeDownToClose: interactions?.swipeDownToClose ?? true,
543
+ backToClose: interactions?.backToClose ?? true,
544
+ };
545
+ }
546
+
547
+ function createOpenMeta(
548
+ reason: ImagePreviewOpenChangeReason,
549
+ value: number,
550
+ images: readonly ImagePreviewResolvedImage[]
551
+ ): ImagePreviewOpenChangeMeta {
552
+ return {
553
+ reason,
554
+ value,
555
+ image: images[value] ?? null,
556
+ total: images.length,
557
+ };
558
+ }
559
+
560
+ function createChangeMeta(
561
+ reason: ImagePreviewChangeReason,
562
+ previousValue: number,
563
+ value: number,
564
+ images: readonly ImagePreviewResolvedImage[]
565
+ ): ImagePreviewChangeMeta {
566
+ return {
567
+ reason,
568
+ previousValue,
569
+ value,
570
+ image: images[value] ?? null,
571
+ total: images.length,
572
+ };
573
+ }
574
+
575
+ function createResult(
576
+ reason: ImagePreviewCloseReason,
577
+ value: number,
578
+ images: readonly ImagePreviewResolvedImage[]
579
+ ): ImagePreviewResult {
580
+ const normalizedValue = normalizeIndex(value, images.length);
581
+ return {
582
+ reason,
583
+ value: normalizedValue,
584
+ image: images[normalizedValue] ?? null,
585
+ total: images.length,
586
+ };
587
+ }
588
+
589
+ function getPrefetchUris(
590
+ images: readonly ImagePreviewResolvedImage[],
591
+ value: number,
592
+ mode: ImagePreviewProps['prefetch']
593
+ ) {
594
+ if (mode === false || images.length <= 1) return [];
595
+
596
+ const indexes =
597
+ mode === 'all'
598
+ ? images.map((_, index) => index)
599
+ : [value - 1, value + 1].filter((index) => index >= 0 && index < images.length);
600
+
601
+ const seen = new Set<string>();
602
+ const uris: string[] = [];
603
+ for (const index of indexes) {
604
+ const uri = images[index]?.uri;
605
+ if (!uri || seen.has(uri)) continue;
606
+ seen.add(uri);
607
+ uris.push(uri);
608
+ }
609
+ return uris;
610
+ }
611
+
612
+ class ImagePreviewServiceClass {
613
+ private controller: ImagePreviewServiceController | null = null;
614
+ private currentRequest: ImagePreviewServiceRequest | null = null;
615
+ private requestId = 0;
616
+
617
+ _bind(controller: ImagePreviewServiceController | null) {
618
+ this.controller = controller;
619
+ }
620
+
621
+ open(options: ImagePreviewOpenOptions): ImagePreviewServiceHandle {
622
+ const images = normalizeImages(options.images);
623
+ const id = ++this.requestId;
624
+ let resolveResult: (result: ImagePreviewResult) => void = () => {};
625
+ const result = new Promise<ImagePreviewResult>((resolve) => {
626
+ resolveResult = resolve;
627
+ });
628
+ const request: ImagePreviewServiceRequest = {
629
+ ...options,
630
+ id,
631
+ index: normalizeIndex(options.index, images.length),
632
+ result,
633
+ resolve: resolveResult,
634
+ settled: false,
635
+ };
636
+
637
+ const handle: ImagePreviewServiceHandle = {
638
+ id,
639
+ result,
640
+ close: (reason = 'api') => {
641
+ if (this.currentRequest?.id === id) {
642
+ this.close(reason);
643
+ }
644
+ },
645
+ setValue: (value) => {
646
+ if (this.currentRequest?.id === id) {
647
+ this.setValue(value);
648
+ }
649
+ },
650
+ getSnapshot: () => this.getSnapshot(),
651
+ };
652
+
653
+ if (this.currentRequest && !this.currentRequest.settled) {
654
+ this.finishRequest(this.currentRequest, 'replace');
655
+ }
656
+
657
+ this.currentRequest = request;
658
+
659
+ if (!images.length) {
660
+ this.finishRequest(request, 'empty');
661
+ return handle;
662
+ }
663
+
664
+ if (!this.controller) {
665
+ console.warn('[imagePreview] ImagePreviewProvider is not mounted.');
666
+ this.finishRequest(request, 'unmount');
667
+ return handle;
668
+ }
669
+
670
+ this.controller.open(request);
671
+ return handle;
672
+ }
673
+
674
+ close(reason: ImagePreviewCloseReason = 'api') {
675
+ if (!this.currentRequest || this.currentRequest.settled) return;
676
+ if (!this.controller) {
677
+ this.finishRequest(this.currentRequest, reason);
678
+ return;
679
+ }
680
+ this.controller.close(reason);
681
+ }
682
+
683
+ setValue(value: number) {
684
+ this.controller?.setValue(value);
685
+ }
686
+
687
+ getSnapshot(): ImagePreviewServiceSnapshot {
688
+ return (
689
+ this.controller?.getSnapshot() ?? {
690
+ open: false,
691
+ value: 0,
692
+ image: null,
693
+ total: 0,
694
+ }
695
+ );
696
+ }
697
+
698
+ _complete(id: number, result: ImagePreviewResult) {
699
+ if (this.currentRequest?.id !== id) return;
700
+ this.finishRequest(this.currentRequest, result.reason, result);
701
+ }
702
+
703
+ _dispose(id: number) {
704
+ if (this.currentRequest?.id !== id || this.currentRequest.settled) return;
705
+ this.finishRequest(this.currentRequest, 'unmount');
706
+ }
707
+
708
+ private finishRequest(
709
+ request: ImagePreviewServiceRequest,
710
+ reason: ImagePreviewCloseReason,
711
+ result?: ImagePreviewResult
712
+ ) {
713
+ if (request.settled) return;
714
+ request.settled = true;
715
+ const finalResult = result ?? createResult(reason, request.index, normalizeImages(request.images));
716
+ request.onClose?.(finalResult);
717
+ request.resolve(finalResult);
718
+ if (this.currentRequest?.id === request.id) {
719
+ this.currentRequest = null;
720
+ }
721
+ }
722
+ }
723
+
724
+ export const imagePreview = new ImagePreviewServiceClass();
725
+
726
+ function CloseButton({
727
+ color,
728
+ backgroundColor,
729
+ label,
730
+ onPress,
731
+ }: {
732
+ color: string;
733
+ backgroundColor: string;
734
+ label: string;
735
+ onPress: () => void;
736
+ }) {
737
+ return (
738
+ <Pressable
739
+ accessibilityLabel={label}
740
+ accessibilityRole="button"
741
+ hitSlop={CONTROL_HIT_SLOP}
742
+ onPress={onPress}
743
+ style={({ pressed }) => [
744
+ styles.closeButton,
745
+ { backgroundColor, opacity: pressed ? 0.72 : 1 },
746
+ ]}
747
+ >
748
+ <Feather color={color} name="x" size={CLOSE_ICON_SIZE} />
749
+ </Pressable>
750
+ );
751
+ }
752
+
753
+ function ZoomableImage({
754
+ active,
755
+ contentFit,
756
+ currentIndexSV,
757
+ dismissible,
758
+ image,
759
+ imageAccessibilityLabel,
760
+ imageProps,
761
+ imageStyle,
762
+ index,
763
+ interactions,
764
+ labels,
765
+ onImageError,
766
+ onImageLoad,
767
+ onPageChange,
768
+ onRequestClose,
769
+ onSingleTap,
770
+ pageTranslateX,
771
+ totalCount,
772
+ viewport,
773
+ backdropOpacity,
774
+ colors,
775
+ }: {
776
+ active: boolean;
777
+ contentFit: NonNullable<ImageProps['contentFit']>;
778
+ currentIndexSV: SharedValue<number>;
779
+ dismissible: boolean;
780
+ image: ImagePreviewResolvedImage;
781
+ imageAccessibilityLabel: string;
782
+ imageProps?: ImagePreviewImageProps;
783
+ imageStyle?: StyleProp<ImageStyle>;
784
+ index: number;
785
+ interactions: ResolvedInteractions;
786
+ labels: ImagePreviewLabels;
787
+ onImageError?: (
788
+ event: ImageErrorEventData,
789
+ meta: { value: number; image: ImagePreviewResolvedImage }
790
+ ) => void;
791
+ onImageLoad?: (
792
+ event: ImageLoadEventData,
793
+ meta: { value: number; image: ImagePreviewResolvedImage }
794
+ ) => void;
795
+ onPageChange: (newIndex: number) => void;
796
+ onRequestClose: (reason: ImagePreviewCloseReason) => void;
797
+ onSingleTap: () => void;
798
+ pageTranslateX: SharedValue<number>;
799
+ totalCount: number;
800
+ viewport: Viewport;
801
+ backdropOpacity: SharedValue<number>;
802
+ colors: ResolvedColors;
803
+ }) {
804
+ const [failed, setFailed] = React.useState(false);
805
+ const [loading, setLoading] = React.useState(true);
806
+ const imageCachePolicy = imageProps?.cachePolicy ?? 'memory-disk';
807
+ const imagePriority = imageProps?.priority ?? (active ? 'high' : 'low');
808
+ const viewportStyle = React.useMemo(
809
+ () => ({ width: viewport.width, height: viewport.height }),
810
+ [viewport.height, viewport.width]
811
+ );
812
+
813
+ const scale = useSharedValue(1);
814
+ const savedScale = useSharedValue(1);
815
+ const translateX = useSharedValue(0);
816
+ const translateY = useSharedValue(0);
817
+ const savedTranslateX = useSharedValue(0);
818
+ const savedTranslateY = useSharedValue(0);
819
+ const dismissY = useSharedValue(0);
820
+ const mode = useSharedValue<GestureMode>('none');
821
+ const tapSuppression = useSharedValue(0);
822
+ const pinchActive = useSharedValue(0);
823
+ const gestureFocalX = useSharedValue(0);
824
+ const gestureFocalY = useSharedValue(0);
825
+ const panOffsetX = useSharedValue(0);
826
+ const panOffsetY = useSharedValue(0);
827
+ const gestureBoundX = useSharedValue(0);
828
+ const gestureBoundY = useSharedValue(0);
829
+
830
+ React.useEffect(() => {
831
+ setFailed(false);
832
+ setLoading(true);
833
+ }, [image.key]);
834
+
835
+ React.useEffect(() => {
836
+ if (mode.value !== 'none') return;
837
+
838
+ const bounds = getViewportPanBounds(scale.value, viewport.width, viewport.height);
839
+ const clamped = {
840
+ x: clampV(translateX.value, -bounds.maxX, bounds.maxX),
841
+ y: clampV(translateY.value, -bounds.maxY, bounds.maxY),
842
+ };
843
+
844
+ translateX.value = withSpring(clamped.x, SPRING_CONFIG);
845
+ translateY.value = withSpring(clamped.y, SPRING_CONFIG);
846
+ savedTranslateX.value = clamped.x;
847
+ savedTranslateY.value = clamped.y;
848
+ // eslint-disable-next-line react-hooks/exhaustive-deps
849
+ }, [viewport.height, viewport.width]);
850
+
851
+ const handleImageLoadStart = React.useCallback(() => {
852
+ setFailed(false);
853
+ setLoading(true);
854
+ }, []);
855
+
856
+ const handleImageLoad = React.useCallback(
857
+ (event: ImageLoadEventData) => {
858
+ setLoading(false);
859
+ onImageLoad?.(event, { value: index, image });
860
+ },
861
+ [image, index, onImageLoad]
862
+ );
863
+
864
+ const handleImageError = React.useCallback(
865
+ (event: ImageErrorEventData) => {
866
+ setFailed(true);
867
+ setLoading(false);
868
+ onImageError?.(event, { value: index, image });
869
+ },
870
+ [image, index, onImageError]
871
+ );
872
+
873
+ const resetImageTransform = () => {
874
+ 'worklet';
875
+ cancelAnimation(scale);
876
+ cancelAnimation(translateX);
877
+ cancelAnimation(translateY);
878
+ scale.value = withTiming(MIN_SCALE, RESET_TIMING);
879
+ savedScale.value = MIN_SCALE;
880
+ translateX.value = withTiming(0, RESET_TIMING);
881
+ translateY.value = withTiming(0, RESET_TIMING);
882
+ savedTranslateX.value = 0;
883
+ savedTranslateY.value = 0;
884
+ };
885
+
886
+ const blockTap = () => {
887
+ 'worklet';
888
+ cancelAnimation(tapSuppression);
889
+ tapSuppression.value = 1;
890
+ };
891
+
892
+ const releaseTap = () => {
893
+ 'worklet';
894
+ tapSuppression.value = withTiming(0, { duration: TAP_SUPPRESSION_RELEASE_DURATION });
895
+ };
896
+
897
+ const settleImageTransform = () => {
898
+ 'worklet';
899
+ cancelAnimation(scale);
900
+ cancelAnimation(translateX);
901
+ cancelAnimation(translateY);
902
+
903
+ const currentScale = scale.value;
904
+ let targetScale = clampV(currentScale, MIN_SCALE, MAX_SCALE);
905
+ if (targetScale <= MIN_SCALE + SCALE_EPSILON) {
906
+ targetScale = MIN_SCALE;
907
+ if (Math.abs(currentScale - targetScale) > SCALE_EPSILON) {
908
+ scale.value = withSpring(targetScale, SPRING_CONFIG);
909
+ } else {
910
+ scale.value = targetScale;
911
+ }
912
+ if (Math.abs(translateX.value) > TRANSLATE_EPSILON) {
913
+ translateX.value = withSpring(0, SPRING_CONFIG);
914
+ } else {
915
+ translateX.value = 0;
916
+ }
917
+ if (Math.abs(translateY.value) > TRANSLATE_EPSILON) {
918
+ translateY.value = withSpring(0, SPRING_CONFIG);
919
+ } else {
920
+ translateY.value = 0;
921
+ }
922
+ savedScale.value = targetScale;
923
+ savedTranslateX.value = 0;
924
+ savedTranslateY.value = 0;
925
+ return;
926
+ }
927
+
928
+ const bounds = getViewportPanBounds(targetScale, viewport.width, viewport.height);
929
+ const clamped = {
930
+ x: clampV(translateX.value, -bounds.maxX, bounds.maxX),
931
+ y: clampV(translateY.value, -bounds.maxY, bounds.maxY),
932
+ };
933
+
934
+ if (Math.abs(currentScale - targetScale) > SCALE_EPSILON) {
935
+ scale.value = withSpring(targetScale, SPRING_CONFIG);
936
+ } else {
937
+ scale.value = targetScale;
938
+ }
939
+ if (Math.abs(translateX.value - clamped.x) > TRANSLATE_EPSILON) {
940
+ translateX.value = withSpring(clamped.x, SPRING_CONFIG);
941
+ } else {
942
+ translateX.value = clamped.x;
943
+ }
944
+ if (Math.abs(translateY.value - clamped.y) > TRANSLATE_EPSILON) {
945
+ translateY.value = withSpring(clamped.y, SPRING_CONFIG);
946
+ } else {
947
+ translateY.value = clamped.y;
948
+ }
949
+ savedScale.value = targetScale;
950
+ savedTranslateX.value = clamped.x;
951
+ savedTranslateY.value = clamped.y;
952
+ };
953
+
954
+ useAnimatedReaction(
955
+ () => currentIndexSV.value,
956
+ (curr, prev) => {
957
+ if (prev !== null && prev === index && curr !== index) {
958
+ resetImageTransform();
959
+ dismissY.value = 0;
960
+ mode.value = 'none';
961
+ }
962
+ }
963
+ );
964
+
965
+ const singleTap = Gesture.Tap()
966
+ .enabled(active && interactions.tapBehavior !== 'none')
967
+ .numberOfTaps(1)
968
+ .maxDuration(220)
969
+ .onEnd(() => {
970
+ 'worklet';
971
+ if (currentIndexSV.value !== index) return;
972
+ if (mode.value !== 'none') return;
973
+ if (tapSuppression.value > 0.01) return;
974
+ if (scale.value > 1.05) return;
975
+ if (interactions.tapBehavior === 'close') {
976
+ if (dismissible) scheduleOnRN(onRequestClose, 'tap');
977
+ return;
978
+ }
979
+ scheduleOnRN(onSingleTap);
980
+ });
981
+
982
+ const doubleTap = Gesture.Tap()
983
+ .enabled(active && interactions.doubleTapToZoom)
984
+ .numberOfTaps(2)
985
+ .maxDuration(300)
986
+ .onEnd((event) => {
987
+ 'worklet';
988
+ if (currentIndexSV.value !== index) return;
989
+ if (mode.value !== 'none') return;
990
+ if (tapSuppression.value > 0.01) return;
991
+ cancelAnimation(scale);
992
+ cancelAnimation(translateX);
993
+ cancelAnimation(translateY);
994
+
995
+ if (scale.value > 1.05) {
996
+ resetImageTransform();
997
+ } else {
998
+ const targetScale = Math.min(DOUBLE_TAP_SCALE, MAX_SCALE);
999
+ const focalX = event.x - viewport.width / 2;
1000
+ const focalY = event.y - viewport.height / 2;
1001
+ const bounds = getViewportPanBounds(targetScale, viewport.width, viewport.height);
1002
+ const targetX = clampV(-focalX * (targetScale - MIN_SCALE), -bounds.maxX, bounds.maxX);
1003
+ const targetY = clampV(-focalY * (targetScale - MIN_SCALE), -bounds.maxY, bounds.maxY);
1004
+
1005
+ scale.value = withTiming(targetScale, RESET_TIMING);
1006
+ savedScale.value = targetScale;
1007
+ translateX.value = withTiming(targetX, RESET_TIMING);
1008
+ translateY.value = withTiming(targetY, RESET_TIMING);
1009
+ savedTranslateX.value = targetX;
1010
+ savedTranslateY.value = targetY;
1011
+ }
1012
+ });
1013
+
1014
+ const pinch = Gesture.Pinch()
1015
+ .enabled(active && interactions.pinchToZoom)
1016
+ .onStart((event) => {
1017
+ 'worklet';
1018
+ if (currentIndexSV.value !== index) return;
1019
+ pinchActive.value = 1;
1020
+ blockTap();
1021
+ mode.value = 'pinch';
1022
+ cancelAnimation(scale);
1023
+ cancelAnimation(translateX);
1024
+ cancelAnimation(translateY);
1025
+ cancelAnimation(pageTranslateX);
1026
+ cancelAnimation(dismissY);
1027
+ pageTranslateX.value = 0;
1028
+ dismissY.value = 0;
1029
+ backdropOpacity.value = withSpring(1, SPRING_CONFIG);
1030
+
1031
+ savedScale.value = scale.value;
1032
+ savedTranslateX.value = translateX.value;
1033
+ savedTranslateY.value = translateY.value;
1034
+ gestureFocalX.value = event.focalX - viewport.width / 2;
1035
+ gestureFocalY.value = event.focalY - viewport.height / 2;
1036
+ })
1037
+ .onUpdate((event) => {
1038
+ 'worklet';
1039
+ if (currentIndexSV.value !== index) return;
1040
+ if (event.numberOfPointers < 2) return;
1041
+ const nextScale = clampV(
1042
+ savedScale.value * event.scale,
1043
+ MIN_SCALE * 0.5,
1044
+ MAX_SCALE + MIN_SCALE
1045
+ );
1046
+
1047
+ scale.value = nextScale;
1048
+ translateX.value =
1049
+ savedTranslateX.value + (savedScale.value - nextScale) * gestureFocalX.value;
1050
+ translateY.value =
1051
+ savedTranslateY.value + (savedScale.value - nextScale) * gestureFocalY.value;
1052
+ })
1053
+ .onEnd(() => {
1054
+ 'worklet';
1055
+ if (currentIndexSV.value !== index) {
1056
+ pinchActive.value = 0;
1057
+ return;
1058
+ }
1059
+ settleImageTransform();
1060
+ mode.value = 'none';
1061
+ pinchActive.value = 0;
1062
+ releaseTap();
1063
+ })
1064
+ .onFinalize(() => {
1065
+ 'worklet';
1066
+ if (pinchActive.value > 0 || mode.value === 'pinch') {
1067
+ settleImageTransform();
1068
+ mode.value = 'none';
1069
+ }
1070
+ pinchActive.value = 0;
1071
+ releaseTap();
1072
+ });
1073
+
1074
+ const pan = Gesture.Pan()
1075
+ .enabled(
1076
+ active &&
1077
+ (interactions.pinchToZoom ||
1078
+ interactions.doubleTapToZoom ||
1079
+ interactions.swipeToChange ||
1080
+ interactions.swipeDownToClose)
1081
+ )
1082
+ .minPointers(1)
1083
+ .minDistance(1)
1084
+ .averageTouches(true)
1085
+ .onStart((event) => {
1086
+ 'worklet';
1087
+ if (currentIndexSV.value !== index) return;
1088
+ blockTap();
1089
+
1090
+ cancelAnimation(translateX);
1091
+ cancelAnimation(translateY);
1092
+ cancelAnimation(pageTranslateX);
1093
+ cancelAnimation(dismissY);
1094
+ cancelAnimation(backdropOpacity);
1095
+
1096
+ savedTranslateX.value = translateX.value;
1097
+ savedTranslateY.value = translateY.value;
1098
+ panOffsetX.value = 0;
1099
+ panOffsetY.value = 0;
1100
+ const bounds = getViewportPanBounds(scale.value, viewport.width, viewport.height);
1101
+ gestureBoundX.value = bounds.maxX;
1102
+ gestureBoundY.value = bounds.maxY;
1103
+ pageTranslateX.value = 0;
1104
+ dismissY.value = 0;
1105
+ backdropOpacity.value = withSpring(1, SPRING_CONFIG);
1106
+ if (event.numberOfPointers >= 2 || pinchActive.value > 0 || mode.value === 'pinch') {
1107
+ mode.value = 'none';
1108
+ } else {
1109
+ mode.value = scale.value > MIN_SCALE + SCALE_EPSILON ? 'drag' : 'undecided';
1110
+ }
1111
+ })
1112
+ .onUpdate((event) => {
1113
+ 'worklet';
1114
+ if (currentIndexSV.value !== index) return;
1115
+ if (pinchActive.value > 0) {
1116
+ panOffsetX.value = event.translationX;
1117
+ panOffsetY.value = event.translationY;
1118
+ return;
1119
+ }
1120
+
1121
+ if (mode.value === 'none') {
1122
+ savedTranslateX.value = translateX.value;
1123
+ savedTranslateY.value = translateY.value;
1124
+ panOffsetX.value = event.translationX;
1125
+ panOffsetY.value = event.translationY;
1126
+ const bounds = getViewportPanBounds(scale.value, viewport.width, viewport.height);
1127
+ gestureBoundX.value = bounds.maxX;
1128
+ gestureBoundY.value = bounds.maxY;
1129
+ mode.value = scale.value > MIN_SCALE + SCALE_EPSILON ? 'drag' : 'undecided';
1130
+ return;
1131
+ }
1132
+
1133
+ const deltaX = event.translationX - panOffsetX.value;
1134
+ const deltaY = event.translationY - panOffsetY.value;
1135
+
1136
+ if (mode.value === 'undecided') {
1137
+ const absX = Math.abs(deltaX);
1138
+ const absY = Math.abs(deltaY);
1139
+ if (absX <= MODE_LOCK_DISTANCE && absY <= MODE_LOCK_DISTANCE) return;
1140
+
1141
+ if (absX >= absY) {
1142
+ if (!interactions.swipeToChange) return;
1143
+ mode.value = 'swipe';
1144
+ } else {
1145
+ if (!dismissible || !interactions.swipeDownToClose) return;
1146
+ mode.value = 'dismiss';
1147
+ }
1148
+ }
1149
+
1150
+ if (mode.value === 'drag') {
1151
+ const nextX = savedTranslateX.value + deltaX;
1152
+ const nextY = savedTranslateY.value + deltaY;
1153
+ translateX.value = rubberClampAxis(nextX, gestureBoundX.value);
1154
+ translateY.value = rubberClampAxis(nextY, gestureBoundY.value);
1155
+ } else if (mode.value === 'swipe') {
1156
+ let tx = deltaX;
1157
+ const canNext = currentIndexSV.value < totalCount - 1;
1158
+ const canPrev = currentIndexSV.value > 0;
1159
+ if ((!canPrev && tx > 0) || (!canNext && tx < 0)) {
1160
+ tx *= EDGE_RESISTANCE;
1161
+ }
1162
+ pageTranslateX.value = tx;
1163
+ } else if (mode.value === 'dismiss') {
1164
+ const dragY = Math.max(0, deltaY);
1165
+ dismissY.value = dragY;
1166
+ const progress = dragY / (viewport.height * 0.42);
1167
+ backdropOpacity.value = interpolate(
1168
+ progress,
1169
+ [0, 1],
1170
+ [1, 0.1],
1171
+ Extrapolation.CLAMP
1172
+ );
1173
+ }
1174
+ })
1175
+ .onEnd((event) => {
1176
+ 'worklet';
1177
+ if (currentIndexSV.value !== index) return;
1178
+ if (pinchActive.value > 0 || mode.value === 'pinch') {
1179
+ mode.value = 'none';
1180
+ return;
1181
+ }
1182
+
1183
+ const deltaX = event.translationX - panOffsetX.value;
1184
+ const deltaY = event.translationY - panOffsetY.value;
1185
+
1186
+ if (mode.value === 'drag') {
1187
+ settleImageTransform();
1188
+ releaseTap();
1189
+ } else if (mode.value === 'swipe') {
1190
+ const canNext = currentIndexSV.value < totalCount - 1;
1191
+ const canPrev = currentIndexSV.value > 0;
1192
+ const swipeThreshold = viewport.width * 0.23;
1193
+ const goNext =
1194
+ canNext && (deltaX < -swipeThreshold || event.velocityX < -VELOCITY_THRESHOLD);
1195
+ const goPrev =
1196
+ canPrev && (deltaX > swipeThreshold || event.velocityX > VELOCITY_THRESHOLD);
1197
+
1198
+ if (goNext) {
1199
+ const target = currentIndexSV.value + 1;
1200
+ pageTranslateX.value = withSpring(-viewport.width, SPRING_CONFIG, (finished) => {
1201
+ 'worklet';
1202
+ if (finished) {
1203
+ currentIndexSV.value = target;
1204
+ pageTranslateX.value = 0;
1205
+ releaseTap();
1206
+ scheduleOnRN(onPageChange, target);
1207
+ }
1208
+ });
1209
+ } else if (goPrev) {
1210
+ const target = currentIndexSV.value - 1;
1211
+ pageTranslateX.value = withSpring(viewport.width, SPRING_CONFIG, (finished) => {
1212
+ 'worklet';
1213
+ if (finished) {
1214
+ currentIndexSV.value = target;
1215
+ pageTranslateX.value = 0;
1216
+ releaseTap();
1217
+ scheduleOnRN(onPageChange, target);
1218
+ }
1219
+ });
1220
+ } else {
1221
+ pageTranslateX.value = withSpring(0, SPRING_CONFIG, (finished) => {
1222
+ 'worklet';
1223
+ if (finished) releaseTap();
1224
+ });
1225
+ }
1226
+ } else if (mode.value === 'dismiss') {
1227
+ const dismissThreshold = viewport.height * 0.12;
1228
+ if (deltaY > dismissThreshold || event.velocityY > DISMISS_VELOCITY_THRESHOLD) {
1229
+ dismissY.value = withTiming(viewport.height, EXIT_TIMING);
1230
+ backdropOpacity.value = withTiming(0, EXIT_TIMING, () => {
1231
+ scheduleOnRN(onRequestClose, 'swipeDown');
1232
+ });
1233
+ } else {
1234
+ dismissY.value = withSpring(0, SPRING_CONFIG);
1235
+ backdropOpacity.value = withSpring(1, SPRING_CONFIG);
1236
+ releaseTap();
1237
+ }
1238
+ } else {
1239
+ releaseTap();
1240
+ }
1241
+
1242
+ mode.value = 'none';
1243
+ })
1244
+ .onFinalize(() => {
1245
+ 'worklet';
1246
+ if (pinchActive.value > 0 || mode.value === 'pinch') return;
1247
+ if (mode.value === 'none') return;
1248
+ if (mode.value === 'drag') {
1249
+ settleImageTransform();
1250
+ } else if (mode.value === 'swipe') {
1251
+ pageTranslateX.value = withSpring(0, SPRING_CONFIG);
1252
+ } else if (mode.value === 'dismiss') {
1253
+ dismissY.value = withSpring(0, SPRING_CONFIG);
1254
+ backdropOpacity.value = withSpring(1, SPRING_CONFIG);
1255
+ }
1256
+ mode.value = 'none';
1257
+ releaseTap();
1258
+ });
1259
+
1260
+ const tapGestures = Gesture.Exclusive(doubleTap, singleTap);
1261
+ const composed = Gesture.Simultaneous(tapGestures, Gesture.Simultaneous(pinch, pan));
1262
+
1263
+ const pageStyle = useAnimatedStyle(() => {
1264
+ const pageX = (index - currentIndexSV.value) * viewport.width + pageTranslateX.value;
1265
+ return {
1266
+ opacity: backdropOpacity.value,
1267
+ transform: [
1268
+ { translateX: pageX + translateX.value },
1269
+ { translateY: translateY.value + dismissY.value },
1270
+ { scale: scale.value },
1271
+ ],
1272
+ };
1273
+ });
1274
+
1275
+ return (
1276
+ <GestureDetector gesture={composed}>
1277
+ <Animated.View
1278
+ pointerEvents={active ? 'auto' : 'none'}
1279
+ style={[styles.imagePage, viewportStyle, pageStyle]}
1280
+ >
1281
+ <Image
1282
+ {...imageProps}
1283
+ accessibilityLabel={imageAccessibilityLabel}
1284
+ alt={imageAccessibilityLabel}
1285
+ cachePolicy={imageCachePolicy}
1286
+ contentFit={contentFit}
1287
+ onError={handleImageError}
1288
+ onLoad={handleImageLoad}
1289
+ onLoadStart={handleImageLoadStart}
1290
+ priority={imagePriority}
1291
+ recyclingKey={image.key}
1292
+ source={image.source}
1293
+ style={[viewportStyle, imageStyle]}
1294
+ />
1295
+
1296
+ {loading && !failed && (
1297
+ <View pointerEvents="none" style={styles.loadingState}>
1298
+ <LoadingSpinner
1299
+ color={colors.loading}
1300
+ size={LOADING_SPINNER_SIZE}
1301
+ speed={1.8}
1302
+ />
1303
+ </View>
1304
+ )}
1305
+
1306
+ {failed && (
1307
+ <View pointerEvents="none" style={styles.errorState}>
1308
+ <View style={[styles.errorCard, { backgroundColor: colors.errorBackground }]}>
1309
+ <Feather
1310
+ color={colors.errorText}
1311
+ name="image"
1312
+ size={ERROR_ICON_SIZE}
1313
+ style={styles.errorIcon}
1314
+ />
1315
+ <Text
1316
+ align="center"
1317
+ color={colors.errorText}
1318
+ lineHeight={ERROR_TEXT_LINE_HEIGHT}
1319
+ size={ERROR_TEXT_SIZE}
1320
+ weight="medium"
1321
+ >
1322
+ {labels.loadFailed}
1323
+ </Text>
1324
+ </View>
1325
+ </View>
1326
+ )}
1327
+ </Animated.View>
1328
+ </GestureDetector>
1329
+ );
1330
+ }
1331
+
1332
+ type ImagePreviewOverlayProps = {
1333
+ accessibilityLabel?: ViewProps['accessibilityLabel'];
1334
+ closeButton: boolean;
1335
+ colors: ResolvedColors;
1336
+ contentFit: NonNullable<ImageProps['contentFit']>;
1337
+ counter: boolean;
1338
+ disabled: boolean;
1339
+ dismissible: boolean;
1340
+ imageProps?: ImagePreviewImageProps;
1341
+ imageStyle?: StyleProp<ImageStyle>;
1342
+ images: readonly ImagePreviewResolvedImage[];
1343
+ interactions: ResolvedInteractions;
1344
+ labels: ImagePreviewLabels;
1345
+ modalProps?: ImagePreviewProps['modalProps'];
1346
+ onChange: (value: number, reason: ImagePreviewChangeReason) => void;
1347
+ onCloseComplete: (result: ImagePreviewResult) => void;
1348
+ onImageError?: ImagePreviewProps['onImageError'];
1349
+ onImageLoad?: ImagePreviewProps['onImageLoad'];
1350
+ onOpenChange: (open: boolean, meta: ImagePreviewOpenChangeMeta) => void;
1351
+ open: boolean;
1352
+ prefetch: ImagePreviewProps['prefetch'];
1353
+ prefetchCachePolicy: ImagePreviewPrefetchCachePolicy;
1354
+ renderAhead: number;
1355
+ renderFooter?: ImagePreviewProps['renderFooter'];
1356
+ renderHeader?: ImagePreviewProps['renderHeader'];
1357
+ renderOverlay?: ImagePreviewProps['renderOverlay'];
1358
+ rootStyle?: StyleProp<ViewStyle>;
1359
+ testID?: ViewProps['testID'];
1360
+ value: number;
1361
+ };
1362
+
1363
+ function ImagePreviewOverlay({
1364
+ accessibilityLabel,
1365
+ closeButton,
1366
+ colors,
1367
+ contentFit,
1368
+ counter,
1369
+ disabled,
1370
+ dismissible,
1371
+ imageProps,
1372
+ imageStyle,
1373
+ images,
1374
+ interactions,
1375
+ labels,
1376
+ modalProps,
1377
+ onChange,
1378
+ onCloseComplete,
1379
+ onImageError,
1380
+ onImageLoad,
1381
+ onOpenChange,
1382
+ open,
1383
+ prefetch,
1384
+ prefetchCachePolicy,
1385
+ renderAhead,
1386
+ renderFooter,
1387
+ renderHeader,
1388
+ renderOverlay,
1389
+ rootStyle,
1390
+ testID,
1391
+ value,
1392
+ }: ImagePreviewOverlayProps) {
1393
+ const insets = useSafeAreaInsets();
1394
+ const window = useWindowDimensions();
1395
+ const [mounted, setMounted] = React.useState(false);
1396
+ const [closing, setClosing] = React.useState(false);
1397
+ const [renderIndex, setRenderIndex] = React.useState(() => normalizeIndex(value, images.length));
1398
+ const [chromeVisible, setChromeVisible] = React.useState(true);
1399
+ const closeReasonRef = React.useRef<ImagePreviewCloseReason>('api');
1400
+ const closingRef = React.useRef(false);
1401
+ const imagesRef = React.useRef(images);
1402
+ const onCloseCompleteRef = React.useRef(onCloseComplete);
1403
+ const renderIndexRef = React.useRef(renderIndex);
1404
+ imagesRef.current = images;
1405
+ onCloseCompleteRef.current = onCloseComplete;
1406
+ renderIndexRef.current = renderIndex;
1407
+
1408
+ const backdropOpacity = useSharedValue(0);
1409
+ const chromeOpacity = useSharedValue(1);
1410
+ const pageTranslateX = useSharedValue(0);
1411
+ const currentIndexSV = useSharedValue(normalizeIndex(value, images.length));
1412
+
1413
+ const viewport = React.useMemo(
1414
+ () => ({ width: window.width, height: window.height }),
1415
+ [window.height, window.width]
1416
+ );
1417
+ const total = images.length;
1418
+ const normalizedValue = normalizeIndex(value, total);
1419
+ const currentImage = images[renderIndex] ?? images[normalizedValue] ?? null;
1420
+
1421
+ const setChromeVisibleAnimated = React.useCallback(
1422
+ (nextVisible: boolean) => {
1423
+ setChromeVisible(nextVisible);
1424
+ chromeOpacity.value = withTiming(nextVisible ? 1 : 0, CHROME_TIMING);
1425
+ },
1426
+ [chromeOpacity]
1427
+ );
1428
+
1429
+ const requestClose = React.useCallback(
1430
+ (reason: ImagePreviewCloseReason) => {
1431
+ if (disabled) return;
1432
+ if (
1433
+ !dismissible &&
1434
+ reason !== 'api' &&
1435
+ reason !== 'empty' &&
1436
+ reason !== 'replace' &&
1437
+ reason !== 'unmount'
1438
+ ) {
1439
+ return;
1440
+ }
1441
+ if (closingRef.current) return;
1442
+ closeReasonRef.current = reason;
1443
+ onOpenChange(false, createOpenMeta(reason, renderIndexRef.current, images));
1444
+ },
1445
+ [disabled, dismissible, images, onOpenChange]
1446
+ );
1447
+
1448
+ const finishClose = React.useCallback(() => {
1449
+ const result = createResult(
1450
+ closeReasonRef.current,
1451
+ renderIndexRef.current,
1452
+ imagesRef.current
1453
+ );
1454
+ setMounted(false);
1455
+ setClosing(false);
1456
+ onCloseCompleteRef.current(result);
1457
+ }, []);
1458
+
1459
+ const jumpToValue = React.useCallback(
1460
+ (nextValue: number, reason: ImagePreviewChangeReason) => {
1461
+ const target = normalizeIndex(nextValue, total);
1462
+ const previousValue = renderIndexRef.current;
1463
+ if (target === previousValue) return;
1464
+ currentIndexSV.value = target;
1465
+ pageTranslateX.value = 0;
1466
+ setRenderIndex(target);
1467
+ onChange(target, reason);
1468
+ },
1469
+ [currentIndexSV, onChange, pageTranslateX, total]
1470
+ );
1471
+
1472
+ const renderContext = React.useMemo<ImagePreviewRenderContext>(
1473
+ () => ({
1474
+ open,
1475
+ value: renderIndex,
1476
+ image: currentImage,
1477
+ total,
1478
+ close: (reason = 'api') => requestClose(reason),
1479
+ setValue: (nextValue) => jumpToValue(nextValue, 'api'),
1480
+ }),
1481
+ [currentImage, jumpToValue, open, renderIndex, requestClose, total]
1482
+ );
1483
+
1484
+ React.useEffect(() => {
1485
+ if (open && total > 0) {
1486
+ const nextValue = normalizeIndex(value, total);
1487
+ closingRef.current = false;
1488
+ closeReasonRef.current = 'api';
1489
+ setClosing(false);
1490
+ setChromeVisible(true);
1491
+ chromeOpacity.value = 1;
1492
+ currentIndexSV.value = nextValue;
1493
+ pageTranslateX.value = 0;
1494
+ setRenderIndex(nextValue);
1495
+ setMounted(true);
1496
+ backdropOpacity.value = withTiming(1, ENTER_TIMING);
1497
+ } else if (mounted) {
1498
+ closingRef.current = true;
1499
+ setClosing(true);
1500
+ backdropOpacity.value = withTiming(0, EXIT_TIMING, (finished) => {
1501
+ if (finished) {
1502
+ scheduleOnRN(finishClose);
1503
+ }
1504
+ });
1505
+ }
1506
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1507
+ }, [open, total]);
1508
+
1509
+ React.useEffect(() => {
1510
+ if (!open || total <= 0) return;
1511
+ const nextValue = normalizeIndex(value, total);
1512
+ currentIndexSV.value = nextValue;
1513
+ pageTranslateX.value = 0;
1514
+ setRenderIndex(nextValue);
1515
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1516
+ }, [value, total]);
1517
+
1518
+ React.useEffect(() => {
1519
+ if (!mounted || prefetch === false) return;
1520
+ const uris = getPrefetchUris(images, renderIndex, prefetch ?? 'adjacent');
1521
+ if (!uris.length) return;
1522
+ Image.prefetch(uris, prefetchCachePolicy).catch(() => {});
1523
+ }, [images, mounted, prefetch, prefetchCachePolicy, renderIndex]);
1524
+
1525
+ useAnimatedReaction(
1526
+ () => currentIndexSV.value,
1527
+ (nextValue) => {
1528
+ scheduleOnRN(setRenderIndex, nextValue);
1529
+ }
1530
+ );
1531
+
1532
+ const handlePageChange = React.useCallback(
1533
+ (nextValue: number) => {
1534
+ setRenderIndex(nextValue);
1535
+ onChange(nextValue, 'swipe');
1536
+ },
1537
+ [onChange]
1538
+ );
1539
+
1540
+ const handleImageLoad = React.useCallback(
1541
+ (event: ImageLoadEventData, meta: { value: number; image: ImagePreviewResolvedImage }) => {
1542
+ onImageLoad?.(event, meta);
1543
+ },
1544
+ [onImageLoad]
1545
+ );
1546
+
1547
+ const handleImageError = React.useCallback(
1548
+ (event: ImageErrorEventData, meta: { value: number; image: ImagePreviewResolvedImage }) => {
1549
+ if (meta.value === renderIndexRef.current && typeof __DEV__ !== 'undefined' && __DEV__) {
1550
+ console.warn('[imagePreview] Failed to load image', {
1551
+ error: event.error,
1552
+ uri: meta.image.uri,
1553
+ });
1554
+ }
1555
+ onImageError?.(event, meta);
1556
+ },
1557
+ [onImageError]
1558
+ );
1559
+
1560
+ const handleSingleTap = React.useCallback(() => {
1561
+ if (interactions.tapBehavior !== 'toggleChrome') return;
1562
+ setChromeVisibleAnimated(!chromeVisible);
1563
+ }, [chromeVisible, interactions.tapBehavior, setChromeVisibleAnimated]);
1564
+
1565
+ const handleModalRequestClose = React.useCallback(() => {
1566
+ if (!interactions.backToClose) return;
1567
+ requestClose('back');
1568
+ }, [interactions.backToClose, requestClose]);
1569
+
1570
+ const handleAccessibilityEscape = React.useCallback(() => {
1571
+ requestClose('back');
1572
+ }, [requestClose]);
1573
+
1574
+ const backdropStyle = useAnimatedStyle(() => ({
1575
+ opacity: backdropOpacity.value,
1576
+ }));
1577
+
1578
+ const chromeStyle = useAnimatedStyle(() => ({
1579
+ opacity: backdropOpacity.value * chromeOpacity.value,
1580
+ }));
1581
+
1582
+ if (!mounted) return null;
1583
+
1584
+ const lower = Math.max(0, renderIndex - renderAhead);
1585
+ const upper = Math.min(total - 1, renderIndex + renderAhead);
1586
+ const renderRange: number[] = [];
1587
+ for (let index = lower; index <= upper; index += 1) {
1588
+ renderRange.push(index);
1589
+ }
1590
+
1591
+ const topOffset = insets.top + CLOSE_BUTTON_TOP;
1592
+ const bottomOffset = insets.bottom + BOTTOM_CHROME_OFFSET;
1593
+ const showCloseButton = closeButton && dismissible && !disabled;
1594
+ const showCounter = counter && total > 1;
1595
+ const chromePointerEvents = chromeVisible && !closing ? 'box-none' : 'none';
1596
+
1597
+ return (
1598
+ <Modal
1599
+ animationType="none"
1600
+ hardwareAccelerated
1601
+ onRequestClose={handleModalRequestClose}
1602
+ presentationStyle="overFullScreen"
1603
+ statusBarTranslucent
1604
+ supportedOrientations={[
1605
+ 'portrait',
1606
+ 'portrait-upside-down',
1607
+ 'landscape',
1608
+ 'landscape-left',
1609
+ 'landscape-right',
1610
+ ]}
1611
+ transparent
1612
+ visible={mounted}
1613
+ {...modalProps}
1614
+ >
1615
+ <GestureHandlerRootView style={styles.modalRoot}>
1616
+ <View
1617
+ accessibilityLabel={accessibilityLabel ?? labels.preview}
1618
+ accessibilityViewIsModal
1619
+ onAccessibilityEscape={handleAccessibilityEscape}
1620
+ pointerEvents={closing ? 'none' : 'auto'}
1621
+ style={[styles.root, { zIndex: DEFAULT_Z_INDEX }, rootStyle]}
1622
+ testID={testID}
1623
+ >
1624
+ <Animated.View
1625
+ pointerEvents="none"
1626
+ style={[styles.backdrop, { backgroundColor: colors.backdrop }, backdropStyle]}
1627
+ />
1628
+
1629
+ {renderRange.map((index) => {
1630
+ const image = images[index];
1631
+ if (!image) return null;
1632
+ const imageAccessibilityLabel =
1633
+ image.accessibilityLabel ?? image.alt ?? labels.image(index + 1, total);
1634
+ return (
1635
+ <ZoomableImage
1636
+ key={image.key}
1637
+ active={index === renderIndex}
1638
+ backdropOpacity={backdropOpacity}
1639
+ colors={colors}
1640
+ contentFit={contentFit}
1641
+ currentIndexSV={currentIndexSV}
1642
+ dismissible={dismissible && !disabled}
1643
+ image={image}
1644
+ imageAccessibilityLabel={imageAccessibilityLabel}
1645
+ imageProps={imageProps}
1646
+ imageStyle={imageStyle}
1647
+ index={index}
1648
+ interactions={interactions}
1649
+ labels={labels}
1650
+ onImageError={handleImageError}
1651
+ onImageLoad={handleImageLoad}
1652
+ onPageChange={handlePageChange}
1653
+ onRequestClose={requestClose}
1654
+ onSingleTap={handleSingleTap}
1655
+ pageTranslateX={pageTranslateX}
1656
+ totalCount={total}
1657
+ viewport={viewport}
1658
+ />
1659
+ );
1660
+ })}
1661
+
1662
+ {renderOverlay && (
1663
+ <View pointerEvents="box-none" style={styles.overlaySlot}>
1664
+ {renderOverlay(renderContext)}
1665
+ </View>
1666
+ )}
1667
+
1668
+ {renderHeader && (
1669
+ <Animated.View
1670
+ pointerEvents={chromePointerEvents}
1671
+ style={[styles.headerSlot, { top: topOffset }, chromeStyle]}
1672
+ >
1673
+ {renderHeader(renderContext)}
1674
+ </Animated.View>
1675
+ )}
1676
+
1677
+ {showCloseButton && (
1678
+ <Animated.View
1679
+ pointerEvents={chromeVisible && !closing ? 'auto' : 'none'}
1680
+ style={[styles.closeButtonWrap, { top: topOffset }, chromeStyle]}
1681
+ >
1682
+ <CloseButton
1683
+ backgroundColor={colors.controlBackground}
1684
+ color={colors.controlForeground}
1685
+ label={labels.close}
1686
+ onPress={() => requestClose('closeButton')}
1687
+ />
1688
+ </Animated.View>
1689
+ )}
1690
+
1691
+ {(renderFooter || showCounter) && (
1692
+ <Animated.View
1693
+ pointerEvents={chromePointerEvents}
1694
+ style={[styles.bottomChrome, { bottom: bottomOffset }, chromeStyle]}
1695
+ >
1696
+ {renderFooter && <View style={styles.footerSlot}>{renderFooter(renderContext)}</View>}
1697
+ {showCounter && (
1698
+ <View
1699
+ accessibilityLabel={labels.counter(renderIndex + 1, total)}
1700
+ pointerEvents="none"
1701
+ style={[
1702
+ styles.counterPill,
1703
+ { backgroundColor: colors.counterBackground },
1704
+ ]}
1705
+ >
1706
+ <Text
1707
+ color={colors.counterText}
1708
+ lineHeight={COUNTER_LINE_HEIGHT}
1709
+ size={COUNTER_FONT_SIZE}
1710
+ tabularNumbers
1711
+ weight="semibold"
1712
+ >
1713
+ {renderIndex + 1} / {total}
1714
+ </Text>
1715
+ </View>
1716
+ )}
1717
+ </Animated.View>
1718
+ )}
1719
+ </View>
1720
+ </GestureHandlerRootView>
1721
+ </Modal>
1722
+ );
1723
+ }
1724
+
1725
+ const ImagePreviewRoot = React.forwardRef<ImagePreviewRef, ImagePreviewProps>(function ImagePreview(
1726
+ {
1727
+ accessibilityLabel,
1728
+ closeButton = true,
1729
+ colors,
1730
+ contentFit = 'contain',
1731
+ counter = true,
1732
+ defaultOpen = false,
1733
+ defaultValue = 0,
1734
+ disabled = false,
1735
+ dismissible = true,
1736
+ imageProps,
1737
+ imageStyle,
1738
+ images,
1739
+ interactions,
1740
+ labels,
1741
+ modalProps,
1742
+ onChange,
1743
+ onClose,
1744
+ onImageError,
1745
+ onImageLoad,
1746
+ onOpenChange,
1747
+ open,
1748
+ prefetch = false,
1749
+ prefetchCachePolicy = 'memory-disk',
1750
+ renderAhead,
1751
+ renderFooter,
1752
+ renderHeader,
1753
+ renderOverlay,
1754
+ rootStyle,
1755
+ testID,
1756
+ value,
1757
+ },
1758
+ ref
1759
+ ) {
1760
+ const theme = useTheme();
1761
+ const { t } = useI18n();
1762
+ const normalizedImages = React.useMemo(() => normalizeImages(images), [images]);
1763
+ const total = normalizedImages.length;
1764
+ const isOpenControlled = open !== undefined;
1765
+ const isValueControlled = value !== undefined;
1766
+ const [innerOpen, setInnerOpen] = React.useState(defaultOpen);
1767
+ const [innerValue, setInnerValue] = React.useState(() => normalizeIndex(defaultValue, total));
1768
+ const actualOpen = isOpenControlled ? !!open : innerOpen;
1769
+ const actualValue = normalizeIndex(isValueControlled ? value : innerValue, total);
1770
+ const closeReasonRef = React.useRef<ImagePreviewCloseReason>('api');
1771
+ const closeRequestedRef = React.useRef(false);
1772
+ const previousOpenRef = React.useRef(actualOpen);
1773
+ const valueRef = React.useRef(actualValue);
1774
+ valueRef.current = actualValue;
1775
+
1776
+ const resolvedInteractions = React.useMemo(
1777
+ () => resolveInteractions(interactions),
1778
+ [interactions]
1779
+ );
1780
+
1781
+ const resolvedColors = React.useMemo<ResolvedColors>(
1782
+ () => ({
1783
+ backdrop: colors?.backdrop ?? '#000000',
1784
+ controlBackground: colors?.controlBackground ?? 'rgba(17, 24, 39, 0.62)',
1785
+ controlForeground: colors?.controlForeground ?? theme.colors.onPrimary,
1786
+ counterBackground: colors?.counterBackground ?? 'rgba(17, 24, 39, 0.58)',
1787
+ counterText: colors?.counterText ?? theme.colors.onPrimary,
1788
+ errorBackground: colors?.errorBackground ?? 'rgba(17, 24, 39, 0.72)',
1789
+ errorText: colors?.errorText ?? theme.colors.onPrimary,
1790
+ loading: colors?.loading ?? theme.colors.onPrimary,
1791
+ }),
1792
+ [
1793
+ colors?.backdrop,
1794
+ colors?.controlBackground,
1795
+ colors?.controlForeground,
1796
+ colors?.counterBackground,
1797
+ colors?.counterText,
1798
+ colors?.errorBackground,
1799
+ colors?.errorText,
1800
+ colors?.loading,
1801
+ theme.colors.onPrimary,
1802
+ ]
1803
+ );
1804
+
1805
+ const resolvedLabels = React.useMemo<ImagePreviewLabels>(
1806
+ () => ({
1807
+ preview: labels?.preview ?? t('imagePreview.preview'),
1808
+ close: labels?.close ?? t('imagePreview.close'),
1809
+ loadFailed: labels?.loadFailed ?? t('imagePreview.loadFailed'),
1810
+ counter:
1811
+ labels?.counter ??
1812
+ ((current, imageTotal) =>
1813
+ t('imagePreview.counter', { current, total: imageTotal })),
1814
+ image:
1815
+ labels?.image ??
1816
+ ((current, imageTotal) => t('imagePreview.image', { current, total: imageTotal })),
1817
+ }),
1818
+ [labels, t]
1819
+ );
1820
+
1821
+ const notifyOpenChange = React.useCallback(
1822
+ (nextOpen: boolean, meta: ImagePreviewOpenChangeMeta) => {
1823
+ if (!nextOpen) {
1824
+ closeReasonRef.current = meta.reason as ImagePreviewCloseReason;
1825
+ closeRequestedRef.current = true;
1826
+ }
1827
+ if (!isOpenControlled) setInnerOpen(nextOpen);
1828
+ onOpenChange?.(nextOpen, meta);
1829
+ },
1830
+ [isOpenControlled, onOpenChange]
1831
+ );
1832
+
1833
+ const notifyChange = React.useCallback(
1834
+ (nextValue: number, reason: ImagePreviewChangeReason) => {
1835
+ const normalizedValue = normalizeIndex(nextValue, total);
1836
+ const previousValue = valueRef.current;
1837
+ if (normalizedValue === previousValue) return;
1838
+ valueRef.current = normalizedValue;
1839
+ if (!isValueControlled) setInnerValue(normalizedValue);
1840
+ onChange?.(
1841
+ normalizedValue,
1842
+ createChangeMeta(reason, previousValue, normalizedValue, normalizedImages)
1843
+ );
1844
+ },
1845
+ [isValueControlled, normalizedImages, onChange, total]
1846
+ );
1847
+
1848
+ const close = React.useCallback(
1849
+ (reason: ImagePreviewCloseReason = 'api') => {
1850
+ notifyOpenChange(false, createOpenMeta(reason, valueRef.current, normalizedImages));
1851
+ },
1852
+ [normalizedImages, notifyOpenChange]
1853
+ );
1854
+
1855
+ React.useImperativeHandle(
1856
+ ref,
1857
+ () => ({
1858
+ close,
1859
+ setValue: (nextValue) => notifyChange(nextValue, 'api'),
1860
+ getValue: () => valueRef.current,
1861
+ }),
1862
+ [close, notifyChange]
1863
+ );
1864
+
1865
+ React.useEffect(() => {
1866
+ if (actualOpen) {
1867
+ closeReasonRef.current = 'api';
1868
+ closeRequestedRef.current = false;
1869
+ } else if (previousOpenRef.current && !actualOpen && !closeRequestedRef.current) {
1870
+ closeReasonRef.current = 'api';
1871
+ }
1872
+ previousOpenRef.current = actualOpen;
1873
+ }, [actualOpen]);
1874
+
1875
+ React.useEffect(() => {
1876
+ if (actualOpen && total === 0) {
1877
+ notifyOpenChange(false, createOpenMeta('empty', 0, normalizedImages));
1878
+ }
1879
+ }, [actualOpen, normalizedImages, notifyOpenChange, total]);
1880
+
1881
+ React.useEffect(() => {
1882
+ if (isValueControlled) return;
1883
+ const nextValue = normalizeIndex(innerValue, total);
1884
+ if (nextValue !== innerValue) setInnerValue(nextValue);
1885
+ }, [innerValue, isValueControlled, total]);
1886
+
1887
+ const handleCloseComplete = React.useCallback(
1888
+ (result: ImagePreviewResult) => {
1889
+ onClose?.({
1890
+ ...result,
1891
+ reason: closeReasonRef.current,
1892
+ });
1893
+ },
1894
+ [onClose]
1895
+ );
1896
+
1897
+ return (
1898
+ <ImagePreviewOverlay
1899
+ accessibilityLabel={accessibilityLabel}
1900
+ closeButton={closeButton}
1901
+ colors={resolvedColors}
1902
+ contentFit={contentFit}
1903
+ counter={counter}
1904
+ disabled={disabled}
1905
+ dismissible={dismissible}
1906
+ imageProps={imageProps}
1907
+ imageStyle={imageStyle}
1908
+ images={normalizedImages}
1909
+ interactions={resolvedInteractions}
1910
+ labels={resolvedLabels}
1911
+ modalProps={modalProps}
1912
+ onChange={notifyChange}
1913
+ onCloseComplete={handleCloseComplete}
1914
+ onImageError={onImageError}
1915
+ onImageLoad={onImageLoad}
1916
+ onOpenChange={notifyOpenChange}
1917
+ open={actualOpen && total > 0}
1918
+ prefetch={prefetch}
1919
+ prefetchCachePolicy={prefetchCachePolicy}
1920
+ renderAhead={clampRenderAhead(renderAhead)}
1921
+ renderFooter={renderFooter}
1922
+ renderHeader={renderHeader}
1923
+ renderOverlay={renderOverlay}
1924
+ rootStyle={rootStyle}
1925
+ testID={testID}
1926
+ value={actualValue}
1927
+ />
1928
+ );
1929
+ });
1930
+
1931
+ ImagePreviewRoot.displayName = 'ImagePreview';
1932
+
1933
+ export const ImagePreview = React.memo(ImagePreviewRoot);
1934
+ ImagePreview.displayName = 'ImagePreview';
1935
+
1936
+ type ProviderState = {
1937
+ open: boolean;
1938
+ request: ImagePreviewServiceRequest | null;
1939
+ value: number;
1940
+ closeReason: ImagePreviewCloseReason;
1941
+ };
1942
+
1943
+ export function ImagePreviewProvider({ children }: { children: React.ReactNode }) {
1944
+ const [state, setState] = React.useState<ProviderState>({
1945
+ open: false,
1946
+ request: null,
1947
+ value: 0,
1948
+ closeReason: 'api',
1949
+ });
1950
+ const stateRef = React.useRef(state);
1951
+ stateRef.current = state;
1952
+
1953
+ React.useEffect(() => {
1954
+ const controller: ImagePreviewServiceController = {
1955
+ open: (request) => {
1956
+ setState({
1957
+ open: true,
1958
+ request,
1959
+ value: request.index,
1960
+ closeReason: 'api',
1961
+ });
1962
+ },
1963
+ close: (reason) => {
1964
+ setState((prev) => ({ ...prev, open: false, closeReason: reason }));
1965
+ },
1966
+ setValue: (nextValue) => {
1967
+ setState((prev) => ({
1968
+ ...prev,
1969
+ value: normalizeIndex(nextValue, normalizeImages(prev.request?.images ?? []).length),
1970
+ }));
1971
+ },
1972
+ getSnapshot: () => {
1973
+ const current = stateRef.current;
1974
+ const images = normalizeImages(current.request?.images ?? []);
1975
+ const value = normalizeIndex(current.value, images.length);
1976
+ return {
1977
+ open: current.open,
1978
+ value,
1979
+ image: images[value] ?? null,
1980
+ total: images.length,
1981
+ };
1982
+ },
1983
+ };
1984
+
1985
+ imagePreview._bind(controller);
1986
+ return () => {
1987
+ imagePreview._bind(null);
1988
+ const request = stateRef.current.request;
1989
+ if (request) imagePreview._dispose(request.id);
1990
+ };
1991
+ }, []);
1992
+
1993
+ const request = state.request;
1994
+
1995
+ const handleOpenChange = React.useCallback(
1996
+ (nextOpen: boolean, meta: ImagePreviewOpenChangeMeta) => {
1997
+ request?.onOpenChange?.(nextOpen, meta);
1998
+ if (!nextOpen) {
1999
+ setState((prev) => ({
2000
+ ...prev,
2001
+ open: false,
2002
+ closeReason: meta.reason as ImagePreviewCloseReason,
2003
+ }));
2004
+ }
2005
+ },
2006
+ [request]
2007
+ );
2008
+
2009
+ const handleChange = React.useCallback(
2010
+ (nextValue: number, meta: ImagePreviewChangeMeta) => {
2011
+ setState((prev) => ({ ...prev, value: nextValue }));
2012
+ request?.onChange?.(nextValue, meta);
2013
+ },
2014
+ [request]
2015
+ );
2016
+
2017
+ const handleClose = React.useCallback(
2018
+ (result: ImagePreviewResult) => {
2019
+ if (!request) return;
2020
+ const finalResult = {
2021
+ ...result,
2022
+ reason: stateRef.current.closeReason,
2023
+ };
2024
+ imagePreview._complete(request.id, finalResult);
2025
+ setState((prev) =>
2026
+ prev.request?.id === request.id
2027
+ ? { open: false, request: null, value: 0, closeReason: 'api' }
2028
+ : prev
2029
+ );
2030
+ },
2031
+ [request]
2032
+ );
2033
+
2034
+ let previewNode: React.ReactNode = null;
2035
+
2036
+ if (request) {
2037
+ const {
2038
+ index: _index,
2039
+ onChange: _onChange,
2040
+ onClose: _onClose,
2041
+ onOpenChange: _onOpenChange,
2042
+ ...previewProps
2043
+ } = request;
2044
+
2045
+ previewNode = (
2046
+ <ImagePreview
2047
+ key={request.id}
2048
+ {...previewProps}
2049
+ onChange={handleChange}
2050
+ onClose={handleClose}
2051
+ onOpenChange={handleOpenChange}
2052
+ open={state.open}
2053
+ value={state.value}
2054
+ />
2055
+ );
2056
+ }
2057
+
2058
+ return (
2059
+ <>
2060
+ {children}
2061
+ {previewNode}
2062
+ </>
2063
+ );
2064
+ }
2065
+
2066
+ const styles = StyleSheet.create({
2067
+ modalRoot: {
2068
+ flex: 1,
2069
+ },
2070
+ root: {
2071
+ flex: 1,
2072
+ width: '100%',
2073
+ height: '100%',
2074
+ elevation: Platform.OS === 'android' ? 100 : 0,
2075
+ overflow: 'hidden',
2076
+ },
2077
+ backdrop: {
2078
+ ...StyleSheet.absoluteFillObject,
2079
+ },
2080
+ imagePage: {
2081
+ position: 'absolute',
2082
+ justifyContent: 'center',
2083
+ alignItems: 'center',
2084
+ overflow: 'hidden',
2085
+ zIndex: 1,
2086
+ },
2087
+ overlaySlot: {
2088
+ ...StyleSheet.absoluteFillObject,
2089
+ zIndex: 5,
2090
+ },
2091
+ headerSlot: {
2092
+ position: 'absolute',
2093
+ left: 0,
2094
+ right: 0,
2095
+ zIndex: 12,
2096
+ },
2097
+ closeButtonWrap: {
2098
+ position: 'absolute',
2099
+ left: CLOSE_BUTTON_LEFT,
2100
+ zIndex: 14,
2101
+ },
2102
+ closeButton: {
2103
+ width: CLOSE_BUTTON_SIDE,
2104
+ height: CLOSE_BUTTON_SIDE,
2105
+ borderRadius: CONTROL_RADIUS,
2106
+ alignItems: 'center',
2107
+ justifyContent: 'center',
2108
+ },
2109
+ bottomChrome: {
2110
+ position: 'absolute',
2111
+ left: 0,
2112
+ right: 0,
2113
+ alignItems: 'center',
2114
+ gap: BOTTOM_CHROME_GAP,
2115
+ zIndex: 12,
2116
+ },
2117
+ footerSlot: {
2118
+ alignSelf: 'stretch',
2119
+ },
2120
+ counterPill: {
2121
+ minHeight: COUNTER_MIN_HEIGHT,
2122
+ paddingHorizontal: COUNTER_PADDING_HORIZONTAL,
2123
+ borderRadius: COUNTER_RADIUS,
2124
+ alignItems: 'center',
2125
+ justifyContent: 'center',
2126
+ },
2127
+ errorState: {
2128
+ ...StyleSheet.absoluteFillObject,
2129
+ alignItems: 'center',
2130
+ justifyContent: 'center',
2131
+ },
2132
+ loadingState: {
2133
+ ...StyleSheet.absoluteFillObject,
2134
+ alignItems: 'center',
2135
+ justifyContent: 'center',
2136
+ zIndex: 3,
2137
+ },
2138
+ errorCard: {
2139
+ maxWidth: ERROR_CARD_MAX_WIDTH,
2140
+ paddingHorizontal: ERROR_CARD_PADDING_HORIZONTAL,
2141
+ paddingVertical: ERROR_CARD_PADDING_VERTICAL,
2142
+ borderRadius: ERROR_CARD_RADIUS,
2143
+ alignItems: 'center',
2144
+ },
2145
+ errorIcon: {
2146
+ marginBottom: ERROR_ICON_MARGIN_BOTTOM,
2147
+ },
2148
+ });