Flutter TabBar实现描点滚动绑定
最近有朋友问到怎么在Flutter实现Tab滚动的效果,Flutter有一套TabBar+TabBarView横向滚动切换的组件,但是这个TabBarView是功能上类似PageView的页面切换组件,效果就是一屏一屏地横向切换,无法做到竖向流式布局。
于是整理了下之前的代码,做了个结合TabBar和CustomScrollView互相控制的锚点绑定滚动效果。
效果预览
实现原理:
锚点定位采用GlobalKey获取RenderObject,再调用ScrollControll.position.ensureVisible,让视窗滚动到指定组件的位置。
锚点绑定是遍历预定义的GlobalKey,获取对象的paintData,可以拿到相对父组件的绘制位置,通过对比来判断当前索引。
代码如下:
import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; class AnchorPage extends StatefulWidget { const AnchorPage({Key? key}) : super(key: key); @override StatecreateState() => _AnchorPageState(); } class _AnchorPageState extends Statewith SingleTickerProviderStateMixin { /// 预定义一组GlobalKey,用于锚点 final keys =[ GlobalKey(debugLabel: 'tab1'), GlobalKey(debugLabel: 'tab2'), GlobalKey(debugLabel: 'tab3'), ]; late final tabController = TabController(length: 3, vsync: this); final scrollController = ScrollController(); static const expandedHeight = 240.0; /// 触发距离 double offset = 50; /// 控制tabbar的左侧缩进,防止与返回箭头重叠 double collapseStep = 0; bool isTabClicked = false; @override void initState() { super.initState(); scrollController.addListener(_onScroll); } @override void dispose() { scrollController.removeListener(_onScroll); super.dispose(); } void _onTabChange(i) { final keyRenderObject = keys[i] .currentContext ?.findAncestorRenderObjectOfType(); if (keyRenderObject != null) { // 点击的时候不让滚动影响tab isTabClicked = true; scrollController.position .ensureVisible(keyRenderObject, duration: const Duration(milliseconds: 300), curve: Curves.easeIn) .then((value) { isTabClicked = false; }); } } void _onScroll() { double newStep = 0; if (scrollController.offset > expandedHeight - kToolbarHeight * 2) { newStep = scrollController.offset > expandedHeight - kToolbarHeight ? 1 : (scrollController.offset - (expandedHeight - kToolbarHeight * 2)) / kToolbarHeight; } setState(() { collapseStep = newStep; }); if (isTabClicked) return; int i = 0; for (; i < keys.length; i++) { final keyRenderObject = keys[i] .currentContext ?.findAncestorRenderObjectOfType(); if (keyRenderObject != null) { final offsetY = (keyRenderObject.parentData as SliverPhysicalParentData) .paintOffset .dy; if (offsetY > kToolbarHeight + offset) { break; } } } final newIndex = i == 0 ? 0 : i - 1; if (newIndex != tabController.index) { tabController.animateTo(newIndex); } } @override Widget build(BuildContext context) { final tabBar = TabBar( controller: tabController, labelColor: Colors.white, unselectedLabelColor: Colors.grey.shade300, indicatorColor: Colors.white, onTap: _onTabChange, tabs: const [ Tab(child: Text('Tab1')), Tab(child: Text('Tab2')), Tab(child: Text('Tab3')), ], ); return Scaffold( body: CustomScrollView( controller: scrollController, slivers: [ SliverAppBar( pinned: true, expandedHeight: expandedHeight, collapsedHeight: kToolbarHeight, flexibleSpace: FlexibleSpaceBar( title: Container( height: kToolbarHeight, alignment: Alignment.center, child: tabBar, ), expandedTitleScale: 1, titlePadding: EdgeInsets.only(left: 50 * collapseStep), collapseMode: CollapseMode.pin, background: const Padding( padding: EdgeInsets.symmetric(vertical: kToolbarHeight), child: FittedBox( child: FlutterLogo(), ), ), ), ), SliverToBoxAdapter( child: ListTitle( 'List 1', key: keys[0], ), ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => Item(index), childCount: 8), ), SliverToBoxAdapter( child: ListTitle( 'List 2', key: keys[1], ), ), SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => Item(index), childCount: 9, ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3), ), SliverToBoxAdapter( child: ListTitle( 'List 3', key: keys[2], ), ), SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => Item(index), childCount: 8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2), ), ], ), ); } } /// 标题组件 class ListTitle extends StatelessWidget { final String text; const ListTitle(this.text, {Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), padding: const EdgeInsets.only(left: 8), decoration: const TitleDecoration(), child: Text(text), ); } } /// 标题锚点的装饰 class TitleDecoration extends Decoration { final double? width; final Color? color; const TitleDecoration({ this.width, this.color, }); @override BoxPainter createBoxPainter([VoidCallback? onChanged]) { return TitleBoxPainter(this); } } class TitleBoxPainter extends BoxPainter { final TitleDecoration decoration; TitleBoxPainter(this.decoration); @override void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(offset.dx, offset.dy, decoration.width ?? 4, configuration.size?.height ?? 0), const Radius.circular(8)), Paint() ..color = decoration.color ?? Colors.blue ..style = PaintingStyle.fill, ); } } /// 测试用,显示元素 class Item extends StatelessWidget { const Item(this.index, {Key? key}) : super(key: key); final int index; @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, decoration: BoxDecoration(color: Colors.primaries[index % 18]), child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Text('text $index')), ); } }