Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow nested sheets without boilerplate #5660

Merged
merged 5 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading