concentric-sheet 0.0.2

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NitroConcentricSheet.podspec +31 -0
  3. package/README.md +118 -0
  4. package/ios/Bridge.h +8 -0
  5. package/ios/SheetModalController.swift +912 -0
  6. package/lib/NativeSheetModal.d.ts +10 -0
  7. package/lib/NativeSheetModal.js +243 -0
  8. package/lib/index.d.ts +3 -0
  9. package/lib/index.js +1 -0
  10. package/lib/specs/SheetModalController.nitro.d.ts +46 -0
  11. package/lib/specs/SheetModalController.nitro.js +1 -0
  12. package/nitro.json +23 -0
  13. package/nitrogen/generated/.gitattributes +1 -0
  14. package/nitrogen/generated/ios/NitroConcentricSheet+autolinking.rb +60 -0
  15. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.cpp +33 -0
  16. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Bridge.hpp +269 -0
  17. package/nitrogen/generated/ios/NitroConcentricSheet-Swift-Cxx-Umbrella.hpp +69 -0
  18. package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.mm +33 -0
  19. package/nitrogen/generated/ios/NitroConcentricSheetAutolinking.swift +26 -0
  20. package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.cpp +11 -0
  21. package/nitrogen/generated/ios/c++/HybridSheetModalControllerSpecSwift.hpp +130 -0
  22. package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec.swift +58 -0
  23. package/nitrogen/generated/ios/swift/HybridSheetModalControllerSpec_cxx.swift +181 -0
  24. package/nitrogen/generated/ios/swift/ModalCornerConfiguration.swift +83 -0
  25. package/nitrogen/generated/ios/swift/ModalCornerConfigurationType.swift +48 -0
  26. package/nitrogen/generated/ios/swift/ModalViewBackground.swift +40 -0
  27. package/nitrogen/generated/ios/swift/PresentedModalConfig.swift +129 -0
  28. package/nitrogen/generated/ios/swift/PresentedModalDetent.swift +71 -0
  29. package/nitrogen/generated/ios/swift/SheetDetentIdentifier.swift +40 -0
  30. package/nitrogen/generated/ios/swift/SheetPresentationConfig.swift +238 -0
  31. package/nitrogen/generated/ios/swift/Variant_NullType_PresentedModalDetent.swift +18 -0
  32. package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.cpp +24 -0
  33. package/nitrogen/generated/shared/c++/HybridSheetModalControllerSpec.hpp +71 -0
  34. package/nitrogen/generated/shared/c++/ModalCornerConfiguration.hpp +97 -0
  35. package/nitrogen/generated/shared/c++/ModalCornerConfigurationType.hpp +84 -0
  36. package/nitrogen/generated/shared/c++/ModalViewBackground.hpp +76 -0
  37. package/nitrogen/generated/shared/c++/PresentedModalConfig.hpp +115 -0
  38. package/nitrogen/generated/shared/c++/PresentedModalDetent.hpp +94 -0
  39. package/nitrogen/generated/shared/c++/SheetDetentIdentifier.hpp +76 -0
  40. package/nitrogen/generated/shared/c++/SheetPresentationConfig.hpp +130 -0
  41. package/package.json +103 -0
  42. package/react-native.config.js +13 -0
  43. package/src/NativeSheetModal.tsx +343 -0
  44. package/src/index.ts +16 -0
  45. package/src/specs/SheetModalController.nitro.ts +54 -0
