PAYATHON 2026

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 配置,进一步判断是否涉及版本行为变化。

备注:内容仅供参考。