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')),
);
}
}