@@ -0,0 +1,343 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2
+ import {
3
+ Modal as RNModal,
4
+ type ModalProps as RNModalProps,
5
+ Platform,
6
+ } from 'react-native'
7
+ import { getHybridObjectConstructor } from 'react-native-nitro-modules'
8
+ import type {
9
+ PresentedModalConfig,
10
+ PresentedModalDetent,
11
+ SheetModalController,
12
+ SheetPresentationConfig,
13
+ } from './specs/SheetModalController.nitro'
14
+
15
+ const APPLY_RETRY_LIMIT = 8
16
+ const APPLY_RETRY_DELAY_MS = 16
17
+
18
+ let cachedController: SheetModalController | null = null
19
+ let didFailToCreateController = false
20
+ let nextModalInstanceId = 1
21
+
22
+ function getSheetModalController(): SheetModalController | null {
23
+ if (Platform.OS !== 'ios') return null
24
+ if (cachedController != null) return cachedController
25
+ if (didFailToCreateController) return null
26
+
27
+ try {
28
+ const SheetModalControllerCtor =
29
+ getHybridObjectConstructor<SheetModalController>('SheetModalController')
30
+ cachedController = new SheetModalControllerCtor()
31
+ return cachedController
32
+ } catch {
33
+ didFailToCreateController = true
34
+ return null
35
+ }
36
+ }
37
+
38
+ export interface NativeSheetModalProps
39
+ extends Pick<
40
+ RNModalProps,
41
+ | 'children'
42
+ | 'visible'
43
+ | 'animationType'
44
+ | 'presentationStyle'
45
+ | 'onRequestClose'
46
+ | 'onDismiss'
47
+ | 'onShow'
48
+ | 'allowSwipeDismissal'
49
+ >,
50
+ SheetPresentationConfig,
51
+ Omit<PresentedModalConfig, 'modalInstanceId' | 'sheet'> {
52
+ onDetentChange?: (detent: PresentedModalDetent) => void
53
+ }
54
+
55
+ type ModalOnShow = NonNullable<RNModalProps['onShow']>
56
+ type ModalOnDismiss = NonNullable<RNModalProps['onDismiss']>
57
+
58
+ function logDebug(
59
+ instanceId: number,
60
+ message: string,
61
+ extra?: Record<string, unknown>
62
+ ) {
63
+ if (!__DEV__) return
64
+ const tag = `[NativeSheetModal:${instanceId}]`
65
+ if (extra != null) {
66
+ console.log(`${tag} ${message}`, extra)
67
+ } else {
68
+ console.log(`${tag} ${message}`)
69
+ }
70
+ }
71
+
72
+ export function applyPresentedModalConfig(
73
+ config: PresentedModalConfig
74
+ ): boolean {
75
+ const controller = getSheetModalController()
76
+ if (controller == null) return false
77
+ return controller.applyPresentedModalConfig(config)
78
+ }
79
+
80
+ export function dismissPresentedNativeModal(animated = true): boolean {
81
+ const controller = getSheetModalController()
82
+ if (controller == null) return false
83
+ return controller.dismissPresentedModal(animated)
84
+ }
85
+
86
+ export function Modal(props: NativeSheetModalProps) {
87
+ const {
88
+ children,
89
+ visible,
90
+ animationType,
91
+ presentationStyle,
92
+ onRequestClose,
93
+ onDismiss: onDismissProp,
94
+ onShow: onShowProp,
95
+ allowSwipeDismissal,
96
+ detents,
97
+ customDetentHeights,
98
+ selectedDetentIdentifier,
99
+ selectedCustomDetentHeight,
100
+ largestUndimmedDetentIdentifier,
101
+ largestUndimmedCustomDetentHeight,
102
+ prefersGrabberVisible,
103
+ preferredCornerRadius,
104
+ prefersScrollingExpandsWhenScrolledToEdge,
105
+ prefersEdgeAttachedInCompactHeight,
106
+ widthFollowsPreferredContentSizeWhenEdgeAttached,
107
+ wantsFullScreen,
108
+ isModalInPresentation,
109
+ preferredContentWidth,
110
+ preferredContentHeight,
111
+ modalViewBackground,
112
+ cornerConfiguration,
113
+ onDetentChange,
114
+ } = props
115
+
116
+ const resolvedAllowSwipeDismissal =
117
+ allowSwipeDismissal ??
118
+ (isModalInPresentation == null ? true : !isModalInPresentation)
119
+
120
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
121
+ const detentPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
122
+ const isVisibleRef = useRef(visible)
123
+ const prevVisibleRef = useRef(false)
124
+ const modalInstanceIdRef = useRef<number>(nextModalInstanceId++)
125
+ const lastDetentKeyRef = useRef<string | null>(null)
126
+
127
+ const nativeConfig = useMemo<PresentedModalConfig>(() => {
128
+ const sheet: SheetPresentationConfig | undefined =
129
+ detents == null &&
130
+ customDetentHeights == null &&
131
+ selectedDetentIdentifier == null &&
132
+ selectedCustomDetentHeight == null &&
133
+ largestUndimmedDetentIdentifier == null &&
134
+ largestUndimmedCustomDetentHeight == null &&
135
+ prefersGrabberVisible == null &&
136
+ preferredCornerRadius == null &&
137
+ prefersScrollingExpandsWhenScrolledToEdge == null &&
138
+ prefersEdgeAttachedInCompactHeight == null &&
139
+ widthFollowsPreferredContentSizeWhenEdgeAttached == null &&
140
+ wantsFullScreen == null
141
+ ? undefined
142
+ : {
143
+ detents,
144
+ customDetentHeights,
145
+ selectedDetentIdentifier,
146
+ selectedCustomDetentHeight,
147
+ largestUndimmedDetentIdentifier,
148
+ largestUndimmedCustomDetentHeight,
149
+ prefersGrabberVisible,
150
+ preferredCornerRadius,
151
+ prefersScrollingExpandsWhenScrolledToEdge,
152
+ prefersEdgeAttachedInCompactHeight,
153
+ widthFollowsPreferredContentSizeWhenEdgeAttached,
154
+ wantsFullScreen,
155
+ }
156
+
157
+ return {
158
+ modalInstanceId: modalInstanceIdRef.current,
159
+ isModalInPresentation,
160
+ preferredContentWidth,
161
+ preferredContentHeight,
162
+ modalViewBackground: modalViewBackground ?? 'clear',
163
+ cornerConfiguration: cornerConfiguration ?? {
164
+ type: 'containerConcentric',
165
+ },
166
+ sheet,
167
+ }
168
+ }, [
169
+ detents,
170
+ customDetentHeights,
171
+ selectedDetentIdentifier,
172
+ selectedCustomDetentHeight,
173
+ largestUndimmedDetentIdentifier,
174
+ largestUndimmedCustomDetentHeight,
175
+ prefersGrabberVisible,
176
+ preferredCornerRadius,
177
+ prefersScrollingExpandsWhenScrolledToEdge,
178
+ prefersEdgeAttachedInCompactHeight,
179
+ widthFollowsPreferredContentSizeWhenEdgeAttached,
180
+ wantsFullScreen,
181
+ isModalInPresentation,
182
+ preferredContentWidth,
183
+ preferredContentHeight,
184
+ modalViewBackground,
185
+ cornerConfiguration,
186
+ ])
187
+
188
+ const clearRetryTimeout = useCallback(() => {
189
+ if (timeoutRef.current != null) {
190
+ clearTimeout(timeoutRef.current)
191
+ timeoutRef.current = null
192
+ logDebug(modalInstanceIdRef.current, 'cleared apply retry timeout')
193
+ }
194
+ }, [])
195
+
196
+ const clearDetentPollInterval = useCallback(() => {
197
+ if (detentPollIntervalRef.current != null) {
198
+ clearInterval(detentPollIntervalRef.current)
199
+ detentPollIntervalRef.current = null
200
+ }
201
+ }, [])
202
+
203
+ useEffect(() => {
204
+ const wasVisible = prevVisibleRef.current
205
+ prevVisibleRef.current = visible === true
206
+ isVisibleRef.current = visible
207
+
208
+ if (Platform.OS !== 'ios') return
209
+
210
+ if (!wasVisible && visible) {
211
+ const controller = getSheetModalController()
212
+ controller?.cachePresentedModalConfig(nativeConfig)
213
+ } else if (wasVisible && !visible) {
214
+ logDebug(modalInstanceIdRef.current, 'visible=false: cleanup')
215
+ clearRetryTimeout()
216
+ clearDetentPollInterval()
217
+ lastDetentKeyRef.current = null
218
+ }
219
+ }, [visible, nativeConfig, clearRetryTimeout, clearDetentPollInterval])
220
+
221
+ const applyNativeConfigWithRetry = useCallback(() => {
222
+ if (Platform.OS !== 'ios' || !visible) return
223
+ const controller = getSheetModalController()
224
+ if (controller == null) return
225
+
226
+ clearRetryTimeout()
227
+ logDebug(modalInstanceIdRef.current, 'applyNativeConfigWithRetry start', {
228
+ visible,
229
+ selectedCustomDetentHeight,
230
+ largestUndimmedCustomDetentHeight,
231
+ detents,
232
+ customDetentHeights,
233
+ })
234
+
235
+ let attempts = 0
236
+ const attempt = () => {
237
+ if (!isVisibleRef.current) return
238
+ const didApply = controller.applyPresentedModalConfig(nativeConfig)
239
+ attempts += 1
240
+ logDebug(modalInstanceIdRef.current, 'applyPresentedModalConfig attempt', {
241
+ attempt: attempts,
242
+ didApply,
243
+ })
244
+ if (!didApply && attempts < APPLY_RETRY_LIMIT) {
245
+ timeoutRef.current = setTimeout(attempt, APPLY_RETRY_DELAY_MS)
246
+ }
247
+ }
248
+
249
+ attempt()
250
+ }, [
251
+ clearRetryTimeout,
252
+ customDetentHeights,
253
+ detents,
254
+ largestUndimmedCustomDetentHeight,
255
+ nativeConfig,
256
+ selectedCustomDetentHeight,
257
+ visible,
258
+ ])
259
+
260
+ useEffect(() => {
261
+ applyNativeConfigWithRetry()
262
+ }, [applyNativeConfigWithRetry])
263
+
264
+ useEffect(() => {
265
+ return () => {
266
+ clearRetryTimeout()
267
+ clearDetentPollInterval()
268
+ }
269
+ }, [clearDetentPollInterval, clearRetryTimeout])
270
+
271
+ useEffect(() => {
272
+ if (Platform.OS !== 'ios') return
273
+ if (visible !== true || onDetentChange == null) {
274
+ clearDetentPollInterval()
275
+ return
276
+ }
277
+
278
+ const controller = getSheetModalController()
279
+ if (controller == null) return
280
+
281
+ const poll = () => {
282
+ if (!isVisibleRef.current) return
283
+ const detent = controller.getPresentedModalDetent(modalInstanceIdRef.current)
284
+ if (detent == null) return
285
+
286
+ const detentKey = `${detent.rawDetentIdentifier ?? ''}|${
287
+ detent.customDetentHeight ?? ''
288
+ }`
289
+ if (detentKey === lastDetentKeyRef.current) return
290
+
291
+ lastDetentKeyRef.current = detentKey
292
+ logDebug(modalInstanceIdRef.current, 'emit onDetentChange', {
293
+ detentIdentifier: detent.detentIdentifier,
294
+ rawDetentIdentifier: detent.rawDetentIdentifier,
295
+ customDetentHeight: detent.customDetentHeight,
296
+ })
297
+ onDetentChange(detent)
298
+ }
299
+
300
+ clearDetentPollInterval()
301
+ poll()
302
+ detentPollIntervalRef.current = setInterval(poll, 80)
303
+
304
+ return () => {
305
+ clearDetentPollInterval()
306
+ }
307
+ }, [clearDetentPollInterval, onDetentChange, visible])
308
+
309
+ const onShow = useCallback<ModalOnShow>(
310
+ (event) => {
311
+ logDebug(modalInstanceIdRef.current, 'onShow')
312
+ applyNativeConfigWithRetry()
313
+ onShowProp?.(event)
314
+ },
315
+ [applyNativeConfigWithRetry, onShowProp]
316
+ )
317
+
318
+ const onDismiss = useCallback<ModalOnDismiss>(() => {
319
+ logDebug(modalInstanceIdRef.current, 'onDismiss')
320
+ clearRetryTimeout()
321
+ onDismissProp?.()
322
+ }, [clearRetryTimeout, onDismissProp])
323
+
324
+ if (Platform.OS === 'ios' && !visible) {
325
+ return null
326
+ }
327
+
328
+ return (
329
+ <RNModal
330
+ visible={visible}
331
+ animationType={animationType ?? 'slide'}
332
+ presentationStyle={presentationStyle ?? 'pageSheet'}
333
+ onRequestClose={onRequestClose}
334
+ onDismiss={onDismiss}
335
+ onShow={onShow}
336
+ allowSwipeDismissal={resolvedAllowSwipeDismissal}
337
+ >
338
+ {children}
339
+ </RNModal>
340
+ )
341
+ }
342
+
343
+ export default Modal
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export {
2
+ Modal,
3
+ Modal as NativeSheetModal,
4
+ applyPresentedModalConfig,
5
+ dismissPresentedNativeModal,
6
+ } from './NativeSheetModal'
7
+ export type { NativeSheetModalProps } from './NativeSheetModal'
8
+ export type {
9
+ ModalCornerConfiguration,
10
+ ModalCornerConfigurationType,
11
+ ModalViewBackground,
12
+ PresentedModalConfig,
13
+ PresentedModalDetent,
14
+ SheetDetentIdentifier,
15
+ SheetPresentationConfig,
16
+ } from './specs/SheetModalController.nitro'
@@ -0,0 +1,54 @@
1
+ import type { HybridObject } from 'react-native-nitro-modules'
2
+
3
+ export type SheetDetentIdentifier = 'medium' | 'large'
4
+ export type ModalViewBackground = 'clear' | 'systemBackground'
5
+ export type ModalCornerConfigurationType =
6
+ | 'none'
7
+ | 'fixed'
8
+ | 'containerConcentric'
9
+ | 'capsule'
10
+
11
+ export interface ModalCornerConfiguration {
12
+ type: ModalCornerConfigurationType
13
+ radius?: number
14
+ minimumRadius?: number
15
+ maximumRadius?: number
16
+ }
17
+
18
+ export interface SheetPresentationConfig {
19
+ detents?: SheetDetentIdentifier[]
20
+ customDetentHeights?: number[]
21
+ selectedDetentIdentifier?: SheetDetentIdentifier
22
+ selectedCustomDetentHeight?: number
23
+ largestUndimmedDetentIdentifier?: SheetDetentIdentifier
24
+ largestUndimmedCustomDetentHeight?: number
25
+ prefersGrabberVisible?: boolean
26
+ preferredCornerRadius?: number
27
+ prefersScrollingExpandsWhenScrolledToEdge?: boolean
28
+ prefersEdgeAttachedInCompactHeight?: boolean
29
+ widthFollowsPreferredContentSizeWhenEdgeAttached?: boolean
30
+ wantsFullScreen?: boolean
31
+ }
32
+
33
+ export interface PresentedModalConfig {
34
+ modalInstanceId?: number
35
+ isModalInPresentation?: boolean
36
+ preferredContentWidth?: number
37
+ preferredContentHeight?: number
38
+ modalViewBackground?: ModalViewBackground
39
+ cornerConfiguration?: ModalCornerConfiguration
40
+ sheet?: SheetPresentationConfig
41
+ }
42
+
43
+ export interface PresentedModalDetent {
44
+ detentIdentifier?: SheetDetentIdentifier
45
+ customDetentHeight?: number
46
+ rawDetentIdentifier?: string
47
+ }
48
+
49
+ export interface SheetModalController extends HybridObject<{ ios: 'swift' }> {
50
+ cachePresentedModalConfig(config: PresentedModalConfig): boolean
51
+ applyPresentedModalConfig(config: PresentedModalConfig): boolean
52
+ getPresentedModalDetent(modalInstanceId: number): PresentedModalDetent | null
53
+ dismissPresentedModal(animated: boolean): boolean
54
+ }