Flutter——实现交叉列表的联动效果

大家好,
小半年的10-10-6,所以一直没更新,借着五一假期的时间更新一篇热热身,我们开门见山直入主题吧。

效果

实现

说明:代码直接写在了框架项目的demo里,所以用到了一些里面的代码,不过不影响阅读。
     另外,老规矩,说明我还是写在注释里方便阅读

Bedrock MVVM+Provider 开发脚手架

页面布局

根布局

  @override
  Widget build(BuildContext context) {
    return switchStatusBar2Dark(
        child: ProviderWidget(
            builder: (ctx,model,child){
              return _buildPage();
            },
            model: vm,onModelReady: (model) {},));
  }
  Widget _buildPage() {
    return Container(
      width: getWidthPx(750),height: getHeightPx(1334),
      child: Column(
        children: [
          getSizeBox(height: getWidthPx(40) + ScreenUtil.getStatusBarH(context)),
          _buildTabs(), //横向的tab listview
          getSizeBox(height: getWidthPx(40)),
          Expanded(
            child: _buildPageBody(),  //纵向的 list view
          ),
        ],
      ),
    );
  }

横向tab的代码

  Widget _buildTabs() {
    return Container(
      width: getWidthPx(750),height: getWidthPx(100),
      child: ListView.builder(
        cacheExtent: 1500,
        padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
        //用于控制listview,所以添加了scroll controller
        controller: vm.tabController,
        scrollDirection: Axis.horizontal,
        itemCount: vm.tabsTitle.length,
        itemBuilder: (ctx,e) {
          return GestureDetector(
            onTap: () {
              vm.tapTab(e);
            },
            child: TabItemWidget(e).generateWidget(),
          );
        },
      ),
    );
  }

纵向的listview

  Widget _buildPageBody() {
    return Container(
      width: getWidthPx(750),color: Colors.white,
      child: ListView.builder(
      //用于控制listview,所以添加了scroll controller
        controller: vm.bodyController,
        itemCount: vm.tabsTitle.length,
        padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
        itemBuilder: (ctx,e) => BodyItemWidget(e).generateWidget(),
      ),
    );
  }

以上就是页面的基础布局代码,我们接着看一下 item 的widget代码。

item widget 的布局

tab item 的布局代码 :

  @override
  Widget build(BuildContext context) {
    //这里保存一下tab的 context 后面会用到
    vm.saveTabsChildCtx(tabIndex, context);
    
    //这里用selector 控制一下刷新范围
    return Selector(
      selector: (ctx, model) {
        return model.selectTabIndex;
      },
      builder: (ctx,value,child) {
        return Container(
          width: getWidthPx(150),
          padding: EdgeInsets.symmetric(horizontal: getWidthPx(20)),
          margin: EdgeInsets.only(right: getWidthPx(32)),
          decoration: BoxDecoration(
              color: value == tabIndex ? Colors.green : Colors.white,
              borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
              border: Border.all(color: Colors.black,width: getWidthPx(2))
          ),alignment: Alignment.center,
          child: Text('标题  $tabIndex',style: TextStyle(color: Colors.black,fontSize: getSp(20)),),
        );
      },
    );
  }

纵向列表的item 布局代码 :

  late double height;

  @override
  void initState() {
    super.initState();
    vm = Provider.of(context,listen: false);
    //生成一个随机高度
    height = (Random().nextInt(20)).clamp(5, 19) * 50;
  }


  @override
  Widget build(BuildContext context) {
     //保存一下 context 如上
    vm.saveBodyItemCtx(index, context);
    
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
          borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
          border: Border.all(color: Colors.lightBlueAccent,width: getWidthPx(2))
      ),margin: EdgeInsets.only(bottom: getWidthPx(32)),
      child: Column(
        children: [
          Text('标题 $index',style: TextStyle(color: Colors.black,fontSize: getSp(30))),
          Container(
            width: getWidthPx(750),
            height: getWidthPx(height),
            margin: EdgeInsets.symmetric(horizontal: getWidthPx(16),vertical: getWidthPx(42)),
            alignment: Alignment.center,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.all(Radius.circular(getHeightPx(16))),
              color: Colors.grey
            ),child: Text('内容 $index',style: TextStyle(color: Colors.white,fontSize: getSp(50))),
          ),
        ],
      ),
    );
  }

这里页面的整体布局代码就完成了,非常简单,下面来实现具体效果。

效果实现——滚动切换tab

首先我们需要给垂直列表的controller设置一个监听器。

