Skip to content

Commit

Permalink
Allow nested sheets without boilerplate (#5660)
Browse files Browse the repository at this point in the history
Co-authored-by: Hailey <[email protected]>
  • Loading branch information
mozzius and haileyok authored Oct 9, 2024
1 parent b3ade19 commit cca344a
Show file tree
Hide file tree
Showing 20 changed files with 550 additions and 509 deletions.
10 changes: 10 additions & 0 deletions modules/bottom-sheet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ import {
BottomSheetState,
BottomSheetViewProps,
} from './src/BottomSheet.types'
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
import {
BottomSheetOutlet,
BottomSheetPortalProvider,
BottomSheetProvider,
} from './src/BottomSheetPortal'

export {
BottomSheet,
BottomSheetNativeComponent,
BottomSheetOutlet,
BottomSheetPortalProvider,
BottomSheetProvider,
BottomSheetSnapPoint,
type BottomSheetState,
type BottomSheetViewProps,
Expand Down
114 changes: 19 additions & 95 deletions modules/bottom-sheet/src/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,100 +1,24 @@
import * as React from 'react'
import {
Dimensions,
NativeSyntheticEvent,
Platform,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import React from 'react'

import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetNativeComponent} from './BottomSheetNativeComponent'
import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal'

const screenHeight = Dimensions.get('screen').height
export const BottomSheet = React.forwardRef<
BottomSheetNativeComponent,
BottomSheetViewProps
>(function BottomSheet(props, ref) {
const Portal = useBottomSheetPortal_INTERNAL()

const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')

const NativeModule = requireNativeModule('BottomSheet')

export class BottomSheet extends React.Component<
BottomSheetViewProps,
{
open: boolean
}
> {
ref = React.createRef<any>()

constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
open: false,
}
}

present() {
this.setState({open: true})
}

dismiss() {
this.ref.current?.dismiss()
}

private onStateChange = (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => {
const {state} = event.nativeEvent
const isOpen = state !== 'closed'
this.setState({open: isOpen})
this.props.onStateChange?.(event)
}

private updateLayout = () => {
this.ref.current?.updateLayout()
}

static dismissAll = async () => {
await NativeModule.dismissAll()
}

render() {
const {children, backgroundColor, ...rest} = this.props
const cornerRadius = rest.cornerRadius ?? 0

if (!this.state.open) {
return null
}

return (
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
]}>
<View onLayout={this.updateLayout}>{children}</View>
</View>
</NativeView>
if (__DEV__ && !Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
)
}
}

return (
<Portal>
<BottomSheetNativeComponent {...props} ref={ref} />
</Portal>
)
})
103 changes: 103 additions & 0 deletions modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as React from 'react'
import {
Dimensions,
NativeSyntheticEvent,
Platform,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'

import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'

const screenHeight = Dimensions.get('screen').height

const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')

const NativeModule = requireNativeModule('BottomSheet')

export class BottomSheetNativeComponent extends React.Component<
BottomSheetViewProps,
{
open: boolean
}
> {
ref = React.createRef<any>()

constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
open: false,
}
}

present() {
this.setState({open: true})
}

dismiss() {
this.ref.current?.dismiss()
}

private onStateChange = (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => {
const {state} = event.nativeEvent
const isOpen = state !== 'closed'
this.setState({open: isOpen})
this.props.onStateChange?.(event)
}

private updateLayout = () => {
this.ref.current?.updateLayout()
}

static dismissAll = async () => {
await NativeModule.dismissAll()
}

render() {
const {children, backgroundColor, ...rest} = this.props
const cornerRadius = rest.cornerRadius ?? 0

if (!this.state.open) {
return null
}

return (
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
]}>
<View onLayout={this.updateLayout}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
</NativeView>
)
}
}
40 changes: 40 additions & 0 deletions modules/bottom-sheet/src/BottomSheetPortal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react'

import {createPortalGroup_INTERNAL} from './lib/Portal'

type PortalContext = React.ElementType<{children: React.ReactNode}>

const Context = React.createContext({} as PortalContext)

export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)

export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])

return (
<Context.Provider value={portal.Portal}>
<portal.Provider>
{children}
<portal.Outlet />
</portal.Provider>
</Context.Provider>
)
}

const defaultPortal = createPortalGroup_INTERNAL()

export const BottomSheetOutlet = defaultPortal.Outlet

export function BottomSheetProvider({children}: {children: React.ReactNode}) {
return (
<Context.Provider value={defaultPortal.Portal}>
<defaultPortal.Provider>{children}</defaultPortal.Provider>
</Context.Provider>
)
}
67 changes: 67 additions & 0 deletions modules/bottom-sheet/src/lib/Portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react'

type Component = React.ReactElement

type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}

type ComponentMap = {
[id: string]: Component
}

export function createPortalGroup_INTERNAL() {
const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})

function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)

const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])

const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])

const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)

return (
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
)
}

function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}

function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}

return {Provider, Outlet, Portal}
}
19 changes: 11 additions & 8 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'

SplashScreen.preventAutoHideAsync()
Expand Down Expand Up @@ -197,14 +198,16 @@ function App() {
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
Expand Down
Loading

0 comments on commit cca344a

Please sign in to comment.