@expo/ui 56.0.9 → 56.0.10

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 (157) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/CLAUDE.md +1 -1
  3. package/android/build.gradle +2 -2
  4. package/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +11 -5
  5. package/android/src/main/java/expo/modules/ui/HorizontalPagerView.kt +97 -16
  6. package/android/src/main/java/expo/modules/ui/colors/MaterialColors.kt +2 -0
  7. package/build/State/index.d.ts +6 -0
  8. package/build/State/index.d.ts.map +1 -0
  9. package/build/community/pager-view/PagerView.android.d.ts +7 -0
  10. package/build/community/pager-view/PagerView.android.d.ts.map +1 -0
  11. package/build/community/pager-view/PagerView.d.ts +8 -0
  12. package/build/community/pager-view/PagerView.d.ts.map +1 -0
  13. package/build/community/pager-view/PagerView.ios.d.ts +15 -0
  14. package/build/community/pager-view/PagerView.ios.d.ts.map +1 -0
  15. package/build/community/pager-view/index.d.ts +3 -0
  16. package/build/community/pager-view/index.d.ts.map +1 -0
  17. package/build/community/pager-view/types.d.ts +128 -0
  18. package/build/community/pager-view/types.d.ts.map +1 -0
  19. package/build/community/segmented-control/vendor/SegmentsSeparators.d.ts.map +1 -1
  20. package/build/jetpack-compose/HorizontalPager/index.d.ts +27 -0
  21. package/build/jetpack-compose/HorizontalPager/index.d.ts.map +1 -1
  22. package/build/jetpack-compose/Host/index.d.ts +1 -0
  23. package/build/jetpack-compose/Host/index.d.ts.map +1 -1
  24. package/build/jetpack-compose/LoadingIndicator/index.d.ts +1 -1
  25. package/build/jetpack-compose/LoadingIndicator/index.d.ts.map +1 -1
  26. package/build/jetpack-compose/SyncSwitch/index.d.ts +1 -1
  27. package/build/jetpack-compose/SyncSwitch/index.d.ts.map +1 -1
  28. package/build/jetpack-compose/TextField/index.d.ts +1 -1
  29. package/build/jetpack-compose/TextField/index.d.ts.map +1 -1
  30. package/build/jetpack-compose/index.d.ts +1 -2
  31. package/build/jetpack-compose/index.d.ts.map +1 -1
  32. package/build/swift-ui/Host/index.d.ts +1 -0
  33. package/build/swift-ui/Host/index.d.ts.map +1 -1
  34. package/build/swift-ui/ScrollView/index.d.ts +30 -0
  35. package/build/swift-ui/ScrollView/index.d.ts.map +1 -1
  36. package/build/swift-ui/SecureField/index.d.ts +1 -1
  37. package/build/swift-ui/SecureField/index.d.ts.map +1 -1
  38. package/build/swift-ui/SyncToggle/index.d.ts +1 -1
  39. package/build/swift-ui/SyncToggle/index.d.ts.map +1 -1
  40. package/build/swift-ui/TextField/index.d.ts +1 -1
  41. package/build/swift-ui/TextField/index.d.ts.map +1 -1
  42. package/build/swift-ui/index.d.ts +1 -2
  43. package/build/swift-ui/index.d.ts.map +1 -1
  44. package/build/swift-ui/modifiers/index.d.ts +25 -15
  45. package/build/swift-ui/modifiers/index.d.ts.map +1 -1
  46. package/build/swift-ui/modifiers/scrollObservation.d.ts +52 -0
  47. package/build/swift-ui/modifiers/scrollObservation.d.ts.map +1 -0
  48. package/build/swift-ui/modifiers/scrollPosition.d.ts +1 -1
  49. package/build/swift-ui/modifiers/scrollPosition.d.ts.map +1 -1
  50. package/build/swift-ui/modifiers/symbolEffect.d.ts +1 -1
  51. package/build/swift-ui/modifiers/symbolEffect.d.ts.map +1 -1
  52. package/build/universal/BottomSheet/index.android.d.ts.map +1 -1
  53. package/build/universal/BottomSheet/index.d.ts.map +1 -1
  54. package/build/universal/BottomSheet/index.ios.d.ts.map +1 -1
  55. package/build/universal/Checkbox/index.d.ts.map +1 -1
  56. package/build/universal/Collapsible/index.d.ts.map +1 -1
  57. package/build/universal/ListItem/ListItem.d.ts.map +1 -1
  58. package/build/universal/RNHostView/index.android.d.ts +7 -0
  59. package/build/universal/RNHostView/index.android.d.ts.map +1 -0
  60. package/build/universal/RNHostView/index.d.ts +7 -0
  61. package/build/universal/RNHostView/index.d.ts.map +1 -0
  62. package/build/universal/RNHostView/index.ios.d.ts +7 -0
  63. package/build/universal/RNHostView/index.ios.d.ts.map +1 -0
  64. package/build/universal/RNHostView/types.d.ts +23 -0
  65. package/build/universal/RNHostView/types.d.ts.map +1 -0
  66. package/build/universal/Switch/index.d.ts.map +1 -1
  67. package/build/universal/TextInput/index.d.ts.map +1 -1
  68. package/build/universal/index.d.ts +1 -0
  69. package/build/universal/index.d.ts.map +1 -1
  70. package/expo-module.config.json +1 -1
  71. package/ios/ExpoUIModule.swift +6 -3
  72. package/ios/HostView.swift +21 -18
  73. package/ios/Modifiers/FontModifier.swift +72 -20
  74. package/ios/Modifiers/ScrollObservationModifiers.swift +107 -0
  75. package/ios/Modifiers/ViewModifierRegistry.swift +8 -0
  76. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.9/expo.modules.ui-56.0.9-sources.jar → 56.0.10/expo.modules.ui-56.0.10-sources.jar} +0 -0
  77. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10-sources.jar.md5 +1 -0
  78. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10-sources.jar.sha1 +1 -0
  79. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10-sources.jar.sha256 +1 -0
  80. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10-sources.jar.sha512 +1 -0
  81. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.aar +0 -0
  82. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.aar.md5 +1 -0
  83. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.aar.sha1 +1 -0
  84. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.aar.sha256 +1 -0
  85. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.aar.sha512 +1 -0
  86. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.9/expo.modules.ui-56.0.9.module → 56.0.10/expo.modules.ui-56.0.10.module} +22 -22
  87. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.module.md5 +1 -0
  88. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.module.sha1 +1 -0
  89. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.module.sha256 +1 -0
  90. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.module.sha512 +1 -0
  91. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.9/expo.modules.ui-56.0.9.pom → 56.0.10/expo.modules.ui-56.0.10.pom} +1 -1
  92. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.pom.md5 +1 -0
  93. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.pom.sha1 +1 -0
  94. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.pom.sha256 +1 -0
  95. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.10/expo.modules.ui-56.0.10.pom.sha512 +1 -0
  96. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml +4 -4
  97. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.md5 +1 -1
  98. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha1 +1 -1
  99. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha256 +1 -1
  100. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha512 +1 -1
  101. package/package.json +7 -3
  102. package/src/State/index.ts +10 -0
  103. package/src/State/useNativeState.ts +8 -0
  104. package/src/community/pager-view/PagerView.android.tsx +223 -0
  105. package/src/community/pager-view/PagerView.ios.tsx +267 -0
  106. package/src/community/pager-view/PagerView.tsx +14 -0
  107. package/src/community/pager-view/index.tsx +13 -0
  108. package/src/community/pager-view/types.tsx +137 -0
  109. package/src/community/picker/Picker.android.tsx +1 -1
  110. package/src/community/segmented-control/vendor/SegmentsSeparators.tsx +3 -6
  111. package/src/jetpack-compose/HorizontalPager/index.tsx +89 -18
  112. package/src/jetpack-compose/Host/index.tsx +1 -0
  113. package/src/jetpack-compose/LoadingIndicator/index.tsx +1 -2
  114. package/src/jetpack-compose/SyncSwitch/index.tsx +1 -3
  115. package/src/jetpack-compose/TextField/index.tsx +1 -4
  116. package/src/jetpack-compose/index.ts +1 -2
  117. package/src/swift-ui/Host/index.tsx +1 -0
  118. package/src/swift-ui/ScrollView/index.tsx +33 -0
  119. package/src/swift-ui/SecureField/index.tsx +1 -4
  120. package/src/swift-ui/SyncToggle/index.tsx +1 -3
  121. package/src/swift-ui/TextField/index.tsx +1 -4
  122. package/src/swift-ui/index.tsx +1 -3
  123. package/src/swift-ui/modifiers/index.ts +37 -14
  124. package/src/swift-ui/modifiers/scrollObservation.ts +80 -0
  125. package/src/swift-ui/modifiers/scrollPosition.ts +1 -2
  126. package/src/swift-ui/modifiers/symbolEffect.ts +1 -2
  127. package/src/swift-ui/withAnimation.ts +1 -1
  128. package/src/universal/BottomSheet/index.android.tsx +33 -10
  129. package/src/universal/BottomSheet/index.ios.tsx +12 -10
  130. package/src/universal/BottomSheet/index.tsx +3 -0
  131. package/src/universal/Checkbox/index.tsx +14 -2
  132. package/src/universal/Collapsible/index.tsx +17 -4
  133. package/src/universal/ListItem/ListItem.tsx +7 -2
  134. package/src/universal/RNHostView/index.android.tsx +33 -0
  135. package/src/universal/RNHostView/index.ios.tsx +12 -0
  136. package/src/universal/RNHostView/index.tsx +39 -0
  137. package/src/universal/RNHostView/types.ts +25 -0
  138. package/src/universal/Switch/index.tsx +7 -2
  139. package/src/universal/TextInput/index.tsx +12 -2
  140. package/src/universal/index.ts +1 -0
  141. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9-sources.jar.md5 +0 -1
  142. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9-sources.jar.sha1 +0 -1
  143. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9-sources.jar.sha256 +0 -1
  144. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9-sources.jar.sha512 +0 -1
  145. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.aar +0 -0
  146. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.aar.md5 +0 -1
  147. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.aar.sha1 +0 -1
  148. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.aar.sha256 +0 -1
  149. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.aar.sha512 +0 -1
  150. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.module.md5 +0 -1
  151. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.module.sha1 +0 -1
  152. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.module.sha256 +0 -1
  153. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.module.sha512 +0 -1
  154. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.pom.md5 +0 -1
  155. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.pom.sha1 +0 -1
  156. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.pom.sha256 +0 -1
  157. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.9/expo.modules.ui-56.0.9.pom.sha512 +0 -1
