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)), ), ), ], ); } }
代码关键点解释
- 滚动监听与位置计算:通过
ScrollController监听滚动,利用GlobalKey获取目标Widget的全局位置,对比视口范围判断当前应该采用哪种定位方式。 - 占位Widget:在原滚动流中添加与目标Widget等高的
SizedBox,保证滚动时布局不会因为目标Widget的位置变化而出现跳动。 - 动态定位:使用
Stack和Positioned,根据_positionState的值动态设置top或bottom属性,实现固定顶部、固定底部或正常滚动的切换。
内容的提问来源于stack exchange,提问作者KiddoV
相关产品推荐
相关产品推荐

