Expo 55 / React Native:如何修复滚动时悬浮按钮显隐异常?
可以先检查 CTA 是否与 ScrollView 的实际视口相交,并在滚动、CTA 布局或视口尺寸变化时重新计算。仅凭这段代码,还无法确认异常是否由 Expo 55 的版本变更引起;现有实现中有几个问题需要先排查。
原有判断为什么可能不准确?
WINDOW_HEIGHT 不一定等于 ScrollView 的高度
当前判断使用:
const quizTopOnScreen = quizSectionY.current - y;
const isVisible = quizTopOnScreen < WINDOW_HEIGHT - 80;
即使坐标换算正确,quizTopOnScreen 通常表示 CTA 顶部相对于滚动视口顶部的位置,不一定是相对于整个屏幕的位置。
如果页面有导航栏、页头、底部区域等布局,ScrollView 的实际可视高度可能小于 WINDOW_HEIGHT。固定减去 80 也无法保证视口边界正确。
滚动事件提供了 nativeEvent.layoutMeasurement.height,可以用它参与判断。
onLayout 的 y 是相对于直接父容器的坐标
e.nativeEvent.layout.y 不能直接当作元素在整个滚动内容中的位置。
如果 CTA 的父容器恰好从滚动内容顶部开始,这个 y 可以用于当前计算。如果 CTA 位于嵌套区块中,还需要考虑父容器在滚动内容中的偏移。
例如,CTA 在父容器中的 y 是 100,父容器从内容坐标 600 开始,那么 CTA 的内容坐标应为 700。直接使用 100 - scrollY 会得到错误的位置。
只在滚动时计算,会遗漏布局变化
原代码在 CTA 的 onLayout 中只更新 ref:
quizSectionY.current = e.nativeEvent.layout.y;
这不会重新判断可见性。如果最后一次滚动时 CTA 尚未完成布局,或者后续内容变化导致 CTA 位置改变,悬浮按钮可能一直保留上次计算的状态,直到下一次滚动。
这可以解释部分与时序有关的现象,但是否就是当前页面的实际原因,还需要结合布局和日志确认。
当前表达式没有检查元素是否已经滚出顶部
quizTopOnScreen < viewportHeight 只检查 CTA 顶部是否越过视口下边界,没有检查 CTA 底部是否仍在视口内。
如果“可见”指至少有一部分进入视口,应判断两个区间是否相交:
const isVisible =
ctaBottom > viewportTop &&
ctaTop < viewportBottom;
一个便于核对坐标的实现
下面示例约定:
- CTA 和课程内容位于同一个从滚动内容原点开始的容器内。
- 该容器没有额外的顶部偏移、边框或 transform。
- 示例关闭了自动 content inset 调整;安全区由外层页面处理。
- CTA 只要有一部分进入视口,就隐藏悬浮按钮。
import React, { useCallback, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
Button,
ScrollView,
View,
} from "react-native";
import type {
NativeScrollEvent,
NativeSyntheticEvent,
} from "react-native";
type Props = {
children: ReactNode;
onStartQuiz: () => void;
};
export function LessonScreen({ children, onStartQuiz }: Props) {
const metrics = useRef({
scrollY: 0,
viewportHeight: 0,
ctaY: null as number | null,
ctaHeight: 0,
});
const [showFloatingQuizBtn, setShowFloatingQuizBtn] =
useState(true);
const updateFloatingBtn = useCallback(() => {
const {
scrollY,
viewportHeight,
ctaY,
ctaHeight,
} = metrics.current;
if (
ctaY === null ||
viewportHeight <= 0 ||
ctaHeight <= 0
) {
return;
}
const viewportTop = scrollY;
const viewportBottom = scrollY + viewportHeight;
const ctaTop = ctaY;
const ctaBottom = ctaY + ctaHeight;
const isVisible =
ctaBottom > viewportTop &&
ctaTop < viewportBottom;
const nextShow = !isVisible;
setShowFloatingQuizBtn((current) =>
current === nextShow ? current : nextShow
);
}, []);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, layoutMeasurement } =
event.nativeEvent;
metrics.current.scrollY = contentOffset.y;
metrics.current.viewportHeight = layoutMeasurement.height;
updateFloatingBtn();
},
[updateFloatingBtn]
);
return (
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentInsetAdjustmentBehavior="never"
scrollEventThrottle={16}
onScroll={handleScroll}
onScrollEndDrag={handleScroll}
onMomentumScrollEnd={handleScroll}
onLayout={(event) => {
metrics.current.viewportHeight =
event.nativeEvent.layout.height;
updateFloatingBtn();
}}
onContentSizeChange={() => updateFloatingBtn()}
>
<View>
<View>{children}</View>
<View
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout;
metrics.current.ctaY = y;
metrics.current.ctaHeight = height;
updateFloatingBtn();
}}
>
<Button title="Start Quiz" onPress={onStartQuiz} />
</View>
</View>
</ScrollView>
{showFloatingQuizBtn && (
<View
style={{
position: "absolute",
right: 20,
bottom: 20,
}}
>
<Button title="Start Quiz" onPress={onStartQuiz} />
</View>
)}
</View>
);
}
这里在 onLayout 中立即重算,因此即使内容不足一屏、用户还没有滚动,也可以根据 CTA 的初始位置隐藏悬浮按钮。滚动结束事件用于再次读取结束时的位置,不能替代正确的坐标计算。
onContentSizeChange 只触发重新判断,不会自动将嵌套元素的局部坐标转换为内容坐标。示例仍然依赖前面的布局约定。
如果必须等 CTA 完全可见再隐藏
把相交判断替换为:
const isVisible =
ctaTop >= viewportTop &&
ctaBottom <= viewportBottom;
如果 CTA 比视口还高,这个条件不会成立。应先明确业务需要的是“部分可见”“完全可见”,还是“露出指定高度”。
如果底部有固定覆盖层,应从视口下边界扣除实际被遮挡的高度:
const viewportBottom =
scrollY + viewportHeight - bottomOcclusion;
其中 bottomOcclusion 应对应真实遮挡区域,不能直接沿用来源不明的 80。如果页面布局已经为底部区域预留了空间,就不要重复扣除。
Animated 是否需要调整?
这个需求最终控制的是一个 React 布尔状态。直接使用 onScroll,可以减少经过 Animated.Value 再读取数值的环节,方便核对数据。
但手动使用 onScroll 不会自动修正错误的坐标、视口高度或布局更新时机。你已经确认 y 会更新,下一步应同时记录 scrollY、viewportHeight、CTA 的内容坐标和高度,检查它们是否属于同一个坐标系。
如果页面还需要 scrollAnim 驱动其他动画,可以保留动画逻辑。可见性判断仍应使用一致的测量数据,并在布局变化时重新执行。scrollEventThrottle={16} 也不代表 JavaScript 在任何负载下都能严格每隔 16 ms 完成一次更新。
修正坐标和布局触发点后,如果仍然只有升级后的环境出现异常,再使用同一份最小示例对比升级前后的依赖、平台和 inset 配置,进一步判断是否涉及版本行为变化。
备注:内容仅供参考。