@@ -0,0 +1,223 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactElement,
10
+ } from 'react';
11
+ import { StyleSheet } from 'react-native';
12
+
13
+ import { wrapNativeEvent, type PagerViewProps } from './types';
14
+ import { worklets } from '../../State';
15
+ import { HorizontalPager, type HorizontalPagerHandle } from '../../jetpack-compose/HorizontalPager';
16
+ import { Host } from '../../jetpack-compose/Host';
17
+ import { RNHostView } from '../../jetpack-compose/RNHostView';
18
+ import { type BuiltinShape, Shapes, clip, fillMaxSize } from '../../jetpack-compose/modifiers';
19
+
20
+ /**
21
+ * Drop-in replacement for `react-native-pager-view` on Android.
22
+ * Renders a Jetpack Compose `HorizontalPager`.
23
+ */
24
+ export function PagerView(props: PagerViewProps) {
25
+ const {
26
+ ref,
27
+ initialPage = 0,
28
+ scrollEnabled = true,
29
+ pageMargin,
30
+ offscreenPageLimit,
31
+ layoutDirection,
32
+ onPageScroll,
33
+ onPageScrollStateChanged,
34
+ onPageSelected,
35
+ children,
36
+ style,
37
+ } = props;
38
+
39
+ const pagerRef = useRef<HorizontalPagerHandle>(null);
40
+ const [scrollEnabledState, setScrollEnabledState] = useState(scrollEnabled);
41
+ useEffect(() => {
42
+ setScrollEnabledState(scrollEnabled);
43
+ }, [scrollEnabled]);
44
+
45
+ // Synthesize pager-view's `idle | dragging | settling` from Compose's raw
46
+ // signals: `isScrollInProgress` (drag or snap-animation in flight) plus
47
+ // drag interactions (start/stop/cancel).
48
+ const isScrollInProgressRef = useRef(false);
49
+ const isDraggingRef = useRef(false);
50
+ const lastEmittedScrollStateRef = useRef<'idle' | 'dragging' | 'settling' | null>(null);
51
+ const emitPageScrollStateIfChanged = (state: 'idle' | 'dragging' | 'settling') => {
52
+ if (state === lastEmittedScrollStateRef.current) return;
53
+ lastEmittedScrollStateRef.current = state;
54
+ onPageScrollStateChanged?.(wrapNativeEvent({ pageScrollState: state }));
55
+ };
56
+
57
+ useImperativeHandle(
58
+ ref,
59
+ () => ({
60
+ setPage: (page: number) => {
61
+ pagerRef.current?.animateScrollToPage(page);
62
+ },
63
+ setPageWithoutAnimation: (page: number) => {
64
+ pagerRef.current?.scrollToPage(page);
65
+ },
66
+ setScrollEnabled: setScrollEnabledState,
67
+ }),
68
+ []
69
+ );
70
+
71
+ const pages = Children.toArray(children)
72
+ .filter((child): child is ReactElement => isValidElement(child))
73
+ .map((child, index) => (
74
+ <RNHostView key={child.key ?? String(index)} modifiers={[fillMaxSize()]}>
75
+ {child}
76
+ </RNHostView>
77
+ ));
78
+
79
+ const pageScrollHandler = useMemo(
80
+ () => (onPageScroll ? buildOnPageScrollHandler(onPageScroll) : undefined),
81
+ [onPageScroll]
82
+ );
83
+
84
+ // RN's `borderRadius` on the host View doesn't reliably clip Compose's draw
85
+ // pass — translate it into a Compose `clip` modifier instead.
86
+ const pagerModifiers = [fillMaxSize()];
87
+ const cornerShape = borderRadiusShape(style, layoutDirection === 'rtl');
88
+ if (cornerShape) {
89
+ pagerModifiers.push(clip(cornerShape));
90
+ }
91
+
92
+ return (
93
+ <Host style={style ?? { flex: 1 }}>
94
+ <HorizontalPager
95
+ ref={pagerRef}
96
+ initialPage={initialPage}
97
+ userScrollEnabled={scrollEnabledState}
98
+ reverseLayout={layoutDirection === 'rtl'}
99
+ pageSpacing={pageMargin}
100
+ beyondViewportPageCount={offscreenPageLimit}
101
+ modifiers={pagerModifiers}
102
+ onSettledPageChange={(page) => {
103
+ onPageSelected?.(wrapNativeEvent({ position: page }));
104
+ }}
105
+ onPageScroll={pageScrollHandler}
106
+ onScrollInProgressChange={
107
+ onPageScrollStateChanged
108
+ ? (inProgress) => {
109
+ isScrollInProgressRef.current = inProgress;
110
+ if (!inProgress) {
111
+ emitPageScrollStateIfChanged('idle');
112
+ } else if (!isDraggingRef.current) {
113
+ emitPageScrollStateIfChanged('settling');
114
+ }
115
+ }
116
+ : undefined
117
+ }
118
+ onDragInteraction={
119
+ onPageScrollStateChanged
120
+ ? (kind) => {
121
+ if (kind === 'start') {
122
+ isDraggingRef.current = true;
123
+ emitPageScrollStateIfChanged('dragging');
124
+ } else {
125
+ isDraggingRef.current = false;
126
+ emitPageScrollStateIfChanged(isScrollInProgressRef.current ? 'settling' : 'idle');
127
+ }
128
+ }
129
+ : undefined
130
+ }>
131
+ {pages}
132
+ </HorizontalPager>
133
+ </Host>
134
+ );
135
+ }
136
+
137
+ // Compose's `(currentPage, fraction ∈ [-0.5, 0.5))` is anchored to the snapped
138
+ // page; pager-view's `(position, offset ∈ [0, 1))` is anchored to the leading
139
+ // page. Re-anchor negative fractions onto the previous page.
140
+ function composePageToPageScroll(
141
+ currentPage: number,
142
+ currentPageOffsetFraction: number
143
+ ): { position: number; offset: number } {
144
+ 'worklet';
145
+ if (currentPageOffsetFraction >= 0) {
146
+ return { position: currentPage, offset: currentPageOffsetFraction };
147
+ }
148
+ return { position: currentPage - 1, offset: 1 + currentPageOffsetFraction };
149
+ }
150
+
151
+ // Mirrors the worklet-ness of the user's callback so the per-frame mapping
152
+ // stays on the UI runtime when the user is also on it.
153
+ function buildOnPageScrollHandler(
154
+ userOnPageScroll: NonNullable<PagerViewProps['onPageScroll']>
155
+ ): (currentPage: number, currentPageOffsetFraction: number) => void {
156
+ if (worklets?.isWorkletFunction?.(userOnPageScroll)) {
157
+ return (currentPage, currentPageOffsetFraction) => {
158
+ 'worklet';
159
+ userOnPageScroll(
160
+ wrapNativeEvent(composePageToPageScroll(currentPage, currentPageOffsetFraction))
161
+ );
162
+ };
163
+ }
164
+ return (currentPage, currentPageOffsetFraction) => {
165
+ userOnPageScroll(
166
+ wrapNativeEvent(composePageToPageScroll(currentPage, currentPageOffsetFraction))
167
+ );
168
+ };
169
+ }
170
+
171
+ // Translates RN border-radius style keys to a Compose `RoundedCornerShape`.
172
+ // Physical (`borderTopLeftRadius`) and logical (`borderTopStartRadius`) keys
173
+ // collapse onto Compose's start/end edges, swapped under RTL.
174
+ function borderRadiusShape(style: PagerViewProps['style'], rtl: boolean): BuiltinShape | undefined {
175
+ const flat = StyleSheet.flatten(style) as Record<string, unknown> | undefined;
176
+ if (!flat) return undefined;
177
+ // Compose `RoundedCornerShape` only takes Dp (numeric); RN's string values
178
+ // like `'50%'` are dropped — `__DEV__` warns once so the no-clip isn't silent.
179
+ const num = (key: string): number | undefined => {
180
+ const v = flat[key];
181
+ if (typeof v === 'number') return v > 0 ? v : undefined;
182
+ if (__DEV__ && typeof v === 'string') {
183
+ warnAboutStringBorderRadiusOnce(key, v);
184
+ }
185
+ return undefined;
186
+ };
187
+ const uniform = num('borderRadius');
188
+ const topLeft = num('borderTopLeftRadius');
189
+ const topRight = num('borderTopRightRadius');
190
+ const bottomLeft = num('borderBottomLeftRadius');
191
+ const bottomRight = num('borderBottomRightRadius');
192
+ const topStart = num('borderTopStartRadius') ?? (rtl ? topRight : topLeft);
193
+ const topEnd = num('borderTopEndRadius') ?? (rtl ? topLeft : topRight);
194
+ const bottomStart = num('borderBottomStartRadius') ?? (rtl ? bottomRight : bottomLeft);
195
+ const bottomEnd = num('borderBottomEndRadius') ?? (rtl ? bottomLeft : bottomRight);
196
+ const hasPerCorner =
197
+ topStart != null || topEnd != null || bottomStart != null || bottomEnd != null;
198
+ if (hasPerCorner) {
199
+ const fallback = uniform ?? 0;
200
+ return Shapes.RoundedCorner({
201
+ topStart: topStart ?? fallback,
202
+ topEnd: topEnd ?? fallback,
203
+ bottomStart: bottomStart ?? fallback,
204
+ bottomEnd: bottomEnd ?? fallback,
205
+ });
206
+ }
207
+ if (uniform != null) {
208
+ return Shapes.RoundedCorner(uniform);
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ let didWarnStringBorderRadius = false;
214
+ function warnAboutStringBorderRadiusOnce(key: string, value: string): void {
215
+ if (didWarnStringBorderRadius) return;
216
+ didWarnStringBorderRadius = true;
217
+ console.warn(
218
+ `[expo-ui PagerView] ${key}: ${JSON.stringify(value)} — string border-radius values ` +
219
+ `aren't supported on the Android pager (Jetpack Compose's RoundedCornerShape requires ` +
220
+ `numeric Dp values). The corner radius is being dropped, so the pager won't clip. ` +
221
+ `Use a numeric pixel value, or omit the style key.`
222
+ );
223
+ }
@@ -0,0 +1,267 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ } from 'react';
12
+ import { Platform } from 'react-native';
13
+
14
+ import { wrapNativeEvent, type PagerViewProps } from './types';
15
+ import { useNativeState, worklets } from '../../State';
16
+ import { Group } from '../../swift-ui/Group';
17
+ import { Host } from '../../swift-ui/Host';
18
+ import { LazyHStack } from '../../swift-ui/LazyHStack';
19
+ import { RNHostView } from '../../swift-ui/RNHostView';
20
+ import { ScrollView, type ScrollGeometry, type ScrollPhase } from '../../swift-ui/ScrollView';
21
+ import {
22
+ containerRelativeFrame,
23
+ id,
24
+ onScrollPhaseChange,
25
+ scrollDisabled,
26
+ scrollPosition,
27
+ scrollTargetBehavior,
28
+ scrollTargetLayout,
29
+ useScrollGeometryChange,
30
+ } from '../../swift-ui/modifiers';
31
+ import { Animation } from '../../swift-ui/modifiers/animation';
32
+ import { withAnimation } from '../../swift-ui/withAnimation';
33
+
34
+ function phaseToPageState(phase: ScrollPhase): 'idle' | 'dragging' | 'settling' {
35
+ switch (phase) {
36
+ case 'idle':
37
+ return 'idle';
38
+ case 'tracking':
39
+ case 'interacting':
40
+ return 'dragging';
41
+ case 'animating':
42
+ case 'decelerating':
43
+ return 'settling';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Drop-in replacement for `react-native-pager-view` on iOS.
49
+ *
50
+ * Renders a SwiftUI `ScrollView` with paging behavior. Scroll position is the
51
+ * single source of truth: an `ObservableState` bound through the
52
+ * `scrollPosition` modifier drives initial placement, user-swipe writeback,
53
+ * and imperative `setPage` / `setPageWithoutAnimation`. The animated path
54
+ * routes the write through `withAnimation`; if `react-native-worklets` isn't
55
+ * installed, `setPage` falls back to a non-animated jump. Requires iOS 17+.
56
+ * Continuous progress (`onPageScroll`) and scroll state
57
+ * (`onPageScrollStateChanged`) events require iOS 18+.
58
+ */
59
+ export function PagerView(props: PagerViewProps) {
60
+ const {
61
+ ref,
62
+ initialPage = 0,
63
+ scrollEnabled = true,
64
+ onPageScroll,
65
+ onPageScrollStateChanged,
66
+ onPageSelected,
67
+ children,
68
+ style,
69
+ } = props;
70
+
71
+ warnIfPreIOS18ScrollCallbacksDropped(onPageScroll, onPageScrollStateChanged);
72
+
73
+ const [scrollEnabledState, setScrollEnabledState] = useState(scrollEnabled);
74
+ useEffect(() => {
75
+ setScrollEnabledState(scrollEnabled);
76
+ }, [scrollEnabled]);
77
+
78
+ const validChildren = useMemo(
79
+ () => Children.toArray(children).filter((c): c is ReactElement => isValidElement(c)),
80
+ [children]
81
+ );
82
+ const pageCount = validChildren.length;
83
+ const pageCountRef = useRef(0);
84
+ pageCountRef.current = pageCount;
85
+
86
+ // Clamp on first render — out-of-range initials produce an id that matches
87
+ // no `Group`, leaving `.scrollPosition(id:)` silently stuck at 0.
88
+ const [clampedInitialPage] = useState(() => {
89
+ if (pageCount === 0) return 0;
90
+ return Math.max(0, Math.min(pageCount - 1, initialPage));
91
+ });
92
+
93
+ const activePageState = useNativeState<string | null>(
94
+ clampedInitialPage > 0 ? String(clampedInitialPage) : null
95
+ );
96
+
97
+ // The SwiftUI writeback fires for both user swipes and our own imperative
98
+ // writes; dedup against this ref so `onPageSelected` fires once per change.
99
+ const lastSelectedPageRef = useRef(clampedInitialPage);
100
+
101
+ // Read the latest `onPageSelected` through a ref so the shrink-clamp effect
102
+ // doesn't need it in deps (and won't re-run / double-fire on callback
103
+ // identity changes).
104
+ const onPageSelectedRef = useRef(onPageSelected);
105
+ onPageSelectedRef.current = onPageSelected;
106
+
107
+ const handleScrolledIDChange = (newId: string | null) => {
108
+ if (newId == null) return;
109
+ const page = parseInt(newId, 10);
110
+ if (!Number.isFinite(page)) return;
111
+ if (page !== lastSelectedPageRef.current) {
112
+ lastSelectedPageRef.current = page;
113
+ onPageSelectedRef.current?.(wrapNativeEvent({ position: page }));
114
+ }
115
+ };
116
+
117
+ // Without clamping, an out-of-range id would silently no-op on
118
+ // `.scrollPosition(id:)` instead of jumping to the nearest page.
119
+ const clampPage = (page: number): number | null => {
120
+ const count = pageCountRef.current;
121
+ if (count === 0 || !Number.isFinite(page)) return null;
122
+ return Math.max(0, Math.min(count - 1, page));
123
+ };
124
+
125
+ // Bypasses the public `value` setter on JS-thread writes — its dev warning
126
+ // is aimed at user code; our own imperative API is an intentional JS-thread
127
+ // writer. Inside a worklet we use `activePageState.value = …` directly,
128
+ // which routes through the SharedObject prototype installed by `index.fx`.
129
+ const writePageFromJS = (id: string) => {
130
+ (activePageState as unknown as { setValue(v: { value: string }): void }).setValue({
131
+ value: id,
132
+ });
133
+ };
134
+
135
+ // Re-anchor when the page count drops past the current selection. Matches
136
+ // Android, where Compose's `PagerState` clamps `currentPage` to the new
137
+ // max and `settledPage` fires the corresponding event. Without this on
138
+ // iOS, `.scrollPosition(id:)` silently no-ops on the missing id and the
139
+ // pager visually drifts without firing `onPageSelected`.
140
+ useLayoutEffect(() => {
141
+ if (pageCount === 0) return;
142
+ if (lastSelectedPageRef.current < pageCount) return;
143
+ const clamped = pageCount - 1;
144
+ lastSelectedPageRef.current = clamped;
145
+ writePageFromJS(String(clamped));
146
+ onPageSelectedRef.current?.(wrapNativeEvent({ position: clamped }));
147
+ }, [pageCount]);
148
+
149
+ useImperativeHandle(
150
+ ref,
151
+ () => ({
152
+ setPage: (page: number) => {
153
+ const clamped = clampPage(page);
154
+ if (clamped == null) return;
155
+ const nextId = String(clamped);
156
+ // `withAnimation` requires `react-native-worklets`; fall back to an
157
+ // instant jump if it isn't installed.
158
+ if (worklets) {
159
+ // `null` would disable animation; `Animation.default` opts into
160
+ // SwiftUI's default paging animation.
161
+ withAnimation(Animation.default, () => {
162
+ 'worklet';
163
+ activePageState.value = nextId;
164
+ });
165
+ } else {
166
+ writePageFromJS(nextId);
167
+ }
168
+ },
169
+ setPageWithoutAnimation: (page: number) => {
170
+ const clamped = clampPage(page);
171
+ if (clamped == null) return;
172
+ writePageFromJS(String(clamped));
173
+ },
174
+ setScrollEnabled: setScrollEnabledState,
175
+ }),
176
+ [activePageState]
177
+ );
178
+
179
+ const handleScrollGeometry = useMemo(
180
+ () => (onPageScroll ? buildHandleScrollGeometry(onPageScroll) : undefined),
181
+ [onPageScroll]
182
+ );
183
+ const geometryModifier = useScrollGeometryChange(handleScrollGeometry);
184
+
185
+ const phaseModifier = onPageScrollStateChanged
186
+ ? onScrollPhaseChange((phase) =>
187
+ onPageScrollStateChanged(wrapNativeEvent({ pageScrollState: phaseToPageState(phase) }))
188
+ )
189
+ : null;
190
+
191
+ const pages = validChildren.map((child, index) => (
192
+ <Group
193
+ key={child.key ?? String(index)}
194
+ modifiers={[containerRelativeFrame({ axes: 'horizontal' }), id(String(index))]}>
195
+ <RNHostView>{child}</RNHostView>
196
+ </Group>
197
+ ));
198
+
199
+ // Toggle the flag rather than splicing the modifier in/out — SwiftUI diffs
200
+ // modifiers by position, so a shifting array resets the ScrollView's content.
201
+ const modifiers = [
202
+ scrollTargetBehavior('paging'),
203
+ scrollDisabled(!scrollEnabledState),
204
+ scrollPosition(activePageState, { onChange: handleScrolledIDChange }),
205
+ ...(geometryModifier ? [geometryModifier] : []),
206
+ ...(phaseModifier ? [phaseModifier] : []),
207
+ ];
208
+
209
+ return (
210
+ <Host style={style ?? { flex: 1 }}>
211
+ <ScrollView axes="horizontal" showsIndicators={false} modifiers={modifiers}>
212
+ <LazyHStack spacing={0} modifiers={[scrollTargetLayout()]}>
213
+ {pages}
214
+ </LazyHStack>
215
+ </ScrollView>
216
+ </Host>
217
+ );
218
+ }
219
+
220
+ function geometryToPageScroll(geometry: ScrollGeometry): { position: number; offset: number } {
221
+ 'worklet';
222
+ // Clamp so rubber-band overscroll doesn't emit `position` outside
223
+ // `[0, pageCount - 1]`. `Math.round` tolerates sub-pixel content sizing.
224
+ const pageCount = Math.max(1, Math.round(geometry.contentWidth / geometry.containerWidth));
225
+ const positionFloat = Math.max(
226
+ 0,
227
+ Math.min(pageCount - 1, geometry.contentOffsetX / geometry.containerWidth)
228
+ );
229
+ const position = Math.floor(positionFloat);
230
+ return { position, offset: positionFloat - position };
231
+ }
232
+
233
+ // Mirrors the worklet-ness of the user's callback so the per-frame mapping
234
+ // stays on the UI runtime when the user is also on it.
235
+ function buildHandleScrollGeometry(
236
+ userOnPageScroll: NonNullable<PagerViewProps['onPageScroll']>
237
+ ): (geometry: ScrollGeometry) => void {
238
+ if (worklets?.isWorkletFunction?.(userOnPageScroll)) {
239
+ return (geometry) => {
240
+ 'worklet';
241
+ if (geometry.containerWidth <= 0) return;
242
+ userOnPageScroll(wrapNativeEvent(geometryToPageScroll(geometry)));
243
+ };
244
+ }
245
+ return (geometry) => {
246
+ if (geometry.containerWidth <= 0) return;
247
+ userOnPageScroll(wrapNativeEvent(geometryToPageScroll(geometry)));
248
+ };
249
+ }
250
+
251
+ let didWarnPreIOS18 = false;
252
+ function warnIfPreIOS18ScrollCallbacksDropped(
253
+ onPageScroll: PagerViewProps['onPageScroll'],
254
+ onPageScrollStateChanged: PagerViewProps['onPageScrollStateChanged']
255
+ ) {
256
+ if (!__DEV__ || didWarnPreIOS18) return;
257
+ if (!onPageScroll && !onPageScrollStateChanged) return;
258
+ const major = parseInt(String(Platform.Version), 10);
259
+ if (Number.isFinite(major) && major < 18) {
260
+ didWarnPreIOS18 = true;
261
+ console.warn(
262
+ `[expo-ui PagerView] onPageScroll and onPageScrollStateChanged require iOS 18+ ` +
263
+ `and will not fire on iOS ${Platform.Version}. Guard with Platform.Version or ` +
264
+ `provide a fallback for older iOS targets.`
265
+ );
266
+ }
267
+ }
@@ -0,0 +1,14 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ import type { PagerViewProps } from './types';
4
+
5
+ /**
6
+ * A drop-in replacement for `react-native-pager-view`. Renders a horizontally
7
+ * paged view backed by Jetpack Compose's `HorizontalPager` on Android and
8
+ * SwiftUI on iOS. Each child is treated as a separate page.
9
+ */
10
+ export function PagerView(_props: PagerViewProps): never {
11
+ throw new Error(
12
+ `@expo/ui/community/pager-view is not implemented on ${Platform.OS}. Supported platforms: android, ios.`
13
+ );
14
+ }
@@ -0,0 +1,13 @@
1
+ export type {
2
+ PagerViewProps,
3
+ PagerViewRef,
4
+ PagerViewOnPageScrollEventData,
5
+ PagerViewOnPageSelectedEventData,
6
+ PageScrollStateChangedEventData,
7
+ PagerViewOnPageScrollEvent,
8
+ PagerViewOnPageSelectedEvent,
9
+ PageScrollStateChangedEvent,
10
+ } from './types';
11
+
12
+ // named export is for the docs generator; default for normal consumption
13
+ export { PagerView, PagerView as default } from './PagerView';
@@ -0,0 +1,137 @@
1
+ import type { ReactNode, Ref } from 'react';
2
+ import type { NativeSyntheticEvent, ViewProps } from 'react-native';
3
+
4
+ /**
5
+ * Event payload for `onPageScroll`. Mirrors the upstream
6
+ * `react-native-pager-view` shape.
7
+ */
8
+ export type PagerViewOnPageScrollEventData = Readonly<{
9
+ position: number;
10
+ offset: number;
11
+ }>;
12
+
13
+ /**
14
+ * Event payload for `onPageSelected`. Mirrors the upstream
15
+ * `react-native-pager-view` shape.
16
+ */
17
+ export type PagerViewOnPageSelectedEventData = Readonly<{
18
+ position: number;
19
+ }>;
20
+
21
+ /**
22
+ * Event payload for `onPageScrollStateChanged`. Mirrors the upstream
23
+ * `react-native-pager-view` shape.
24
+ */
25
+ export type PageScrollStateChangedEventData = Readonly<{
26
+ pageScrollState: 'idle' | 'dragging' | 'settling';
27
+ }>;
28
+
29
+ export type PagerViewOnPageScrollEvent = NativeSyntheticEvent<PagerViewOnPageScrollEventData>;
30
+ export type PagerViewOnPageSelectedEvent = NativeSyntheticEvent<PagerViewOnPageSelectedEventData>;
31
+ export type PageScrollStateChangedEvent = NativeSyntheticEvent<PageScrollStateChangedEventData>;
32
+
33
+ /**
34
+ * Wraps a payload as `NativeSyntheticEvent`. We only populate `nativeEvent`
35
+ * since our consumers (mirroring upstream `react-native-pager-view`) read
36
+ * only `event.nativeEvent.X`; the unset SyntheticEvent fields would never
37
+ * be observed in practice.
38
+ */
39
+ export const wrapNativeEvent = <T,>(nativeEvent: T): NativeSyntheticEvent<T> => {
40
+ 'worklet';
41
+ return { nativeEvent } as NativeSyntheticEvent<T>;
42
+ };
43
+
44
+ /**
45
+ * Props for the `PagerView` component.
46
+ * Compatible with `react-native-pager-view`.
47
+ */
48
+ export type PagerViewProps = ViewProps & {
49
+ /**
50
+ * Ref handle exposing imperative `setPage`, `setPageWithoutAnimation`,
51
+ * and `setScrollEnabled` methods.
52
+ */
53
+ ref?: Ref<PagerViewRef>;
54
+ /**
55
+ * Index of the page that is initially selected. Read **once** on mount;
56
+ * later changes are ignored. To navigate after mount, call
57
+ * `ref.setPage()` or `ref.setPageWithoutAnimation()`.
58
+ * @default 0
59
+ */
60
+ initialPage?: number;
61
+ /**
62
+ * Whether the user can swipe between pages.
63
+ * @default true
64
+ */
65
+ scrollEnabled?: boolean;
66
+ /**
67
+ * Layout direction for paging.
68
+ * @default 'ltr'
69
+ * @platform android
70
+ */
71
+ layoutDirection?: 'ltr' | 'rtl';
72
+ /**
73
+ * Number of pages kept off-screen on each side of the visible page.
74
+ * @platform android
75
+ */
76
+ offscreenPageLimit?: number;
77
+ /**
78
+ * Pixels of padding between pages.
79
+ * @platform android
80
+ */
81
+ pageMargin?: number;
82
+ /**
83
+ * Fires continuously while a swipe is in progress. The event's `position`
84
+ * is the index of the leading visible page; `offset` is the fractional
85
+ * progress toward the next page in the `[0, 1)` range.
86
+ *
87
+ * **iOS 18+ only.**
88
+ */
89
+ onPageScroll?: (event: PagerViewOnPageScrollEvent) => void;
90
+ /**
91
+ * Fires when a page is fully selected. The event's `position` is the
92
+ * index of the new page.
93
+ */
94
+ onPageSelected?: (event: PagerViewOnPageSelectedEvent) => void;
95
+ /**
96
+ * Fires when the scroll state changes between `idle`, `dragging`,
97
+ * and `settling`.
98
+ *
99
+ * **iOS 18+ only.**
100
+ */
101
+ onPageScrollStateChanged?: (event: PageScrollStateChangedEvent) => void;
102
+ /**
103
+ * Pages of the pager. Each child is treated as a separate page and
104
+ * stretched to fill the pager. Each child should have a stable `key`.
105
+ */
106
+ children?: ReactNode;
107
+ };
108
+
109
+ /**
110
+ * Ref handle for the `PagerView` component.
111
+ * Compatible with `react-native-pager-view`.
112
+ */
113
+ export type PagerViewRef = {
114
+ /**
115
+ * Animate the pager to the given page index. Out-of-range indices are
116
+ * silently ignored. On iOS the animation requires `react-native-worklets`;
117
+ * without it, `setPage` falls back to a non-animated jump.
118
+ */
119
+ setPage: (selectedPage: number) => void;
120
+ /**
121
+ * Jump to the given page index without an animation.
122
+ */
123
+ setPageWithoutAnimation: (selectedPage: number) => void;
124
+ /**
125
+ * Imperatively enable or disable user scrolling — convenient when
126
+ * toggling from a non-React context like a ref-based gesture handler.
127
+ *
128
+ * > **Note:** If the `scrollEnabled` prop is also provided, subsequent
129
+ * > prop changes win and reset the value set imperatively. To use the
130
+ * > imperative path exclusively, omit the prop.
131
+ *
132
+ * > Unlike upstream `react-native-pager-view` (which calls UIManager
133
+ * > directly), this triggers a re-render of `PagerView` so the new
134
+ * > flag flows through to the native view via props.
135
+ */
136
+ setScrollEnabled: (scrollEnabled: boolean) => void;
137
+ };
@@ -7,7 +7,7 @@ import {
7
7
  type PickerItemValue,
8
8
  type PickerProps,
9
9
  } from './types';
10
- import { useNativeState } from '../../State/useNativeState';
10
+ import { useNativeState } from '../../State';
11
11
  import { DropdownMenuItem } from '../../jetpack-compose/DropdownMenu/DropdownMenuItem';
12
12
  import {
13
13
  ExposedDropdownMenuBox,
@@ -12,7 +12,8 @@ export function SegmentsSeparators({
12
12
  values: number;
13
13
  selectedIndex?: number;
14
14
  }) {
15
- const colorScheme = useColorScheme();
15
+ const isDark = useColorScheme() === 'dark';
16
+
16
17
  const hide = (val: number) => {
17
18
  return selectedIndex === val || selectedIndex === val + 1;
18
19
  };
@@ -22,11 +23,7 @@ export function SegmentsSeparators({
22
23
  {[...Array(values - 1).keys()].map((val) => (
23
24
  <View
24
25
  key={val}
25
- style={[
26
- styles.separator,
27
- colorScheme === 'dark' && styles.darkSeparator,
28
- hide(val) && styles.hide,
29
- ]}
26
+ style={[styles.separator, isDark && styles.darkSeparator, hide(val) && styles.hide]}
30
27
  />
31
28
  ))}
32
29
  </View>