Flutter 仿京东商品详情页嵌套滚动组件

需求来源

最终想实现效果

京东商品详情页效果

这是领苗确认记录详情页需要展示给用户的内容,大家可以看到这个页面要承载很多的信息,要向下滚动一段很长的距离才能展示完,想要实现的效果是在页面的顶部有一个TabBar,用户可以通过点击TabBar进行锚点(jumpTo到指定位置),AppBar下的整个页面是可以自由滚动的,在滚动过程中AppBar始终固定在顶部,TabBar在第一次进入详情页的时候不显示,只有在向下滑动的时候会由透明渐变为不透明并固定在顶部,同时当页面滑动到TabBar锚点位置的时候TabBar会切换到对应的下标,也就是要实现TabBar和ScrollView联动的双向控制,Tabbar的切换可以控制页面的跳转,页面的滑动又可以反过来控制TabBar的切换,类似与京东、淘宝的商品详情页效果。

开发思路

  • ScrollView+TabBar
    大家都知道一般情况下TabBar是和TabBarView配合使用的,它俩受同一个TabController控制,所以能轻松实现联动,而ScrollerView有自己的滑动控制器ScrollController,TabController和ScrollController并不能直接互相控制,必须再实现一个Controller把TabController和ScollController结合起来才能使用,这种实现方式没有深入研究过,感兴趣的同学可以自行尝试。
  • SliverAppBar展开折叠
    SliverAppBar控件可以实现页面头部区域展开、折叠的效果,类似于Android中的CollapsingToolbarLayout,效果图如下:



    SliverAppBar控件需要和CustomScrollView搭配使用,SliverAppBar要通常放在slivers的第一位,后面接其他sliver控件。

CustomScrollView(
    slivers: [
    SliverAppBar(),
    //其他sliver控件
])
  • NestedScrollView滚动神器
    NestedScrollView是一个可以在其内部嵌套其他滚动视图的滚动视图组件,其滚动位置是固有链接的。
    在普通的ScrollView中, 如果有一个Sliver组件容纳了一个TabBarView,它沿相反的方向滚动(例如,允许用户在标签所代表的页面之间水平滑动,而列表则垂直滚动),则该TabBarView内部的任何列表都不会相互作用 与外部ScrollView。例如,浏览内部列表以滚动到顶部不会导致外部ScrollView中的SliverAppBar折叠以展开。 效果图如下:


采用方案

SliverAppBar基本已经达到了我们想要的效果,但在界面顶部会有块空白区域试了很多方法怎么都去不掉,最后看了SliverAppBar这个控件的源码发现是它自带的初始高度。
这个没法设置或消除,不可能直接去改源码,所以后来换了一种实现思路,舍弃了SliverAppBar这个控件,以Stack的形式将TabBar置于ScrollView之上也能达到我们想要的效果,那么问题来了,如何实现TabBar的滚动渐变?很容易想到Opacity透明度控件,通过滚动监听来控制TabBar透明度的改变,借助Notificaion可以完美实现我们的需求。

Notification

Notification是Flutter中一个重要的机制,在Widget树中,每一个节点都可以分发通知(Notification)与父(包括祖先)Widget通信,通知会沿着当前节点(context)向上传递,所有父节点都可以通过NotificationListener来监听自己关注的通知,Flutter中称这种通知由子向父的传递为“通知冒泡”(Notification Bubbling)。
  Flutter中很多地方使用了通知,如可滚动(Scrollable) Widget中滑动时就会分发ScrollNotification,而Scrollbar正是通过监听ScrollNotification来确定滚动条位置的。除了ScrollNotification,Flutter中还有SizeChangedLayoutNotification、KeepAliveNotification 、LayoutChangedNotification等。
通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:

  1. 通过NotificationListener可以在从可滚动组件到widget树根之间任意位置都能监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

