FloatingButton
可拖拽、可吸附边缘的悬浮操作按钮
悬浮按钮(FloatingButton)浮在页面之上提供一个常驻入口,支持拖拽、松手吸附边缘和显隐动画。位置完全由 Reanimated 的 transform 驱动,拖拽与吸附全程留在 UI 线程,滚动列表时也不会掉帧。
import { FloatingButton } from '@skyroc/native-ui';基础用法
默认 axis="y",按钮只能上下拖动;onPress 处理点击。手势是 Gesture.Exclusive(pan, tap)——手指一旦移动就判定为拖拽,onPress 不会再触发,因此拖完手不会误触发点击。
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="y",可上下拖动。
</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 | 完全不可拖,只保留点击 |
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="{axis}"</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。
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 边界再退回来,等于视觉上违反了组件自己声明的边距。
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 };开了 magnetic 时 onOffsetChange 给的是吸附终点,且在回弹动画刚开始时就触发,不是动画结束的回调。
显隐
visible 切换时走 180ms 的缩放缓动。两个方向都用 timing 而不是弹簧:缩放上的过冲没有一个方向说得通,收起时冲到负值会把视图镜像翻转,出现时冲过 1 也只是白晃一下。
visible 还接受 SharedValue<boolean>——显隐判断全程留在 UI 线程,不必为每次滚动回到 JS 线程再触发一次渲染(BackTop 就是这么用的)。
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<boolean>,显隐更新留在 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%。
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 叠在动画样式之上,用于写类名表达不了的原生样式。
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 | 距父容器边缘的最小留白,数字表示两轴相同,对象可分轴指定 | FloatingButtonGap | 24 |
| magnetic | 松手后自动吸附到该轴向最近的一侧边缘 | 'x' | 'y' | - |
| visible | 是否可见,切换时走 180ms 缩放缓动;传 SharedValue 可把显隐判断留在 UI 线程 | SharedValue<boolean> | boolean | true |
| onPress | 点击回调,拖拽过程不会触发 | () => void | - |
| disabled | 禁用点击与拖拽,并整体降低不透明度 | boolean | false |
| size | 按钮直径(像素) | number | 48 |
| children | 按钮内容 | ReactNode | - |
| className | 根节点类名,可覆盖默认的底色与圆角 | string | - |
| style | 根节点自定义样式,叠在动画样式之上 | StyleProp<ViewStyle> | - |
类型
import type {
FloatingButtonAxis,
FloatingButtonGap,
FloatingButtonMagnetic,
FloatingButtonOffset,
FloatingButtonProps
} from '@skyroc/native-ui';FloatingButtonAxis
拖拽轴向约束:x 只能横向拖,y 只能纵向拖,xy 自由拖拽,lock 完全不可拖。
FloatingButtonMagnetic
松手后自动吸附到最近边缘的轴向。
FloatingButtonGap
距父容器边缘的最小留白:数字表示两轴相同,对象可分轴指定,缺省的一边回落到默认值 24。
FloatingButtonOffset
按钮左上角坐标,原点为父容器左上角。
| 字段 | 类型 | 说明 |
|---|---|---|
| x* | number | 横向坐标。 |
| y* | number | 纵向坐标。 |