Skyroc Native UI

FloatingButton

可拖拽、可吸附边缘的悬浮操作按钮

悬浮按钮(FloatingButton)浮在页面之上提供一个常驻入口,支持拖拽、松手吸附边缘和显隐动画。位置完全由 Reanimated 的 transform 驱动,拖拽与吸附全程留在 UI 线程,滚动列表时也不会掉帧。

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

基础用法

默认 axis="y",按钮只能上下拖动;onPress 处理点击。手势是 Gesture.Exclusive(pan, tap)——手指一旦移动就判定为拖拽,onPress 不会再触发,因此拖完手不会误触发点击。

FloatingButtonBasic.tsx
Loading…
import { FloatingButton, Portal, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

const FloatingButtonBasic = () => {
  const [pressCount, setPressCount] = useState(0);

  function handlePress() {
    setPressCount(current => current + 1);
  }

  return (
    <View className="bg-background p-4">
      <Text className="text-sm text-muted-foreground">
        已点击 {pressCount} 次;默认 axis=&quot;y&quot;,可上下拖动。
      </Text>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton onPress={handlePress}>
          <Text className="text-xs font-bold text-primary-foreground">+1</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonBasic };

坐标系约定

组件在内部铺一层 absolute inset-0 的测量层(pointerEvents="box-none",只做尺寸探针,不接管任何触摸),把父容器的实测尺寸作为可视边界,原点是父容器左上角。

因此:

  • 挂在整屏容器里就按屏幕算,挂在手机框预览、平板分栏这类定宽容器里就按那个容器算,两种场景都不会把按钮推到可视区外;
  • 想让按钮盖在所有内容之上,把它放在容器的最后一个子节点,或者像示例里那样用 Portal 送到全屏的 PortalHost
  • 首帧 layout 还没回来时用窗口尺寸兜底,量到真实尺寸后按新边界把位置补正过去,这次补正不播放动画(属于纠正估算,不是位置变化)。

不传 offset 时按钮默认停在右下角:横向贴右边界,纵向距下边界再留 100px——直接贴底会压住 TabBar 一类的常驻栏。

拖拽方向

axis 约束拖拽轴向。

取值行为
y只能上下拖(默认)
x只能左右拖
xy自由拖拽
lock完全不可拖,只保留点击
FloatingButtonAxis.tsx
Loading…
import { Button, FloatingButton, Portal, Text } from '@skyroc/native-ui';
import type { FloatingButtonAxis as FloatingButtonAxisValue } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

const AXIS_OPTIONS: { label: string; value: FloatingButtonAxisValue }[] = [
  { label: '横向', value: 'x' },
  { label: '纵向', value: 'y' },
  { label: '自由', value: 'xy' },
  { label: '锁定', value: 'lock' }
];

const FloatingButtonAxis = () => {
  const [axis, setAxis] = useState<FloatingButtonAxisValue>('xy');

  return (
    <View className="gap-3 bg-background p-4">
      <View className="flex-row flex-wrap gap-2">
        {AXIS_OPTIONS.map(option => (
          <Button
            key={option.value}
            size="sm"
            variant={axis === option.value ? 'solid' : 'outline'}
            onPress={() => setAxis(option.value)}
          >
            {option.label}
          </Button>
        ))}
      </View>
      <Text className="text-sm text-muted-foreground">当前 axis=&quot;{axis}&quot;</Text>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton
          axis={axis}
          className="bg-success"
        >
          <Text className="text-xs font-bold uppercase text-success-foreground">{axis}</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonAxis };

受控位置与边距

offset 让位置受控,onOffsetChange 在拖拽结束时回传落位坐标。gap 是距父容器边缘的最小留白,默认 24;给数字表示两轴相同,给对象可以分别指定,缺省的一边回落到默认值。

拖拽与受控 offset 都会被夹进 [gap, 容器尺寸 - size - gap],所以外部写一个越界坐标也不会把按钮推出可视区。需要贴边或自行管理边距时传 0

FloatingButtonOffset.tsx
Loading…
import { Button, FloatingButton, Portal, Text } from '@skyroc/native-ui';
import type { FloatingButtonOffset as FloatingButtonOffsetValue } from '@skyroc/native-ui';
import { useState } from 'react';
import { View, useWindowDimensions } from 'react-native';

const BUTTON_SIZE = 48;
const HORIZONTAL_GAP = 16;
const VERTICAL_GAP = 80;

const FloatingButtonOffset = () => {
  const { height: windowHeight, width: windowWidth } = useWindowDimensions();

  const [offset, setOffset] = useState<FloatingButtonOffsetValue>({
    x: HORIZONTAL_GAP,
    y: windowHeight - BUTTON_SIZE - VERTICAL_GAP
  });

  const offsetLabel = `x=${Math.round(offset.x)}, y=${Math.round(offset.y)}`;

  function moveToLeft() {
    setOffset(current => ({ x: HORIZONTAL_GAP, y: current.y }));
  }

  function moveToRight() {
    setOffset(current => ({ x: windowWidth - BUTTON_SIZE - HORIZONTAL_GAP, y: current.y }));
  }

  return (
    <View className="gap-3 bg-background p-4">
      <View className="flex-row gap-2">
        <Button
          size="sm"
          variant="outline"
          onPress={moveToLeft}
        >
          移到左侧
        </Button>
        <Button
          size="sm"
          variant="outline"
          onPress={moveToRight}
        >
          移到右侧
        </Button>
      </View>
      <Text className="text-sm text-muted-foreground">当前 offset:{offsetLabel}</Text>

      <Portal>
        <FloatingButton
          axis="xy"
          className="bg-info"
          gap={{ x: HORIZONTAL_GAP, y: VERTICAL_GAP }}
          offset={offset}
          onOffsetChange={setOffset}
        >
          <Text className="text-xs font-bold text-info-foreground">POS</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonOffset };

边界重夹在非受控(纯拖拽)时也会跑:旋转屏幕或容器尺寸变小后上限会变,按钮一旦停在可视区外就既点不到也拖不回来。

边缘吸附

magnetic 让松手后自动吸附到该轴向最近的一侧边缘:'x' 吸左右、'y' 吸上下。回弹用的弹簧把阻尼比压到略大于临界值,保留减速手感但完全不过冲——位移一旦过冲,按钮会先冲出 gap 边界再退回来,等于视觉上违反了组件自己声明的边距。

FloatingButtonMagnetic.tsx
Loading…
import { Button, FloatingButton, Portal, Text } from '@skyroc/native-ui';
import type { FloatingButtonMagnetic as FloatingButtonMagneticValue, FloatingButtonOffset } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

const FloatingButtonMagnetic = () => {
  const [magnetic, setMagnetic] = useState<FloatingButtonMagneticValue>('x');
  const [magneticOffset, setMagneticOffset] = useState<FloatingButtonOffset | null>(null);

  function handleMagneticChange(offset: FloatingButtonOffset) {
    setMagneticOffset(offset);
  }

  return (
    <View className="gap-3 bg-background p-4">
      <View className="flex-row gap-2">
        <Button
          size="sm"
          variant={magnetic === 'x' ? 'solid' : 'outline'}
          onPress={() => setMagnetic('x')}
        >
          吸附左右
        </Button>
        <Button
          size="sm"
          variant={magnetic === 'y' ? 'solid' : 'outline'}
          onPress={() => setMagnetic('y')}
        >
          吸附上下
        </Button>
      </View>
      <Text className="text-sm text-muted-foreground">
        最近一次落位:
        {magneticOffset ? ` x=${Math.round(magneticOffset.x)}, y=${Math.round(magneticOffset.y)}` : ' —'}
      </Text>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton
          key={magnetic}
          axis="xy"
          className="bg-warning"
          magnetic={magnetic}
          onOffsetChange={handleMagneticChange}
        >
          <Text className="text-xs font-bold uppercase text-warning-foreground">{magnetic}</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonMagnetic };

开了 magneticonOffsetChange 给的是吸附终点,且在回弹动画刚开始时就触发,不是动画结束的回调。

显隐

visible 切换时走 180ms 的缩放缓动。两个方向都用 timing 而不是弹簧:缩放上的过冲没有一个方向说得通,收起时冲到负值会把视图镜像翻转,出现时冲过 1 也只是白晃一下。

visible 还接受 SharedValue<boolean>——显隐判断全程留在 UI 线程,不必为每次滚动回到 JS 线程再触发一次渲染(BackTop 就是这么用的)。

FloatingButtonVisible.tsx
Loading…
import { Button, FloatingButton, Portal, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { useSharedValue } from 'react-native-reanimated';

const FloatingButtonVisible = () => {
  const [visibleFlag, setVisibleFlag] = useState(true);

  const visible = useSharedValue(true);

  function toggleVisible() {
    const nextVisible = !visibleFlag;

    setVisibleFlag(nextVisible);
    visible.value = nextVisible;
  }

  return (
    <View className="bg-background p-4">
      <View className="gap-3">
        <Text className="text-sm text-muted-foreground">
          本例直接传 SharedValue&lt;boolean&gt;,显隐更新留在 UI 线程。
        </Text>
        <Button
          color="primary"
          variant="solid"
          onPress={toggleVisible}
        >
          {visibleFlag ? '隐藏悬浮按钮' : '显示悬浮按钮'}
        </Button>
      </View>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton
          className="bg-destructive"
          visible={visible}
        >
          <Text className="text-xs font-bold text-destructive-foreground">HI</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonVisible };

缩放小于 0.5 时按钮不再接收触摸:隐藏动画跑完前按钮几乎看不见,此时还能点中就是误触;反过来显示动画过半即可点击,不必等缓动走完。

SharedValue 时首帧在 UI 线程直接同步且不播放动画(React 渲染阶段读不了 .value);传普通 boolean 时初值直接给到位,visible={false} 挂载不会先闪一下按钮。

禁用

disabled 同时关掉点击与拖拽手势,并把整体不透明度降到 50%。

FloatingButtonDisabled.tsx
Loading…
import { Button, FloatingButton, Portal, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';

const FloatingButtonDisabled = () => {
  const [disabled, setDisabled] = useState(false);
  const [pressCount, setPressCount] = useState(0);

  function handlePress() {
    setPressCount(current => current + 1);
  }

  return (
    <View className="bg-background p-4">
      <View className="gap-3">
        <Text className="text-sm text-muted-foreground">已点击 {pressCount} 次;禁用后计数和位置都不会变化。</Text>
        <Button
          color="primary"
          variant="outline"
          onPress={() => setDisabled(prev => !prev)}
        >
          {disabled ? '解除禁用' : '禁用紫色按钮'}
        </Button>
      </View>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton
          axis="xy"
          className="bg-info"
          disabled={disabled}
          onPress={handlePress}
        >
          <Text className="text-xs font-bold text-info-foreground">{disabled ? 'OFF' : 'ON'}</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonDisabled };

尺寸与自定义内容

size 是按钮直径(像素,默认 48),children 放内容,className 覆盖默认的底色与圆角(默认是 bg-primary + 全圆角 + 阴影)。style 叠在动画样式之上,用于写类名表达不了的原生样式。

FloatingButtonSize.tsx
Loading…
import { FloatingButton, Portal, Text } from '@skyroc/native-ui';
import { View } from 'react-native';

const FloatingButtonSize = () => {
  return (
    <View className="bg-background p-4">
      <Text className="text-sm text-muted-foreground">size=64,className 覆盖底色,children 使用自定义文字。</Text>

      {/* 边界按父容器实测尺寸算;这里套一层铺满屏幕的 PortalHost,让按钮浮在整页之上而不是被示例卡片框住 */}
      <Portal>
        <FloatingButton
          axis="xy"
          className="bg-carbon"
          size={64}
        >
          <Text className="text-sm font-bold text-carbon-foreground">BIG</Text>
        </FloatingButton>
      </Portal>
    </View>
  );
};

export { FloatingButtonSize };

按下时不透明度降到 0.8(100ms 过渡),抬起恢复;disabled 的 50% 优先于按下态。

无障碍

组件目前不设置 accessibilityRole,也不透传无障碍属性——读屏器只会读到 children 里的内容,不会播报「按钮」角色。对无障碍要求较高的场景,建议在 children 里放带明确文案的 Text,并保证同样的操作在页面里还有一个常规入口。

API

FloatingButton

属性说明类型默认值
axis拖拽轴向约束,lock 表示完全不可拖、只保留点击'lock' | 'x' | 'xy' | 'y''y'
offset受控位置,原点为父容器左上角;不传则内部自持位置,默认停在右下角FloatingButtonOffset-
onOffsetChange拖拽结束时触发,参数是落位的目标坐标;开了 magnetic 时给的是吸附终点(offset: FloatingButtonOffset) => void-
gap距父容器边缘的最小留白,数字表示两轴相同,对象可分轴指定FloatingButtonGap24
magnetic松手后自动吸附到该轴向最近的一侧边缘'x' | 'y'-
visible是否可见,切换时走 180ms 缩放缓动;传 SharedValue 可把显隐判断留在 UI 线程SharedValue<boolean> | booleantrue
onPress点击回调,拖拽过程不会触发() => void-
disabled禁用点击与拖拽,并整体降低不透明度booleanfalse
size按钮直径(像素)number48
children按钮内容ReactNode-
className根节点类名,可覆盖默认的底色与圆角string-
style根节点自定义样式,叠在动画样式之上StyleProp<ViewStyle>-

类型

import type {
  FloatingButtonAxis,
  FloatingButtonGap,
  FloatingButtonMagnetic,
  FloatingButtonOffset,
  FloatingButtonProps
} from '@skyroc/native-ui';

FloatingButtonAxis

拖拽轴向约束:x 只能横向拖,y 只能纵向拖,xy 自由拖拽,lock 完全不可拖。

'lock' | 'x' | 'xy' | 'y'

FloatingButtonMagnetic

松手后自动吸附到最近边缘的轴向。

'x' | 'y'

FloatingButtonGap

距父容器边缘的最小留白:数字表示两轴相同,对象可分轴指定,缺省的一边回落到默认值 24。

number | { x?: number; y?: number }

FloatingButtonOffset

按钮左上角坐标,原点为父容器左上角。

字段类型说明
x*number横向坐标。
y*number纵向坐标。