demo实现

   NestedScrollView(
    controller: _ctl,
    headerSliverBuilder: _sliverBuilder,//传空
    body: NotificationListener(
        onNotification: (ScrollNotification notification) {
          if (notification is ScrollUpdateNotification) {//ScrollUpdateNotification判断是为了确保是由滚动触发的offset的改变
            int pixels = notification.metrics.pixels.toInt();
            //根据滚动的偏移量计算出透明度,实现appBar滚动渐变
            double alpha =
                notification.metrics.pixels / APPBAR_SCROLL_OFFSET;
            if (alpha < 0)
              alpha = 0;
            else if (alpha > 1) alpha = 1;

            setState(() {
              appBarAlpha = alpha;
              if (pixels == 0) {
                isClick = false;
                _tabC.index = 0;
              } else if (480 < pixels && pixels < 520) {
                isClick = false;
                _tabC.index = 1;
                print(_tabC.index);
              } else if (980 < pixels && pixels < 1000) {
                isClick = false;
                _tabC.index = 2;
              }
            });
            print(appBarAlpha);
          }
          return false;
        },
        child: Stack(
          children: [
            new BodyView(widget.widgets, type, isClick),
            Opacity(
              opacity: appBarAlpha,//透明度随滚动渐变
              child: Container(
                padding: EdgeInsets.symmetric(horizontal: 80.0),
                color: Colors.white,
                height: 40,
                child: new TabBar(
                    controller: _tabC,
                    indicatorSize: TabBarIndicatorSize.label,
                    labelColor: Color(0xffFF4F73),
                    indicatorColor: Color(0xffFF4F73),
                    unselectedLabelColor: Color(0xff000000),
                    labelStyle: new TextStyle(fontSize: 14.0),
                    labelPadding: EdgeInsets.only(bottom: 20),
                    indicatorPadding: EdgeInsets.only(
                        bottom: 15, top: 10, left: 5, right: 5.0),
                    tabs: tabs.map((item) => new Text('$item')).toList()),
              ),
            ),
          ],
        ))),
demo实现效果

通过改造后,目前这个组件的原型已经实现并且可以满足我们的需求,最后就是对该demo进行完善使其能够完美接入我们的业务,做到技术赋能业务。

完整源码

/*
 * @Author: yz.lilinjun 
 * @Date: 2020-06-16 18:42:49 
 * @Last Modified by: yz.lilinjun
 * @Last Modified time: 2020-07-11 17:49:02
 */


import 'package:flutter/material.dart';
import 'package:yingzi_flutter_fmc_plugin/yz_fmc_uikit.dart';

const APPBAR_SCROLL_OFFSET = 100;
const TABBAR_HEIGHT = 44.0;

class YZSliverAppBarPage extends StatefulWidget {
  final List listWidgets;
  final List tabs;
  final List sliverBuilder;

  YZSliverAppBarPage({this.listWidgets, this.tabs, this.sliverBuilder});

  @override
  State createState() => new _YZSliverAppBarPageState();
}

