You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter需求:实现滚动时可分别固定在顶/底部的Sticky Widget

实现SingleChildScrollView内Widget的动态固定效果

要实现你描述的效果,核心思路是通过滚动监听结合动态定位来控制目标Widget的位置,同时在原滚动流中保留占位避免布局跳动。以下是具体实现方案:

关键实现步骤

  • 使用ScrollController监听SingleChildScrollView的滚动偏移
  • 通过GlobalKey获取目标Widget的全局位置,计算它与视口(AppBar、底部导航栏)的相对位置
  • 根据滚动状态,动态切换目标Widget的定位方式:正常滚动/固定顶部/固定底部
  • 用Stack和Positioned来实现动态定位,原位置用占位Widget维持滚动布局的稳定性

完整代码示例

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const Scaffold(
        appBar: AppBar(title: Text("动态固定Widget示例")),
        bottomNavigationBar: BottomNavigationBar(
          items: [
            BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
            BottomNavigationBarItem(icon: Icon(Icons.settings), label: "设置"),
          ],
        ),
        body: ScrollFixedWidgetDemo(),
      ),
    );
  }
}

class ScrollFixedWidgetDemo extends StatefulWidget {
  const ScrollFixedWidgetDemo({super.key});

  @override
  State<ScrollFixedWidgetDemo> createState() => _ScrollFixedWidgetDemoState();
}

class _ScrollFixedWidgetDemoState extends State<ScrollFixedWidgetDemo> {
  final ScrollController _scrollController = ScrollController();
  final GlobalKey _targetKey = GlobalKey();
  double _appBarHeight = 0;
  double _bottomNavBarHeight = 0;
  double _targetWidgetHeight = 0;
  // 控制目标Widget的位置:0=正常滚动,1=固定顶部,2=固定底部
  int _positionState = 0;

  @override
  void initState() {
    super.initState();
    // 等待布局完成后获取各组件高度
    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        _appBarHeight = AppBar().preferredSize.height;
        _bottomNavBarHeight = BottomNavigationBar(
          items: const [],
        ).preferredSize.height;
        _targetWidgetHeight = _targetKey.currentContext!.size!.height;
      });
      // 添加滚动监听
      _scrollController.addListener(_handleScroll);
    });
  }

  void _handleScroll() {
    if (_targetWidgetHeight == 0) return;

    // 获取目标Widget在全局坐标系中的位置
    final renderBox = _targetKey.currentContext!.findRenderObject() as RenderBox;
    final targetGlobalPos = renderBox.localToGlobal(Offset.zero);
    // 目标Widget的顶部和底部位置(相对于屏幕)
    final targetTop = targetGlobalPos.dy;
    final targetBottom = targetGlobalPos.dy + _targetWidgetHeight;

    // 计算视口的有效范围:顶部是AppBar下方,底部是底部导航栏上方
    final viewportTop = _appBarHeight;
    final viewportBottom = MediaQuery.of(context).size.height - _bottomNavBarHeight;

    setState(() {
      if (targetBottom <= viewportBottom && targetTop >= viewportTop) {
        // 目标Widget完全在视口内,正常滚动
        _positionState = 0;
      } else if (targetBottom > viewportBottom) {
        // 目标Widget底部超出视口,固定在底部
        _positionState = 2;
      } else if (targetTop < viewportTop) {
        // 目标Widget顶部超出视口,固定在顶部
        _positionState = 1;
      }
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        SingleChildScrollView(
          controller: _scrollController,
          child: Column(
            children: [
              // 占位内容:模拟滚动列表
              for (int i = 0; i < 20; i++)
                Container(
                  height: 100,
                  color: i % 2 == 0 ? Colors.grey[200] : Colors.grey[300],
                  alignment: Alignment.center,
                  child: Text("列表项 $i"),
                ),
              // 原位置的占位Widget,避免滚动时布局跳动
              SizedBox(
                key: _targetKey,
                height: _targetWidgetHeight,
              ),
              // 更多滚动内容
              for (int i = 20; i < 40; i++)
                Container(
                  height: 100,
                  color: i % 2 == 0 ? Colors.grey[200] : Colors.grey[300],
                  alignment: Alignment.center,
                  child: Text("列表项 $i"),
                ),
            ],
          ),
        ),
        // 动态定位的目标Widget(标记为50的Widget)
        if (_targetWidgetHeight > 0)
          Positioned(
            top: _positionState == 1 ? _appBarHeight : null,
            bottom: _positionState == 2 ? _bottomNavBarHeight : null,
            left: 0,
            right: 0,
            child: Container(
              height: _targetWidgetHeight,
              color: Colors.blueAccent,
              alignment: Alignment.center,
              child: const Text("标记为50的Widget", style: TextStyle(color: Colors.white, fontSize: 18)),
            ),
          ),
      ],
    );
  }
}

代码关键点解释

  1. 滚动监听与位置计算:通过ScrollController监听滚动,利用GlobalKey获取目标Widget的全局位置,对比视口范围判断当前应该采用哪种定位方式。
  2. 占位Widget:在原滚动流中添加与目标Widget等高的SizedBox,保证滚动时布局不会因为目标Widget的位置变化而出现跳动。
  3. 动态定位:使用Stack和Positioned,根据_positionState的值动态设置top或bottom属性,实现固定顶部、固定底部或正常滚动的切换。

内容的提问来源于stack exchange,提问作者KiddoV

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 07:57:27