Skyroc Native UI

Popup

通用弹出层,五个方向 + 遮罩

弹出层(Popup)是最基础的覆盖层组件,基于 react-native-modal 封装:五个弹出方向各自带默认进出场动画,容器负责遮罩、安全区、圆角与背景。Dialog、ActionSheet 等组件都建立在它之上。

import { Popup } from '@skyroc/native-ui';

弹出位置

position 支持 center(默认)、top、bottom、left、right,每个方向都有对应的默认动画:

位置进场出场容器尺寸
centerfadeInfadeOut由内容撑开
topslideInDownslideOutUp满宽
bottomslideInUpslideOutDown满宽
leftslideInLeftslideOutLeft满高、宽 75%
rightslideInRightslideOutRight满高、宽 75%
PopupPositions.tsx
Loading…
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 是四角全圆)。

PopupRound.tsx
Loading…
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 怎么都没用。

PopupSurface.tsx
Loading…
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" 即可覆盖。

PopupDrawer.tsx
Loading…
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。

PopupSafeArea.tsx
Loading…
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) 通知外部 —— 组件本身不持有显隐状态。

PopupBackdrop.tsx
Loading…
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。

PopupAnimation.tsx
Loading…
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 都在对应动画结束后触发。

PopupLifecycle.tsx
Loading…
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)。

PopupInline.tsx
Loading…
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 透传 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 决定booleanfalse
surface容器自身是否绘制背景;内容自带卡片时必须传 falsebooleantrue
closeOnBackdropPress点击遮罩是否关闭booleantrue
closeOnBackPressAndroid 硬件返回键是否关闭,与遮罩点击独立booleantrue
backdropColor遮罩颜色string'#000'
backdropOpacity遮罩不透明度number0.4
animation覆盖默认进出场动画,只传一个方向时另一个仍走默认值{ in?: string; out?: string }-
duration动画时长(毫秒)number300
onOpened打开动画完成后触发() => void-
onClosed关闭动画完成后触发() => void-
safeAreaInsetTop顶部是否避让安全区booleanfalse
safeAreaInsetBottom底部是否避让安全区booleanfalse
className内容区域容器的类名string-

类型

import type { PopupPosition, PopupProps } from '@skyroc/native-ui';

PopupPosition

弹出位置,同时决定默认动画、容器尺寸与圆角方向。

'bottom' | 'center' | 'left' | 'right' | 'top'

包内还导出了 popupVariants 与 popupAnimationMap(position → 默认进出场动画的映射)。