Merge branch 'main' into dialog-alert-type

This commit is contained in:
vineyardbovines
2026-05-08 15:33:08 -04:00
422 changed files with 110907 additions and 49947 deletions
+248
View File
@@ -0,0 +1,248 @@
# Bottom Sheet Expo Module
A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android).
## Overview
This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior.
Key features:
- Native bottom sheet presentation on iOS and Android
- Automatic content height detection (no JS bridge round-trip)
- Configurable snap points (hidden, partial, full)
- Drag-to-dismiss with prevention controls
- Portal-based rendering for proper z-index layering
- Edge-to-edge support on modern Android versions
- iOS 26+ zoom transition support
## Platform Support
- **iOS**: Uses `UISheetPresentationController` (iOS 15+)
- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior`
- **Web**: Not supported (throws error)
## Architecture
### TypeScript Layer
The module exposes a React component that handles rendering and state management:
- **BottomSheet.tsx** (Native): Main component wrapping the native view
- **BottomSheet.web.tsx** (Web): Stub that throws an error
- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration
- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content
- **Portal.tsx**: Generic portal implementation for managing component hierarchy
The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`).
### Native Layer
#### iOS Implementation
- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings
- **SheetView.swift**: Main view component that creates and manages `SheetViewController`
- Observes content height via KVO (Key-Value Observing) on bounds
- Manages sheet lifecycle and state transitions
- Implements `UISheetPresentationControllerDelegate` for drag events
- **SheetViewController.swift**: UIViewController subclass with sheet presentation
- Configures detents (snap points) based on content height
- Handles iOS 26+ safe area adjustments for floating sheet style
- Animates detent changes when content resizes
- **SheetManager.swift**: Singleton that tracks all active sheets with weak references
- **Util.swift**: Helper for calculating screen height minus safe area insets
#### Android Implementation
- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality
- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog
- Uses `OnLayoutChangeListener` to observe content height natively
- Configures `BottomSheetBehavior` for drag and snap behavior
- Handles edge-to-edge display across Android versions (API 29-35+)
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
### Content Height Detection
Both platforms detect content height changes natively without JS bridge round-trips:
- **iOS**: KVO observation on the content view's `bounds` property
- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls)
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
## Props
```typescript
interface BottomSheetViewProps {
children: React.ReactNode
// Appearance
cornerRadius?: number
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
// Behavior
preventDismiss?: boolean // Disable swipe-to-dismiss
preventExpansion?: boolean // Lock to initial height (no full-screen)
disableDrag?: boolean // Disable drag handle (Android only)
fullHeight?: boolean // Start at full screen height
// Height constraints
minHeight?: number // Minimum height in dp
maxHeight?: number // Maximum height in dp
// iOS 26+ transition
sourceViewTag?: number // View tag for zoom transition origin
// Events
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
onStateChange?: (event: BottomSheetStateChangeEvent) => void
}
```
## States and Snap Points
### States
- `closed`: Sheet is dismissed
- `closing`: Sheet is animating closed
- `open`: Sheet is fully visible
- `opening`: Sheet is animating open
### Snap Points
- `Hidden` (0): Dismissed
- `Partial` (1): Half-expanded / content height
- `Full` (2): Expanded to screen height
## Usage
### Basic Example
```tsx
import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
// In your app root:
function App() {
return (
<BottomSheetProvider>
<YourApp />
<BottomSheetOutlet />
</BottomSheetProvider>
)
}
// In a component:
function MyComponent() {
const sheetRef = useRef<BottomSheet>(null)
const openSheet = () => {
sheetRef.current?.present()
}
const closeSheet = () => {
sheetRef.current?.dismiss()
}
return (
<>
<Button onPress={openSheet} title="Open Sheet" />
<BottomSheet
ref={sheetRef}
cornerRadius={16}
backgroundColor="white"
onStateChange={(e) => console.log(e.nativeEvent.state)}
>
<View style={{padding: 20}}>
<Text>Sheet content</Text>
<Button onPress={closeSheet} title="Close" />
</View>
</BottomSheet>
</>
)
}
```
### Nested Sheets
The module supports nesting sheets by using `BottomSheetPortalProvider` within sheet content:
```tsx
<BottomSheet ref={outerSheetRef}>
<BottomSheetPortalProvider>
<Button onPress={() => innerSheetRef.current?.present()} />
<BottomSheet ref={innerSheetRef}>
<Text>Inner sheet content</Text>
</BottomSheet>
</BottomSheetPortalProvider>
</BottomSheet>
```
### Dismiss All Sheets
```tsx
import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
BottomSheetNativeComponent.dismissAll()
```
## Key Implementation Details
### iOS Specific
1. **iOS 15 Compatibility**: On iOS 15, custom detents are not available, so the module uses `.medium()` detent and applies extra styling to prevent visual issues.
2. **iOS 26+ Zoom Transitions**: When `sourceViewTag` is provided on iOS 26+, the sheet zooms from the specified view.
3. **Detent Selection**: The module automatically chooses between custom detents, `.medium()`, and `.large()` based on content height and screen size.
### Android Specific
1. **Edge-to-Edge**: The module handles edge-to-edge display correctly across API levels:
- API 35+: Mandatory edge-to-edge
- API 30-34: Uses `currentWindowMetrics`
- API <30: Uses deprecated `getRealSize()`
2. **Status/Nav Bar Appearance**: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
3. **Drag Handling**: On full-height sheets with `preventDismiss`, dragging is disabled to prevent accidental dismissal (since there's no half-expanded snap point to land on).
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
- **disableDrag**: Android-only prop (iOS drag behavior is controlled via `preventDismiss` + `preventExpansion`)
- **sourceViewTag**: iOS 26+ only (ignored on Android)
## Files Reference
### TypeScript
- `index.ts` - Public API exports
- `src/BottomSheet.types.ts` - TypeScript type definitions
- `src/BottomSheet.tsx` - Native component (re-export)
- `src/BottomSheet.web.tsx` - Web stub
- `src/BottomSheetNativeComponent.tsx` - Native wrapper with portal integration
- `src/BottomSheetNativeComponent.web.tsx` - Web stub for native component
- `src/BottomSheetPortal.tsx` - Portal context and providers
- `src/lib/Portal.tsx` - Generic portal implementation
### iOS
- `ios/BottomSheetModule.swift` - Module definition
- `ios/SheetView.swift` - Main view implementation
- `ios/SheetViewController.swift` - View controller for sheet presentation
- `ios/SheetManager.swift` - Singleton for tracking active sheets
- `ios/Util.swift` - Screen height utility
### Android
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt` - Module definition
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt` - Main view implementation
- `android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt` - Dialog root view group
- `android/src/main/java/expo/modules/bottomsheet/SheetManager.kt` - Sheet tracking singleton
### Configuration
- `expo-module.config.json` - Expo module configuration
@@ -1,4 +1,4 @@
import * as React from 'react'
import {Component, createRef} from 'react'
import {
Dimensions,
type LayoutChangeEvent,
@@ -21,9 +21,11 @@ import {
Context as PortalContext,
} from './BottomSheetPortal'
type NativeViewRef = {dismiss: () => void}
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
ref: React.RefObject<NativeViewRef | null>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
@@ -39,14 +41,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
ref = createRef<NativeViewRef>()
static contextType = PortalContext
@@ -129,6 +131,7 @@ export class BottomSheetNativeComponent extends React.Component<
function BottomSheetNativeComponentInner({
children,
backgroundColor,
maxHeight,
onLayout,
onStateChange,
nativeViewRef,
@@ -139,7 +142,7 @@ function BottomSheetNativeComponentInner({
onStateChange: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: React.RefObject<View>
nativeViewRef: React.RefObject<NativeViewRef | null>
onLayout?: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
@@ -156,6 +159,7 @@ function BottomSheetNativeComponentInner({
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
@@ -170,6 +174,7 @@ function BottomSheetNativeComponentInner({
flex: 1,
backgroundColor,
},
maxHeight != null && {maxHeight},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
@@ -177,7 +182,9 @@ function BottomSheetNativeComponentInner({
},
extraStyles,
]}>
<View onLayout={onLayout}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>