class _YZSliverAppBarPageState extends State
    with TickerProviderStateMixin {
  TabController _tabC; //TabBar的控制器
  ScrollController _ctl = new ScrollController(); //NestedScrollView的主控制器
  List listWidgets = []; //存储封装好的子模块视图的数组
  List positions = List(); //记录每个子模块的位置信息
  int index; //TabBar控制锚点的子模块索引
  double appBarAlpha = 0; //TabBar透明度的控制参数
  bool isScroll = true; //滚动开关,开启时,点击监听失效
  bool isClick = false; //点击开关,开启时,滚动监听失效

  @override
  void initState() {
    super.initState();
    listWidgets = widget.listWidgets.map((view) {
      //对外部传入的每个Widget进行封装,并定义GlobalKey
      GlobalKey _key = GlobalKey();
      return YZSliverWidgetEntity(key: _key, childView: view);
    }).toList();
    _tabC = new TabController(length: widget.tabs.length, vsync: this);
    _tabC.addListener(() => _onTabChanged());
    WidgetsBinding.instance.addPostFrameCallback((_) {
      //监听Widget是否绘制完毕
      _getPositions();
    });

    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    print('内嵌页面build');
    return Scaffold(
      body: NestedScrollView(
          controller: _ctl,
          headerSliverBuilder: _sliverBuilder,
          body: NotificationListener(
              onNotification: (ScrollNotification notification) {
                if (notification is ScrollUpdateNotification) {
                  //ScrollUpdateNotification判断是为了确保是由滚动触发的offset的改变
                  ScrollMetrics metrics = notification.metrics;
                  print(metrics.pixels); // 当前位置
                  _onScroll(notification.metrics.pixels);
                }
                return false;
              },
              child: Stack(
                children: [
                  YZBodyView(listWidgets, index, isScroll, positions),
                  _tabBar()
                ],
              ))),
    );
  }

  List _sliverBuilder(BuildContext context, bool innerBoxIsScrolled) {
    return widget.sliverBuilder ?? [];
  }

  Widget _tabBar() {
    return Opacity(
      opacity: appBarAlpha, //透明度随滚动渐变
      child: Container(
        color: Colors.white,
        height: TABBAR_HEIGHT,
        child: TabBar(
            onTap: (i) {
              //由于滚动和点击动作互斥,而TabController只能监听到tabBar下标的切换,
              //无法监听到点击动作,所以需要实现TabBar的点击回调,否则会出现页面死锁
              isClick = true;
            },
            controller: _tabC,
            indicatorSize: TabBarIndicatorSize.label,
            labelColor: Color(YZThemeFMC.colors.fontColor29C542),
            indicatorColor: Color(YZThemeFMC.colors.color29C542),
            unselectedLabelColor: Color(YZThemeFMC.colors.fontColor595959),
            labelStyle: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500),
            unselectedLabelStyle:
                TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500),
            tabs: widget.tabs),
      ),
    );
  }

  //监听TabBar的切换事件,通知ScrollView进行页面锚点
  void _onTabChanged() {
    if (isClick) {
      setState(() {
        isScroll = false;
        index = _tabC.index;
      });
    }
  }

  //滚动监听
  void _onScroll(double pixels) {
    isClick = false;
    double offset = pixels + positions[0].dy + TABBAR_HEIGHT; //误差修正
    //根据滚动的偏移量计算出透明度,实现appBar滚动渐变
    double alpha = pixels / APPBAR_SCROLL_OFFSET;
    if (alpha < 0)
      alpha = 0;
    else if (alpha > 1) alpha = 1;

    setState(() {
      appBarAlpha = alpha;
      for (int i = 0; i < positions.length; i++) {
        if (i == positions.length - 1) {
          if (offset >= positions[i].dy) {
            //当偏离值溢出控件的滚动范围,TabBar下标始终指向last one
            isScroll = true;
            _tabC.index = i;
          }
        } else if (positions[i].dy <= offset && offset < positions[i + 1].dy) {
          //当滚动的偏离值一进入某个子模块Widget的高度范围内时,则修正TabBar对应的下标
          isScroll = true;
          _tabC.index = i;
        }
      }
    });
  }

  //我们需要获取Widget的大小或位置信息。但Widget对象本身没有大小及位置数据,
  //那么想要拿到Widget的大小及位置信息,就需要通过与Widget对象相关联的RenderBox对象来获取
  void _getPositions() { //获取滚动视图每个子模块的位置信息
    for (int i = 0; i < listWidgets.length; i++) {
      GlobalKey _key = GlobalKey();
      _key = listWidgets[i].key; ////页面渲染完成后遍历拿到每个子模块视图的key
      final RenderBox renderBox =
          _key.currentContext.findRenderObject(); //获取`RenderBox`对象
      final Offset position =
          renderBox.localToGlobal(Offset.zero); //获取renderBox的位置
      print('widget$i position =  $position');
      positions.add(position);
      //最后一个视图不能占满全屏的时候自动补全高度
      // if (i == listWidgets.length - 1) {
      //   final double lastWidgetHeight = renderBox.size.height;
      //   final double deviceHeight = MediaQuery.of(context).size.height;
      //   if (lastWidgetHeight < deviceHeight) {
      //     GlobalKey _key = GlobalKey();
      //     listWidgets.removeLast();
      //     listWidgets.add(YZSliverWidgetEntity(
      //         key: _key,
      //         childView: widget.listWidgets.last,
      //         height: deviceHeight));
      //   }
      // }
    }
  }
}