在页面布局中:

  @override
  void initState() {
    super.initState();
    vm.initListeners();
  }
  
  
    void initListeners() {
    bodyController.addListener(() {
      if (isTapAnimateList) return;
      //在不改变缓存的情况下,总是有3个存活
      updateUnMountedList();
      updateTabs();
    });

  }
  

然后我们设置一个变量 isTapAnimateList

  ///表示点击 tab 时,驱动 垂直列表
  bool isTapAnimateList = false;

然后我们通过updateUnMountedList 方法收集/更新一下存活的item的index。

也可以理解成没有被回收的item
  /// 保存 mounted item 用于垂直list
  List? unMountedList;

  ///更新 存活列表
  void updateUnMountedList() {
    unMountedList = bodyChildCtx.entries
        .where((ctx) {
          StatefulElement ele = ctx.value as StatefulElement;
          return (ele.state.mounted);
        })
        .map((e) => e.key)
        .toList();
  }

这里可以看到有个新变量 bodyChildCtx

  ///body item context
  final SplayTreeMap bodyChildCtx = SplayTreeMap();

它实际上就是我们在item widget的build里那个保存方法中用来存储item的context的:

当然,还有tab的保存容器
  ///tab item context
  final SplayTreeMap tabsChildCtx = SplayTreeMap();
  ///body item context
  final SplayTreeMap bodyChildCtx = SplayTreeMap();
  ///用来保存垂直item的相对偏移位置
  final SplayTreeMap itemOffsetY = SplayTreeMap();
  
  void saveBodyItemCtx(int index , BuildContext ctx) {
    bodyChildCtx.update(index, (value) => ctx , ifAbsent: ()=>ctx);
  }

  void saveTabsChildCtx(int index, BuildContext ctx) {
    tabsChildCtx.update(index, (value) => ctx,ifAbsent: () => ctx);
  }

我们接着controller 的回调里面,看第三个方法:


  ///垂直list 当前滚动的位置
  int currentItemIndex = 0;

  ///选择了第几个tab
  int selectTabIndex = 0;
  
   ///滑动垂直list 驱动tabs滚动
  /// * tabs正在滚动时,不响应其它滚动事件
  bool isScrollAnimateTabs = false;

  ///垂直列表滚动时切换上方tabs
  void updateTabs() {
    if (isScrollAnimateTabs) return;
    //当前垂直列表的第几个item在屏幕顶端
    currentItemIndex = currentIndexOnScreen();
    if (selectTabIndex == currentItemIndex) return;
    selectTabIndex = currentItemIndex;
    final ctx = tabsChildCtx[currentItemIndex];
    if (ctx != null) {
   
      StatefulElement ele = ctx as StatefulElement;
      if(!ele.state.mounted) return;
      isScrollAnimateTabs = true;
      //这里我们对tabs做滚动动画
      Scrollable.ensureVisible(ctx, duration: Duration(milliseconds: tabsScrollDuration));
    }
    isScrollAnimateTabs = false;
    //刷新一下tab的选中状态
    notifyListeners(refreshSelector: true);
  }

我们看一下上面中涉及到的currentIndexOnScreen方法:

  ///返回当前垂直列表 在屏(顶端)的index
  int currentIndexOnScreen() {
    if (unMountedList == null || (unMountedList?.isEmpty ?? true)) return 0;
    //这里做个首、尾的处理
    if (bodyController.position.pixels == bodyController.position.minScrollExtent) {
      //head pos
      return unMountedList!.first;
    } else if (bodyController.position.pixels == bodyController.position.maxScrollExtent) {
      //tail pos
      return unMountedList!.last;
    }
    //如果不是首位,我们需要计算一下item的偏移位置
    final double scrollPos = bodyController.position.pixels;
    //collect 计算一下item相对viewport的位置
    collectVerticalItemHeight();
   
    for (int i = 0; i < unMountedList!.length - 1; i++) {
      final int ctxIndex = unMountedList![i];
      //这个20 是灵活的,具体根据需求变化 ,我这里就写个20
      if ((itemOffsetY[ctxIndex]! - scrollPos).abs() < 20) {
        return unMountedList![i];
      }
    }
    //容灾
    return currentItemIndex;

  }

