Flutter TabBar实现描点滚动绑定

1,672 阅读3分钟

前言

最近有朋友问到怎么在Flutter实现Tab滚动的效果,Flutter有一套TabBar+TabBarView横向滚动切换的组件,但是这个TabBarView是功能上类似PageView的页面切换组件,效果就是一屏一屏地横向切换,无法做到竖向流式布局。

于是整理了下之前的代码,做了个结合TabBar和CustomScrollView互相控制的锚点绑定滚动效果。

效果演示

实现原理

锚点定位采用GlobalKey获取RenderObject,再调用ScrollControll.position.ensureVisible,让视窗滚动到指定组件的位置。

锚点绑定是遍历预定义的GlobalKey,获取对象的paintData,可以拿到相对父组件的绘制位置,通过对比来判断当前索引。

问题点

锚点和滚动位置互相绑定,就需要互相触发。如果两个都做监听Listener触发,会引递归调用的问题,虽然不会引起死循环,但会引起滑动效果卡顿。

比如:在点击Tab项时,触发了列表滚动,列表滚动又触发了Tab切换,因为在滚动刚起步时,计算出的索引项不一定是点击这个索引项,这就导致TabBar的Indecator没有立即切过去,而是随滚动后再切过去。

这里的处理办法是不监听TabBar的change事件,而是监听Click,在Click中去触发滚动定位锚点,同时在滚动前设置一个Tab点击状态(isTabClicked),这样在滚动事件中就可以根据这个状态判断是否再去处理TabBar的索引,最终效果也会比较自然。

代码

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class AnchorPage extends StatefulWidget {
  const AnchorPage({Key? key}) : super(key: key);

  @override
  State<AnchorPage> createState() => _AnchorPageState();
}

class _AnchorPageState extends State<AnchorPage>
    with SingleTickerProviderStateMixin {
  /// 预定义一组GlobalKey,用于锚点
  final keys = <GlobalKey>[
    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<RenderSliverToBoxAdapter>();
    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<RenderSliverToBoxAdapter>();
      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')),
    );
  }
}