//滚动页面的body部分
class YZBodyView extends StatefulWidget {
  final List listWidgets;
  final int index;
  final bool isScroll;
  final List positions;

  YZBodyView(this.listWidgets, this.index, this.isScroll, this.positions);

  @override
  _YZBodyViewState createState() => _YZBodyViewState();
}

class _YZBodyViewState extends State {
  ScrollController _innerC;

  @override
  void initState() {
    super.initState();
    PrimaryScrollController primaryScrollController =
        context.findAncestorWidgetOfExactType(); //取得上下文的父类控制body的主控制器
    _innerC = primaryScrollController.controller; //让内控制器等于取到的这个控制器
  }

  @override
  Widget build(BuildContext context) {
    if (!widget.isScroll) _actions(widget.index);

    return new SingleChildScrollView(
      child: new Column(children: widget.listWidgets),
    );
  }

  //锚点动作
  void _actions(int index) {
    setState(() {
      WidgetsBinding.instance.addPostFrameCallback((callback) {
        for (int i = 0; i < widget.positions.length; i++) {
          if (index == 0) {
            _innerC.jumpTo(0.0); //点击第一个tab直接跳转至顶部
            break;
          } else {
            //点击tab跳转误差修正,positions记录的只是实际渲染的renderBox位置
            //_innerC控制的只是可滚动的部分,不包含NestedScrollView以外的部分
            double position =
                widget.positions[i].dy - widget.positions[0].dy - TABBAR_HEIGHT;
            if (index == i) {
              //跳转到页面指定位置
              _innerC.jumpTo(position);
              break;
            }
          }
        }
      });
    });
  }
}

//对传入的widgets进行封装处理以便取得每个widget的key
class YZSliverWidgetEntity extends StatefulWidget {
  final Widget childView;
  final double height;
  YZSliverWidgetEntity({Key key, this.childView, this.height})
      : super(key: key);
  @override
  YZSliverWidgetEntityState createState() => YZSliverWidgetEntityState();
}

class YZSliverWidgetEntityState extends State {
  Widget childView;

  @override
  void initState() {
    super.initState();
    childView = widget.childView;
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: widget.height,
      child: childView,
    );
  }
}
最终接入业务的实现效果

使用示例

_renderTab() {
   final List myTabs = [
     Tab(text: YZShell.of(context).text('放苗信息')),
     Tab(text: YZShell.of(context).text('领苗信息')),
     Tab(text: YZShell.of(context).text('审批流程')),
   ];
   return myTabs;
 }

YZSliverAppBarPage(
     tabs: _renderTab(),
     listWidgets:
         [
       _seedlingInfo(),
       _getSeedlingInfo(context),
       _approvalProcess(),
     ],
   );

适用性

在一个页面滚动区域不是很长的情况下不建议使用,只有当页面足够长的情况下使用这个组件效果会比较好,因为如果一个页面过短,点击TabBar最后一栏进行锚点时,页面最后一个子模块内容无法置顶,只会将页面最后的内容推到屏幕范围内,并且由于TabBar监听到的是滚动的位置,会导致TabBar无法切换到对应的下标,看上去会像个bug,其实是因为底部已经没有内容了。

总结

这个组件本身并没有太大的技术难度,但是有一些比较细节的小逻辑得处理好,并且从中衍生出来的很多琐碎的小的知识点都可以进行拓展。 在组件开发的过程中遇到问题时不局限于控件本身的设计模式,转变开发思维去找寻一些比较新颖的解决方案可能会有意外的收获。同时技术不能脱离于业务,技术赋能业务才能创造价值。

参考

  • Flutter用NestedScrollView的项目必须知道的坑
  • Flutter 首页必用组件NestedScrollView
  • 滚动监听及控制

你可能感兴趣的:(Flutter 仿京东商品详情页嵌套滚动组件)