计算item相对viewport的位置方法collectVerticalItemHeight 内部实现为:

  ///收集垂直列表item 偏移位置
  /// * 此处应用item的高度不会变化,所以做了收集避免重复计算
  void collectVerticalItemHeight() {
    final minScrollExtent = bodyController.position.minScrollExtent;
    final maxScrollExtent = bodyController.position.maxScrollExtent;
    unMountedList!.forEach((live) {
      if (itemOffsetY.containsKey(live)) return;
      final BuildContext ctx = bodyChildCtx[live]!;
      final RenderObject renderObject = ctx.findRenderObject()!;
      //通过 item的ctx 获取到它的viweport
      final RenderAbstractViewport viewport = RenderAbstractViewport.of(renderObject)!;
      //通过viewport.getOffsetToReveal 获取它的偏移量
      final double target = viewport.getOffsetToReveal(renderObject, 0.0).offset.clamp(minScrollExtent, maxScrollExtent);
      itemOffsetY[live] = target;
    });
  }

至此我们就实现了滚动垂直列表切换tab的功能,下面我们需要实现点击tab来滚动到对应垂直list的item效果。

效果实现——点击tab切换垂直List的item

先要增加一个点击事件,这是肯定滴:

    //这里重贴一下页面布局的代码
    
  Widget _buildTabs() {
    return Container(
      width: getWidthPx(750),height: getWidthPx(100),
      child: ListView.builder(
        cacheExtent: 1500,
        padding: EdgeInsets.symmetric(horizontal: getWidthPx(32)),
        controller: vm.tabController,
        scrollDirection: Axis.horizontal,
        itemCount: vm.tabsTitle.length,
        itemBuilder: (ctx,e) {
          return GestureDetector(
            onTap: () {
            //这里是点击事件!
              vm.tapTab(e);
            },
            child: TabItemWidget(e).generateWidget(),
          );
        },
      ),
    );
  }
  
 void tapTab(int index) async {
    //滚动 垂直列表的item
    jumpToItem(index);
    selectTabIndex = index;
    //刷新一下tab选中效果
    notifyListeners(refreshSelector: true);
    //这里将点击的tab滚动到屏幕中间
    await Scrollable.ensureVisible(tabsChildCtx[index]!,duration: Duration(milliseconds: 500),alignment: 0.5);
 }
  

jumpToItem方法用来滚动到对应的item到屏幕的顶端,代码如下:


  final List tabsTitle = List.generate(25, (index) => index);

  ///用于分片滚动时间,可根据需求调整
  final int standardSingleTime = 128;
  
    /// 长距离最小分片时间
  final int onCardScrollDuration = 16*8;

    ///滚动到第[index]卡片
  void jumpToItem(int index) async {
    
    //计算一下与目标的距离
    final int dis = (index - selectTabIndex).abs();
    if (dis == 0) return;
    //标记为 垂直列表的滚动为点击tab 驱动
    isTapAnimateList = true;

    if (dis == 1) {
      //如果点击的是毗邻的,直接滚过去
      final StatefulElement element = bodyChildCtx[index] as StatefulElement;
      if(!element.state.mounted)return;
      scrollDuration = 500;
      await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
    } else {
     //如果不是毗邻的,那么意味着目标item可能还没有创建
     //所以这里采用的方法是 一个一个滚过去  XD
     // 根据距离占总长度比例 x 时间片, 算出一个单个item的滚动时间
     // 不过不宜太小,否则绘制相关工作未完成会报空
      scrollDuration = math.max((dis / tabsTitle.length * standardSingleTime).ceil(), onCardScrollDuration);
      if (index > selectTabIndex) {
        //tab 向右  ,list 向下 滑动
        int i = selectTabIndex + 1;
        while (i <= index) {
          final StatefulElement element = bodyChildCtx[i] as StatefulElement;
          if(!element.state.mounted) {
            i--;
            continue;
          }
          //通过while 循环,我们一个一个的滚动到目标点
          await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
          i++;
        }
      } else {
        //与上面同理
        //tab 向左  ,list 向上 滑动
        int i = selectTabIndex - 1;
        while (i >= index) {
          final StatefulElement element = bodyChildCtx[i] as StatefulElement;
          if(!element.state.mounted) {
            i--;
            continue;
          }
          await Scrollable.ensureVisible(element, duration: Duration(milliseconds: scrollDuration));
          i--;
        }
      }
    }
   
    isTapAnimateList = false;
  }

至此,交叉列表的垂直联动效果就基本完结了,整体还有很多可以优化的地方,后续再不断完善,有不足的地方欢迎指出,谢谢大家的阅读。

结尾

Demo

demo代码

系列文章

Flutter 仿网易云音乐App

Flutter&Android 启动页(闪屏页)的加载流程和优化方案

Flutter版 仿.知乎列表的视差效果

Flutter——实现网易云音乐的渐进式卡片切换

Flutter 仿同花顺自选股列表

你可能感兴趣的:(flutter,android,前端,android,ios,flutter,objective-c,java)