Popup
通用弹出层,五个方向 + 遮罩
弹出层(Popup)是最基础的覆盖层组件,基于 react-native-modal 封装:五个弹出方向各自带默认进出场动画,容器负责遮罩、安全区、圆角与背景。Dialog、ActionSheet 等组件都建立在它之上。
import { Popup } from '@skyroc/native-ui';弹出位置
position 支持 center(默认)、top、bottom、left、right,每个方向都有对应的默认动画:
| 位置 | 进场 | 出场 | 容器尺寸 |
|---|---|---|---|
center | fadeIn | fadeOut | 由内容撑开 |
top | slideInDown | slideOutUp | 满宽 |
bottom | slideInUp | slideOutDown | 满宽 |
left | slideInLeft | slideOutLeft | 满高、宽 75% |
right | slideInRight | slideOutRight | 满高、宽 75% |
import type { PopupPosition } from '@skyroc/native-ui';
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const POSITIONS: PopupPosition[] = ['center', 'top', 'bottom', 'left', 'right'];
const PopupPositions = () => {
// show 和 position 必须拆成两个 state。
// 若用 `position !== null` 当 show,关闭时 position 会在退场动画播放途中被清空,
// 动画和容器对齐方式会当场跳变成 center 的那一套。
const [position, setPosition] = useState<PopupPosition>('center');
const [show, setShow] = useState(false);
function openPosition(next: PopupPosition) {
setPosition(next);
setShow(true);
}
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
{POSITIONS.map(item => (
<Button
key={item}
variant="tonal"
onPress={() => openPosition(item)}
>
{item}
</Button>
))}
<Popup
round
position={position}
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title={`position = ${position}`}
onClose={() => setShow(false)}
>
<Text color="muted">点击遮罩也可以关闭</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupPositions };何时使用
- 需要自己完全掌控弹层内容时用它。
- 标准的确认框用
Dialog,动作面板用ActionSheet,可拖拽的底部面板用Sheet。
圆角
round 统一控制五个方向的圆角,方向由 position 决定 —— 贴边的那一侧不加圆角(bottom 只有上方两角,center 是四角全圆)。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const PopupRound = () => {
// round 要在退场动画结束前保持不变,所以和 show 拆成两个 state
const [round, setRound] = useState(true);
const [show, setShow] = useState(false);
function openRound(next: boolean) {
setRound(next);
setShow(true);
}
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => openRound(true)}
>
居中 + 圆角
</Button>
<Button
variant="tonal"
onPress={() => openRound(false)}
>
居中 + 直角
</Button>
<Popup
position="center"
round={round}
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title={round ? 'round = true' : 'round = false'}
onClose={() => setShow(false)}
>
<Text color="muted">居中弹层的圆角同样由 round 控制</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupRound };容器背景
surface(默认 true)决定容器自身是否绘制背景。内容自带卡片时必须传 surface={false}:容器的不透明底铺满整个矩形,会盖在子节点圆角的外侧,把圆角原样填成直角,而且改子节点的 className 怎么都没用。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const PopupSurface = () => {
const [show, setShow] = useState(false);
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => setShow(true)}
>
透明容器 + 自绘卡片
</Button>
<Popup
className="w-[85%] max-w-[320px]"
position="center"
show={show}
surface={false}
onUpdateShow={setShow}
>
{/* 卡片自己画背景和圆角,容器只负责定位 */}
<View className="overflow-hidden rounded-3xl bg-background">
<PopupPanel
title="自绘卡片"
onClose={() => setShow(false)}
>
<Text color="muted">圆角来自卡片本身,容器是透明的</Text>
</PopupPanel>
</View>
</Popup>
</View>
);
};
export { PopupSurface };抽屉宽度
left / right 默认 w-3/4(不给宽度会被内容撑成任意宽)。cn 底层是 tailwind-merge,直接传 className="w-1/2" 即可覆盖。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const PopupDrawer = () => {
const [show, setShow] = useState(false);
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => setShow(true)}
>
半宽抽屉(w-1/2)
</Button>
<Popup
round
className="w-1/2"
position="left"
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title="半宽抽屉"
onClose={() => setShow(false)}
>
<Text color="muted">className 覆盖掉了默认的 w-3/4</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupDrawer };安全区避让
safeAreaInsetTop / safeAreaInsetBottom 分别避让状态栏(刘海)与 home indicator。
import type { PopupPosition } from '@skyroc/native-ui';
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const PopupSafeArea = () => {
const [position, setPosition] = useState<Extract<PopupPosition, 'bottom' | 'top'>>('bottom');
const [show, setShow] = useState(false);
function openPosition(next: Extract<PopupPosition, 'bottom' | 'top'>) {
setPosition(next);
setShow(true);
}
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => openPosition('bottom')}
>
底部 + 安全区
</Button>
<Button
variant="tonal"
onPress={() => openPosition('top')}
>
顶部 + 安全区
</Button>
<Popup
round
safeAreaInsetBottom={position === 'bottom'}
safeAreaInsetTop={position === 'top'}
position={position}
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title={position === 'bottom' ? '底部弹层' : '顶部弹层'}
onClose={() => setShow(false)}
>
<Text color="muted">
{position === 'bottom' ? '面板底部留出了 home indicator 的高度' : '面板顶部避让状态栏与刘海区域'}
</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupSafeArea };两者走的是 uniwind 的 pt-safe / pb-safe,而不是读 SafeAreaInsetsContext:安全区尺寸由应用根节点的 Uniwind.updateInsets 同步进运行时,不依赖弹层所在的 React 树里有没有 SafeAreaProvider —— 弹层挂在原生 Modal 里,context 本来就未必接得上。
关闭与遮罩
closeOnBackdropPress 与 closeOnBackPress(Android 硬件返回键)是独立的两个开关:无遮罩可点时不至于无路可退。两者都通过 onUpdateShow(false) 通知外部 —— 组件本身不持有显隐状态。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
/** CloseOnBackdropPress 与 closeOnBackPress 相互独立:点遮罩关不掉时,Android 返回键仍然可以退出 */
const PopupBackdrop = () => {
const [lockedShow, setLockedShow] = useState(false);
const [backLockedShow, setBackLockedShow] = useState(false);
const [backdropShow, setBackdropShow] = useState(false);
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => setLockedShow(true)}
>
点遮罩不关闭
</Button>
<Button
variant="tonal"
onPress={() => setBackLockedShow(true)}
>
返回键不关闭
</Button>
<Button
variant="tonal"
onPress={() => setBackdropShow(true)}
>
自定义遮罩
</Button>
<Popup
round
closeOnBackdropPress={false}
position="center"
show={lockedShow}
onUpdateShow={setLockedShow}
>
<PopupPanel
title="点遮罩不关闭"
onClose={() => setLockedShow(false)}
>
<Text color="muted">只能点下面的按钮,或按 Android 返回键</Text>
</PopupPanel>
</Popup>
<Popup
round
closeOnBackPress={false}
position="center"
show={backLockedShow}
onUpdateShow={setBackLockedShow}
>
<PopupPanel
title="Android 返回键不关闭"
onClose={() => setBackLockedShow(false)}
>
<Text color="muted">点击遮罩或面板按钮仍可关闭</Text>
</PopupPanel>
</Popup>
<Popup
round
backdropColor="#1d4ed8"
backdropOpacity={0.75}
position="center"
show={backdropShow}
onUpdateShow={setBackdropShow}
>
<PopupPanel
title="自定义遮罩"
onClose={() => setBackdropShow(false)}
>
<Text color="muted">backdropColor #1d4ed8,backdropOpacity 0.75</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupBackdrop };backdropColor(默认 '#000')与 backdropOpacity(默认 0.4)自定义遮罩。
动画
animation 覆盖该位置的默认动画,只传一个方向时另一个方向仍走默认值;duration 是进出场时长(默认 300ms)。动画名取自 react-native-animatable。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
/** Animation 只覆盖传入的那个方向,这里只指定了 in,out 仍走 bottom 的默认 slideOutDown */
const PopupAnimation = () => {
const [show, setShow] = useState(false);
return (
<View className="flex-row flex-wrap items-center gap-3 bg-background p-4">
<Button
variant="tonal"
onPress={() => setShow(true)}
>
zoomIn 进 / 默认出(800ms)
</Button>
<Popup
round
animation={{ in: 'zoomIn' }}
duration={800}
position="bottom"
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title="混搭动画"
onClose={() => setShow(false)}
>
<Text color="muted">进场 zoomIn,退场仍是 bottom 默认的 slideOutDown</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupAnimation };生命周期
onOpened / onClosed 都在对应动画结束后触发。
import { Button, Popup, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
const PopupLifecycle = () => {
const [show, setShow] = useState(false);
const [openedCount, setOpenedCount] = useState(0);
const [closedCount, setClosedCount] = useState(0);
return (
<View className="gap-3 bg-background p-4">
<View className="flex-row flex-wrap items-center gap-3">
<Button
variant="tonal"
onPress={() => setShow(true)}
>
onOpened / onClosed
</Button>
</View>
<Text color="muted">
已打开 {openedCount} 次,已关闭 {closedCount} 次(都在动画结束后触发)
</Text>
<Popup
round
position="center"
show={show}
onClosed={() => setClosedCount(prev => prev + 1)}
onOpened={() => setOpenedCount(prev => prev + 1)}
onUpdateShow={setShow}
>
<PopupPanel
title="生命周期"
onClose={() => setShow(false)}
>
<Text color="muted">关掉后回到列表看计数变化</Text>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupLifecycle };与 Toast 共存
原生 Modal 会盖住一切,包括 Portal 渲染的 Toast。需要两者共存时传 coverScreen={false} 让弹层就地渲染(该属性透传给 react-native-modal)。
import { Button, Popup, Text, showToast } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { PopupPanel } from './shared';
/**
* 默认 coverScreen 会开一个原生窗口,Portal 渲染的 Toast 会被挡在后面。 传 coverScreen={false} 让弹层就地渲染即可共存,代价是盖不住原生导航栏,且 Android 返回键不再生效。
*
* CoverScreen={false} 渲染的是一个 absolute inset-0 的 View,定位相对父容器, 所以这里的根容器要有确定高度,弹层才有地方铺开。
*/
const PopupInline = () => {
const [show, setShow] = useState(false);
return (
<View className="min-h-80 flex-1 bg-background p-4">
<View className="flex-row flex-wrap items-center gap-3">
<Button
variant="tonal"
onPress={() => setShow(true)}
>
就地渲染 + Toast
</Button>
</View>
<Popup
round
coverScreen={false}
position="bottom"
show={show}
onUpdateShow={setShow}
>
<PopupPanel
title="就地渲染"
onClose={() => setShow(false)}
>
<Text color="muted">下面这个 Toast 能盖在弹层之上</Text>
<Button
variant="tonal"
onPress={() => showToast('Toast 在弹层上方')}
>
弹一条 Toast
</Button>
</PopupPanel>
</Popup>
</View>
);
};
export { PopupInline };API
Popup
除下表外,Popup 透传 react-native-modal 的属性(avoidKeyboard、coverScreen、statusBarTranslucent、style 等),但下列属性由组件接管:isVisible、animationIn/Out、animationIn/OutTiming、backdropColor、backdropOpacity、onBackdropPress、onBackButtonPress、onModalShow、onModalHide、useNativeDriver。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| show* | 是否显示弹出层 | boolean | - |
| onUpdateShow | 显示状态变化回调(遮罩点击、返回键触发) | (show: boolean) => void | - |
| children | 弹出层内容 | ReactNode | - |
| position | 弹出位置,同时决定默认动画与容器尺寸 | 'center' | 'top' | 'bottom' | 'left' | 'right' | 'center' |
| round | 是否显示圆角,方向由 position 决定 | boolean | false |
| surface | 容器自身是否绘制背景;内容自带卡片时必须传 false | boolean | true |
| closeOnBackdropPress | 点击遮罩是否关闭 | boolean | true |
| closeOnBackPress | Android 硬件返回键是否关闭,与遮罩点击独立 | boolean | true |
| backdropColor | 遮罩颜色 | string | '#000' |
| backdropOpacity | 遮罩不透明度 | number | 0.4 |
| animation | 覆盖默认进出场动画,只传一个方向时另一个仍走默认值 | { in?: string; out?: string } | - |
| duration | 动画时长(毫秒) | number | 300 |
| onOpened | 打开动画完成后触发 | () => void | - |
| onClosed | 关闭动画完成后触发 | () => void | - |
| safeAreaInsetTop | 顶部是否避让安全区 | boolean | false |
| safeAreaInsetBottom | 底部是否避让安全区 | boolean | false |
| className | 内容区域容器的类名 | string | - |
类型
import type { PopupPosition, PopupProps } from '@skyroc/native-ui';PopupPosition
弹出位置,同时决定默认动画、容器尺寸与圆角方向。
包内还导出了 popupVariants 与 popupAnimationMap(position → 默认进出场动画的映射)。