Flutter 分页指示器进阶

Flutter 分页指示器进阶关键字 起因 上一篇链接Flutter 贝塞尔曲线动画的实现思路(水滴分页指示器) – 掘金 (juejin.cn) 衔接上一篇用贝塞尔曲线实现了基本的功能,但实际用的过程中发了种种问题,只能用于设计

关键字

分页、PageView、分页指示器
ListView
形变位移
自动位移显示当前分页对应的标题
回弹和阻尼
多分页超屏幕
标题长度不规律、自定义标题

起因

上一篇链接Flutter 贝塞尔曲线动画的实现思路(水滴分页指示器) – 掘金 (juejin.cn)

衔接上一篇用贝塞尔曲线实现了基本的功能,但实际用的过程中发了种种问题,只能用于设计稿中特定的场景,于是在这里进行优化。

之前的方案只有每个标题都是同样的宽度才可以,当后面加标题而且文字长度都不一的时候,上述宽度计算方法就会无效,并且如果分页数量很多,超过屏幕,就不能用Row,得用ListView来布局。 并且,我还想实现,当我们滑动分页导致分页指示器超出屏幕的时候,会自动根据当前指示器是往右还是往左自动顺延显示下一个标题。还需要多次滑动后只取最后一次有效分页停止来触发获取数据刷新动作。

这时候间距和如果达到类似腾讯新闻分页标题那样的效果还得再细分析一番(腾讯新闻的效果是标题字体放大,我们是指示器位移)

腾讯新闻效果.gif

成品图

这次样式我改为圆角矩形了

效果
无标题有限个数 无标题无限个数颜色同步 有标题超屏幕 自定义标题超屏幕
有限个数没有标题.gif 无标题无限个数颜色同步.gif 多标题超屏幕.gif 自定义标题.gif
效果
边界剪裁预留和滑动阻尼
边界阻尼.gif

分析

一共三个部分

  • 指示器分为上下两层叠加
    
    • 底层 -> 边框指示器占位组件和后面隐藏的标题部分
    • 上层 -> 用CustomPaint画出图形,然后再变形位移
  • 分页主体
    

之前指示器位移是通过等量计算来算出位移距离,现在主要思路是得到每个标题占位指示器的坐标宽高,然后直接位移覆盖上去,这样每个标题item的宽度即使不一样也可以计算出来。

变量区

一下文章用到的所有变量我都会在这里放出,下面文章里的代码也会有相应的注释,方便大家对照

全局单例CostPageViewSingleton的变量

颜色和标题的数量要一致,要不就数组越界

///默认颜色
Color baseColor = Color.fromRGBO(87, 119, 142, 1);
Color baseColor2 = Color.fromRGBO(121, 153, 174, 1);
Color baseColor3 = Color.fromRGBO(149, 179, 199, 1);
Color baseColor4 = Color.fromRGBO(173, 192, 201, 1);
Color baseColor5 = Color.fromRGBO(224, 230, 230, 1);


///分页色彩
List<Color> myHomePageColors = [
  Color.fromRGBO(223, 109, 46, 1),
  Color.fromRGBO(255, 186, 109, 1),
  Color.fromRGBO(250, 216, 152, 1),
  Color.fromRGBO(223, 109, 46, 1),
  Color.fromRGBO(255, 186, 109, 1),
  Color.fromRGBO(250, 216, 152, 1),
  Color.fromRGBO(223, 109, 46, 1),
  Color.fromRGBO(255, 186, 109, 1),
  Color.fromRGBO(250, 216, 152, 1),
  Color.fromRGBO(223, 109, 46, 1),
];

///测试分页标题
List<String> testTitles = [
  '一',
  '二二',
  '三三三',
  '四四四四',
  '五五五五五',
  '六',
  '七七',
  '八八八',
  '九九九九',
  '十十十十十',
];

///所有分页移动数据
Map<String,Map<int,PageBean>> allPageViewBeans = {};

///所有分页标题位移数据
Map<String,double> allListViewDistanceRolled = {};

主要显示页面MyHomePage的变量

///标题
String pageTitle = '今日分页';

///当前页码,小数代表进度
double nowCurPosition = 0.0;

///上一次的page
double oldCurPosition = 0.0;

///半径
double radius = 24.w;

///页数去掉整数部分,一次翻页的进度,不论左滑还是右滑都得是同一个百分数。用于计算动画的进度
double percent = 0.0;

///颜色进度
double colorPercent = 0.0;

///颜色透明度
double colorOpacity = 0.0;

///当前颜色
Color nowColor = CostPageViewSingleton().myHomePageColors.first;

///是否是向右
bool isToRight = true;

///将要跨越的分页下标
int spanIndex = 0;

///分页是否开始滑动
bool pageScrollStart = false;

///分页间距
double pageTitlePadding = 40.w;

///分页指示器外边距
double overallMargin = 64.w;

///指示器宽高
double indicatorWidth = 48.w;

///标题列表高度
double listViewHeight = 80.w;

///起始位置
double offSetX = 0.0;

///分页控制器
late PageController pageController;

///分页标题控制器
ScrollController listScrollController = ScrollController();

///指示器变化矩阵
Matrix4 transform = Matrix4.translationValues(0, 0, 0);///主体

///请求控制器
PreventRepeatedEvent repeatedEvent = PreventRepeatedEvent();

///分页总数
int pageTotal = 0;

用于存放分页数据的类

class PageBean extends Object {

  ///下标
  int pageIndex;

  ///标题
  String pageTitle;

  ///宽度
  double pageWidth;

  ///高度
  double pageHeight;

  ///距离起点的长度
  double offsetRevealToLeading;

  ///距离可视区域终点的长度
  double offsetRevealToTrailingEdge;

  ///可视区域的宽度
  double viewportWidth;

  PageBean(this.pageIndex,this.pageTitle,this.pageWidth,this.pageHeight,this.offsetRevealToLeading,this.offsetRevealToTrailingEdge,this.viewportWidth);

}

分页主体部分

这部分没有太多好说的,普通的PageView,只不过是用了一个动画库AnimationLimiter,这个库可以让你的列表显示具有动画效果。当然仍然需要一个pageController来监听和控制分页 我们在initState里注册PageView监听,这里获取pageTotal是为了其他地方计算用到

@override
void initState() {
  super.initState();
  ///获取分页总数
  pageTotal = CostPageViewSingleton().testTitles.length;
  ///设置系数比例为0.8
  pageController = PageController(viewportFraction: 1.0);
  ///分页监听
  pageController.addListener((){
    ///监听计算
    .........
  });
}
///分页主体
Expanded(
  child: Container(
    margin: EdgeInsets.only(bottom: 32.w),
    child: PageView.builder(///分页
      itemBuilder: (context,pageIndex){
        return AnimationLimiter(
          child: MediaQuery.removePadding(
              context: context,
              removeTop: true,
              child: ListView.builder(///列表
                itemCount: pageTotal,
                physics: AlwaysScrollableScrollPhysics(),
                itemBuilder: (BuildContext context, int index) {
                  ///item
                  return AnimationConfiguration.staggeredList(
                    position: index,
                    duration: Duration(milliseconds: 375),
                    child: ScaleAnimation(
                      scale: 0.1,
                      child: FadeInAnimation(
                        curve: Curves.decelerate,
                        child: Container(
                          margin: EdgeInsets.all(32.w),
                          height: 220,
                          child: Card(
                            elevation: 10,
                            color: CostPageViewSingleton().myHomePageColors[pageIndex],
                            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
                          ),
                        ),
                      ),
                    ),
                  );
                },
              )
          ),
        );
      },
      itemCount: pageTotal,
      scrollDirection: Axis.horizontal,
      reverse: false,
      controller: pageController,
      physics: const PageScrollPhysics(parent: BouncingScrollPhysics()),
    ),
  ),
)

标题底层

这部分和之前有很大的区别,也很重要,主要有占位的边框指示器,后面可以用来获取我们最关键的每个占位指示器所在的坐标。后面再加一个任意组件作为标题,把组件开放给外部,可以自定义程度更高,我们先完成

UI

import 'CostPageViewBean.dart';
import 'CostPageViewSingleton.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class CostPaginationIndicator extends StatefulWidget{

  final String pageKey;///当前是哪个分页
  final String title;///标题
  final double pageTitlePadding;///分页标题间距
  final double indicatorWidth;///分页标题间距
  final Widget? bodyWidget;
  final int index;///下标
  final bool isToRight;///往左 or 往右
  final void Function(PageBean?) onPressed;///点击事件

  CostPaginationIndicator(
      {
        Key? key,
        required this.pageKey,
        required this.title,
        required this.pageTitlePadding,
        required this.indicatorWidth,
        required this.index,
        required this.isToRight,
        required this.onPressed,
        this.bodyWidget,
      }
      ) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return _CostPaginationIndicator();
  }

}

class _CostPaginationIndicator extends State<CostPaginationIndicator>{

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (){
      
      },
      child: Container(
        margin: EdgeInsets.only(right: widget.pageTitlePadding),
        child: Row(
          children: [
            ///指示器占位组件
            Container(
              key: Key('${widget.index}-RoundedRectangle'),
              width: widget.indicatorWidth,
              height: widget.indicatorWidth,
              margin: EdgeInsets.only(right: widget.bodyWidget == null ?0:0),///如果没有主体,就不需要间距
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.all(Radius.circular(4)),///这里设置圆角
                  border: Border.all(color: CostPageViewSingleton().myHomePageColors[widget.index],width: 1)
              ),
            ),
            ///自定义标题
            widget.bodyWidget??Container()
          ],
        ),
      ),
    );
  }

}

组件位置获取

获取数据

之前一直没有好的头绪来精确计算每一个指示器该位移的距离,上一版是固定宽度,固定大小,通过公式我们就可以手动计算出来,那么遇到每个item的大小不一就不好办了。 在我查找资料的时候,这几篇文章给我很大的帮助

  1. Flutter | 如何实现一个精准滑动埋点 – 掘金 (juejin.cn)
  2. Flutter | 深入理解BuildContext – 掘金 (juejin.cn)

这里建议先看完这两篇,再看我的就比较好理解,或者只看第二篇也可以。

当我们知道每个占位指示器的位置,知道每个item的宽高,知道每个item之间的间距,那么一切尽在掌控之中。

这里引用第一篇文章的相关文字

    所有的 `ScrollView` 都是在一个可视区域 `viewport` 当中进行绘制,为了让滑动更加流畅,
通常 `ScrollView` 都会在可视区域之外加载一部分,也就是 `cacheExtent`。
落入该缓存区域的项目即使在屏幕上尚不可见,也会进行布局。这时候 `initState` 就被执行了。
`ListView` 作为 `ScrollView` 的子类同样也使用了这个机制。

    并且不是每个 `Widget` 都会创建一个 `RenderObject`,只有 `RenderObjectWidget` 才会
创建 `RenderObject``ListView` 会默认帮每一个 Item 添加一个 `RepaintBoundary`,
这个 `Widget` 是一个 `SingleChildRenderObjectWidget`,
所以每一个 Item 其实都会有一个它所对应的 `RenderObject`

通过上面所说,我们知道每个item都有RenderObject,所以在addPostFrameCallback回调中就可以通过调用findRenderObject() 来拿到已显示的和预加载的组件的信息,然后通过滑动我们就可以拿到所有组件(所有item)的信息

@override
void initState() {

  ///当前Frame最后一帧绘制完毕
  WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
    ///记录数据
  context.findRenderObject();
  ///getViewPortSize(context);
  });

  super.initState();
}

那么需要哪些信息呢,我做了个图方便计算理解,红色区域就是ListView展示的区域(你可以暂且理解为显示屏幕)

image.png 我们通过findRenderObject() 可以获得当前item

///获取当前组件
final RenderObject? box = context.findRenderObject();

拿到了组件,我们就可以获得item的宽高

///当前组件大小
final Size? sizeItem = box.paintBounds.size;

通过RenderAbstractViewport.of()可以获得可视区域的大小

///获取可视区域
final RenderAbstractViewport? viewport = RenderAbstractViewport.of(box);

有了可视区域我们就可以获得可视区域宽高当前item距离起始位置的距离

///可视区域大小
final Size? size = viewport.paintBounds.size;

/// box 为当前 Item 的 RenderObject
/// alignment 为 0 的时候获得距离起点的相对偏移量,为 1 的时候获得距离可视区域终点的相对偏移量。
final RevealedOffset offsetRevealToLeading = viewport.getOffsetToReveal(box, 0.0, rect: Rect.zero);

已滚动的距离可以用NotificationListener套在ListView外面通过监听获得,这里allListViewDistanceRolled之所以是Map类型,是为多页面做准备。

NotificationListener<ScrollNotification>(
  child: ListView.builder(),///列表
  onNotification: (ScrollNotification notification){
    //开始滚动
    if(notification is ScrollStartNotification){
      // print("开始滚动");
    } else if (notification is ScrollUpdateNotification){
      ///当前ListView位移距离
      CostPageViewSingleton().allListViewDistanceRolled[pageTitle] = notification.metrics.pixels;
      /// 更新滚动
      setState(() {});

    } else if (notification is ScrollEndNotification){
      // print("结束滚动");
    }

    // 返回值是防止冒泡, false是可以冒泡
    return true;
  },
)

到此所有的数据我们都拿到了,为了方便取用,我们用一个单独的model来存放所有数据

class PageBean extends Object {

  ///下标
  int pageIndex;

  ///标题
  String pageTitle;

  ///宽度
  double pageWidth;

  ///高度
  double pageHeight;

  ///距离起点的长度
  double offsetRevealToLeading;

  ///可视区域的宽度
  double viewportWidth;

  PageBean(this.pageIndex,this.pageTitle,this.pageWidth,this.pageHeight,this.offsetRevealToLeading,this.viewportWidth);

}

放上全部获取数据的代码

Size? getViewPortSize(BuildContext context) {
  ///获取当前组件
  final RenderObject? box = context.findRenderObject();
  ///获取可视区域
  final RenderAbstractViewport? viewport = RenderAbstractViewport.of(box);
  ///判空处理
  if (viewport == null || box == null || !box.attached) {
    debugPrint('是否父组件是ScrollView');
    return Size(0, 0);
  }
  /// box 为当前 Item 的 RenderObject
  /// alignment 为 0 的时候获得距离起点的相对偏移量,为 1 的时候获得距离可视区域终点的相对偏移量。
  final RevealedOffset offsetRevealToLeading = viewport.getOffsetToReveal(box, 0.0, rect: Rect.zero);
  ///可视区域大小
  final Size? size = viewport.paintBounds.size;
  ///当前组件大小
  final Size? sizeItem = box.paintBounds.size;
  ///放到分页数据model里
  if(CostPageViewSingleton().allPageViewBeans[widget.pageKey] != null){
    ///每个元素下标对应的数据
    CostPageViewSingleton().allPageViewBeans[widget.pageKey]![widget.index] = PageBean(
        widget.index,///下标
        widget.title, ///标题
        sizeItem?.width??0, ///宽度
        sizeItem?.height??0,///高度 
        offsetRevealToLeading.offset,///距离起点的长度
        size?.width??0.0///可视区域的宽度
    );
  }else{
    ///首次添加数据
    CostPageViewSingleton().allPageViewBeans[widget.pageKey] = {
      widget.index:PageBean(
          widget.index,///下标
          widget.title, ///标题
          sizeItem?.width??0, ///宽度
          sizeItem?.height??0,///高度 
          offsetRevealToLeading.offset,///距离起点的长度
          size?.width??0.0///可视区域的宽度
      )
    };
  }
  return size;
}

然后在绘制最后一帧的回调里调用就可以

///当前Frame最后一帧绘制完毕
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
  ///记录数据
  getViewPortSize(context);
});

那么有人可能会问,为什么我们可以从BuildContext中精确的获取到每一个item,我建议你先看看第二篇文章

全部代码

至此我们标题底层已经全部完成,一下是CostPaginationIndicator的全部代码

import 'CostPageViewBean.dart';
import 'CostPageViewSingleton.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class CostPaginationIndicator extends StatefulWidget{

  final String pageKey;///当前是哪个分页
  final String title;///标题
  final double pageTitlePadding;///分页标题间距
  final double indicatorWidth;///分页标题间距
  final Widget? bodyWidget;
  final int index;///下标
  final bool isToRight;///往左 or 往右
  final void Function(PageBean?) onPressed;///点击事件

  CostPaginationIndicator(
      {
        Key? key,
        required this.pageKey,
        required this.title,
        required this.pageTitlePadding,
        required this.indicatorWidth,
        required this.index,
        required this.isToRight,
        required this.onPressed,
        this.bodyWidget,
      }
      ) : super(key: key);

  @override State<StatefulWidget> createState() {
    // TODO: implement createState
    return _CostPaginationIndicator();
  }

}

class _CostPaginationIndicator extends State<CostPaginationIndicator>{

  @override void initState() {

    ///当前Frame最后一帧绘制完毕
    WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
      ///记录数据
      getViewPortSize(context);
    });

    super.initState();
  }

  @override Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (){
        widget.onPressed(CostPageViewSingleton().allPageViewBeans[widget.pageKey]![widget.index]);
      },
      child: Container(
        // color: Colors.deepOrange,
        margin: EdgeInsets.only(right: widget.pageTitlePadding),
        child: Row(
          children: [
            ///指示器占位组件
            Container(
              key: Key('${widget.index}-RoundedRectangle'),
              width: widget.indicatorWidth,
              height: widget.indicatorWidth,
              margin: EdgeInsets.only(right: widget.bodyWidget == null ?0:16.w),///如果没有主体,就不需要间距
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.all(Radius.circular(4)),///这里设置圆角
                  border: Border.all(color: CostPageViewSingleton().myHomePageColors[widget.index],width: 1)
              ),
            ),
            ///自定义标题
            widget.bodyWidget??Container()
          ],
        ),
      ),
    );
  }


  Size? getViewPortSize(BuildContext context) {
    ///获取当前组件
    final RenderObject? box = context.findRenderObject();
    ///获取可视区域
    final RenderAbstractViewport? viewport = RenderAbstractViewport.of(box);
    ///判空处理
    if (viewport == null || box == null || !box.attached) {
      debugPrint('是否父组件是ScrollView');
      return Size(0, 0);
    }
    /// box 为当前 Item 的 RenderObject
    /// alignment 为 0 的时候获得距离起点的相对偏移量,为 1 的时候获得距离可视区域终点的相对偏移量。
    final RevealedOffset offsetRevealToLeading = viewport.getOffsetToReveal(box, 0.0, rect: Rect.zero);
    ///可视区域大小
    final Size? size = viewport.paintBounds.size;
    ///当前组件大小
    final Size? sizeItem = box.paintBounds.size;
    ///放到分页数据model里
    if(CostPageViewSingleton().allPageViewBeans[widget.pageKey] != null){
      ///每个元素下标对应的数据
      CostPageViewSingleton().allPageViewBeans[widget.pageKey]![widget.index] = PageBean(
          widget.index,///下标
          widget.title, ///标题
          sizeItem?.width??0, ///宽度
          sizeItem?.height??0,///高度
          offsetRevealToLeading.offset,///距离起点的长度
          size?.width??0.0///可视区域的宽度
      );
    }else{
      ///首次添加数据
      CostPageViewSingleton().allPageViewBeans[widget.pageKey] = {
        widget.index:PageBean(
            widget.index,///下标
            widget.title, ///标题
            sizeItem?.width??0, ///宽度
            sizeItem?.height??0,///高度
            offsetRevealToLeading.offset,///距离起点的长度
            size?.width??0.0///可视区域的宽度
        )
      };
    }
    return size;
  }

}

标题顶层

标题顶层和上一篇文章里并没有什么变动,我就不过把操控杆变长了而已

final double M = 1.0;
// final double M = 0.551915024494;

主页部分

这时候主页build是这样的

@override
Widget build(BuildContext context) {
  transform = Matrix4.compose(v.Vector3(offSetX, 0.0, 0.0), v.Quaternion.euler(0, 0, 0), v.Vector3(1.0, 1.0, 1.0));
  return Scaffold(
    body: Container(
      color: Color.fromRGBO(20, 26, 36, 1),///统一背景色
      padding: EdgeInsets.only(top: 112.w),
      child: Column(
        children: [
          ///分页指示器
          Stack(
            children: [
              ///分页标题和隐藏的指示器
              Container(
                padding: EdgeInsets.only(left: overallMargin,right: overallMargin),
                height: listViewHeight,
                child: IntrinsicHeight(
                  child: NotificationListener<ScrollNotification>(
                    child: ListView.builder(///列表
                      itemCount: pageTotal,
                      shrinkWrap: true,
                      physics: AlwaysScrollableScrollPhysics(),
                      scrollDirection: Axis.horizontal,
                      controller: listScrollController,
                      itemBuilder: (BuildContext context, int index) {
                        ///item
                        return CostPaginationIndicator(
                          pageKey: pageTitle,
                          title: CostPageViewSingleton().testTitles[index],
                          pageTitlePadding: pageTitlePadding,
                          index: index,
                          isToRight: isToRight,
                          indicatorWidth: radius*2,
                          bodyWidget: Text(///自定义标题
                            CostPageViewSingleton().testTitles[index],
                            style: TextStyle(
                                color: CostPageViewSingleton().baseColor3,
                                fontSize: ScreenUtil().setSp(32),
                                fontWeight: FontWeight.bold
                            ),
                          ),
                          onPressed: (value){
                            ///点击处理
                          },
                        );
                      },
                    ),
                    onNotification: (ScrollNotification notification){
                      //开始滚动
                      if(notification is ScrollStartNotification){
                        // print("开始滚动");
                      } else if (notification is ScrollUpdateNotification){
                        ///当前ListView位移距离
                        CostPageViewSingleton().allListViewDistanceRolled[pageTitle] = notification.metrics.pixels;
                        /// 更新滚动
                        setState(() {});
                      } else if (notification is ScrollEndNotification){
                        // print("结束滚动");
                      }
                      // 返回值是防止冒泡, false是可以冒泡
                      return true;
                    },
                  ),
                ),
              ),
              ///指示器动画
              Positioned(
                top: (listViewHeight - radius*2)/2,
                left: overallMargin,
                right: overallMargin,
                bottom: (listViewHeight - radius*2)/2,
                child: IgnorePointer(
                  child: ClipRect(
                    clipper: _MyClipper(),
                    child:AnimatedContainer(
                      duration: Duration(microseconds: 100),
                      transform: transform,
                      child: CustomPaint(
                        painter: IndicatorView(
                            radius: radius,
                            percent: percent,
                            isToRight: isToRight,
                            color: nowColor
                        ),
                      ),
                    ),
                  ),
                ),
              )
            ],
          ),
          ///分页主体
          Expanded(
            child: Container(
              margin: EdgeInsets.only(bottom: 32.w),
              child: PageView.builder(///分页
                itemBuilder: (context,pageIndex){
                  return MediaQuery.removePadding(
                      context: context,
                      removeTop: true,
                      child: ListView.builder(///列表
                        itemCount: pageTotal,
                        physics: AlwaysScrollableScrollPhysics(),
                        itemBuilder: (BuildContext context, int index) {
                          ///item
                          return Container(
                            margin: EdgeInsets.all(32.w),
                            height: 220,
                            child: Card(
                              elevation: 10,
                              color: CostPageViewSingleton().myHomePageColors[pageIndex],
                              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
                            ),
                          );
                        },
                      )
                  );
                },
                itemCount: pageTotal,
                scrollDirection: Axis.horizontal,
                reverse: false,
                controller: pageController,
                physics: const PageScrollPhysics(parent: BouncingScrollPhysics()),
              ),
            ),
          )
        ],
      ),
    ),
  );
}

目前没有任何计算,看一下效果

无计算时候的初始效果.gif

之前对于pageController.addListener监听的代码和在主页build中的一些计算我单独拎了出来,因为添加ListView滚动刷新页面的情况下,ListView监听滑动同时需要移动分页指示器,本就是专注pageView和标题顶层绘制的代码放在整体页面刷新的build方法中会导致很多冲突bug指示器位移坐标的错乱。 大部分都没有变化,变化主要在计算位移模块。

///ListView滑动监听处理
  void pageControllerListener(){
    ///当前page数据
    nowCurPosition = pageController.page!;
    ///比对上一次来判断左滑还是右滑
    if (nowCurPosition > oldCurPosition) {///新 > 旧 往右移动
      isToRight = true;
      // debugPrint('指示器往右滑');
    } else if (nowCurPosition < oldCurPosition) {///新 < 旧 往左移动
      isToRight = false;
      // debugPrint('指示器往左滑');
    }else{
      if(nowCurPosition == oldCurPosition && oldCurPosition == 0.0){ ///极值 处于起始位置,还不停往左移动
        isToRight = false;
        // debugPrint('指示器往左滑');
      }else{///极值 处于终点位置,还不停往右移动
        isToRight = true;
        // debugPrint('指示器往右滑');
      }
    }

    ///比对结束赋值
    oldCurPosition = nowCurPosition;

    if (isToRight) {
      /// 2.0354 - 2 正向运动 = 0.0354
      percent = nowCurPosition - nowCurPosition.toInt();
      ///往右,即将跨越的 = 当前的,舍弃小数位
      spanIndex = nowCurPosition.toInt();
    } else {
      ///反向运动,进度由大变小 0.9 -> 0.1 所以 2.9 - 2 = 0.9 ,但实际是 1 - 0.9 = 0.1
      percent =  1 - (nowCurPosition - nowCurPosition.toInt());
      ///往右,即将跨越的 = 当前的,小数位进位
      spanIndex = nowCurPosition.ceil();
    }

    ///颜色进度不需要反向计算
    colorPercent = nowCurPosition - nowCurPosition.toInt();

    ///颜色变化在进度70%左右开始
    if (colorPercent >= 0 && colorPercent <= 0.7) {
      colorOpacity = ( 1.0 - colorPercent );
      ///不到70%就是之前的分页颜色
      nowColor = CostPageViewSingleton().myHomePageColors[nowCurPosition.toInt()].withOpacity(colorOpacity <= 0.3 ?0.5:colorOpacity);
    }else if (colorPercent > 0.7 && colorPercent <= 1.0) {
      ///过了70%就是后面的分页的颜色
      nowColor = CostPageViewSingleton().myHomePageColors[nowCurPosition.ceil()].withOpacity(colorPercent);
    }
    
    ///分页滑动=>计算指示器位移距离
    offSetX = CostPageViewSingleton().indicatorDisplacement(XXXX);

    setState(() {});
  }

}

计算PageView滑动 -> 指示器位移

放个图来方便理解

image.png

接下来,我们区分两种情况,指示器右移左移

///分页滑动=>计算指示器位移距离
///
/// [pageViewBean]:ListView中每个对应分页的标题元素,
/// [isToRight]:指示器是否是往右滑动,
/// [spanIndex]:将要跨越的元素下标
/// [nowListViewDistanceRolled]:当前ListView位移距离
/// [percent]:进度
double indicatorDisplacement(Map<int,PageBean>? pageViewBean,bool isToRight,int spanIndex,double nowListViewDistanceRolled,double percent){
  double offSetX = 0.0;
  ///上一个item距离起点的距离
  double previousItemToLeading = pageViewBean?[spanIndex - 1]?.offsetRevealToLeading??0.0;
  ///当前item距离起点的距离
  double nowItemToLeading = pageViewBean?[spanIndex]?.offsetRevealToLeading??0.0;
  ///下一个item距离起点的距离
  double nextItemToLeading = pageViewBean?[spanIndex + 1]?.offsetRevealToLeading??0.0;
  ///要跨越的距离
  double gap = 0.0;
  
  if(isToRight){///右滑
      ///计算两个item之间的差值
      gap = nextItemToLeading - nowItemToLeading;
      offSetX = nowItemToLeading + percent*gap;
  }else{///左滑
      ///计算两个item之间的差值
      gap = nowItemToLeading - previousItemToLeading;
      offSetX = nowItemToLeading - (percent == 1.0?0:percent)*gap;
  }
  return offSetX;
}

这样我们就可以简单的分页位移跟随了,效果如下

屏幕内部无边界.gif

到这个时候,就和上一篇的程度一样了,屏幕宽度内实现了左右移动,并且有形变颜色变化,而且还支持不同的标题宽度自定义标题(只需要替换CostPaginationIndicator的bodyWidget)。但有几个问题,看效果

边界和超屏幕bug.gif

  1. 边界的时候无法继续滑动
  2. 超出屏幕标题没有跟随滑动
  3. 超出屏幕后没多久出现计算bug导致指示器始终在起点

第一个问题的原因是我们没有做边界处理,我们打印一下分页在起始位置左滑动的数值

debugPrint('分页滑动距离:${pageController.position.pixels}');

image.png

可以看到左滑超过当前分页起始点是负数,右滑是正数(滑动到终点正好是屏幕宽度*分页总数少1的倍数

所以我们还需要添加几个参数

[total]:分页标题总数
[pageController]:分页控制器
///当前已经翻过的页数宽度
double spanPageWidth = 1.0.sw*spanIndex;

看一下改动后的代码

if(isToRight){///右滑
  if(spanIndex == (total - 1)){
    ///如果指示器右滑到终点位置仍旧位移,那么通过滑动距离和阻尼系数(0.1)达到效果
    offSetX = nowItemToLeading + (pageController.position.pixels - spanPageWidth)*0.1;
  }else{
    ///计算两个item之间的差值
    gap = nextItemToLeading - nowItemToLeading;
    offSetX = nowItemToLeading + percent*gap;
  }
}else{///左滑
  ///处理临界滑动
  if(spanIndex == 0){
    ///如果指示器左滑到起始位置仍旧位移,那么通过滑动距离和阻尼系数(0.1)达到效果
    offSetX = nowItemToLeading + pageController.position.pixels*0.1;
  }else{
    ///计算两个item之间的差值
    gap = nowItemToLeading - previousItemToLeading;
    offSetX = nowItemToLeading - (percent == 1.0?0:percent)*gap;
  }
}

看一下效果,因为这时候我们要看到最右边边界的效果,所以我减少了分页个数和标题

边界效果.gif 但这时候超出屏幕的指示器并不会随着标题ListView滑动位移回来,因为我们没有减去ListView已滑动的距离

未减去标题滑动距离.gif 我们在上面的步骤里通过NotificationListener已经拿到了ListView滚动的距离

///当前ListView位移距离 
CostPageViewSingleton().allListViewDistanceRolled[pageTitle] = notification.metrics.pixels;

传入后新的计算方式变成

if(isToRight){///右滑
  if(spanIndex == (total - 1)){
    ///如果指示器右滑到终点位置仍旧位移,那么通过滑动距离和阻尼系数(0.1)达到效果
    offSetX = nowItemToLeading + (pageController.position.pixels - spanPageWidth)*0.1 - nowListViewDistanceRolled;
  }else{
    ///计算两个item之间的差值
    gap = nextItemToLeading - nowItemToLeading;
    offSetX = nowItemToLeading + percent*gap - nowListViewDistanceRolled;
  }
}else{///左滑
  ///处理临界滑动
  if(spanIndex == 0){
    ///如果指示器左滑到起始位置仍旧位移,那么通过滑动距离和阻尼系数(0.1)达到效果
    offSetX = nowItemToLeading + pageController.position.pixels*0.1 - nowListViewDistanceRolled;
  }else{
    ///计算两个item之间的差值
    gap = nowItemToLeading - previousItemToLeading;
    offSetX = nowItemToLeading - (percent == 1.0?0:percent)*gap - nowListViewDistanceRolled;
  }
}

我们再看效果

减去滑动距离.gif 可以看出当指示器位移超过屏幕的时候,PageView一滑动就会显示出来,是因为我们这里的计算是在pageController.addListener里监听触发的,所以才会监听变化后会显示正确的位置。

计算ListView滑动 -> 指示器位移

想要跟随ListView滑动显示,就必须在ListView的NotificationListener监听中也做计算处理。

offSetX = CostPageViewSingleton().allPageViewBeans[pageTitle]![spanIndex]!.offsetRevealToLeading - 1notification.metrics.pixels;

加上这一句后,效果

随标题位移.gif

腾讯新闻效果

看起来可以了,但我想实现腾讯新闻的效果,如下 腾讯新闻效果.gif 我们可以通过我一开始放上的腾讯新闻效果看出,如果滑动结束后下一个(或上一个)item没显示或者没显示完整下一个(或上一个)item就会自动位移显示出来,我们需要在6中不同的情况下计算需要位移的距离。

精确滑动结束判断

一开始我想的是当pageController.page为整数的时候,也就是进度percent为0或者1的时候,就代表一个页面翻了过去,判断的地方在pageControllerListener()函数里

///每次每页翻到后,触发(percent代表一次翻页结束)
if(percent == 0 || percent == 1){
  debugPrint('翻页结束了');
}

我们先按照这个思路看一下效果

无条件触发加载数据.gif

发现一旦滑动的慢一些就可能中途触发,并且在边界滑动还会触发多次。所以我们需要一个多次触发延迟生效,并且只生效最后一次的办法。 我们可以通过StreamController流来监听回调,用Timer延迟执行,然后判空来实现阻隔重复触发。 这里用了这篇文章的代码Flutter实现同一时间产生多次事件,最后一次有效 – rhyme_lph

import 'dart:async';

const Duration _kDuration = Duration(milliseconds: 2000);

class PreventRepeatedEvent {
  final StreamController _controller = StreamController();
  late StreamSubscription _subscription;
  Timer? _timer;

  void addEventListener(void Function(dynamic data) dataCall) {
    _subscription = _controller.stream.listen((event) {
      dataCall(event);
    });
  }

  void sendEvent(dynamic data) {
    if (_timer != null) {
      _timer!.cancel();
    }
    _timer = Timer(_kDuration, () {
      if(!_controller.isClosed){
        _controller.add(data);
      }
    });
  }

  void cancel() {
    _controller.close();
    _subscription.cancel();
  }
}

然后在首页初始化函数注册

@override
void initState() {
  super.initState();
  XXXXXXX
  ///注册请求控制回调
  repeatedEvent.addEventListener(_getDataCallBack);
}
///翻页过程中,翻页动作完毕触发
void _getDataCallBack(data) {
  debugPrint('开始更新数据:$spanIndex');
}

然后之前的这里改为

///每次每页翻到后,触发(percent代表一次翻页结束)
if(percent == 0 || percent == 1){
  ///翻页进度监听,会多次触发,但只需要最后一次有效触发
  repeatedEvent.sendEvent(nowCurPosition);
}

看一下打印结果,触发时间可自己的改动设置

const Duration _kDuration = Duration(milliseconds: 2000);

多次触发.gif

自动显示item

接下来就是确认停止滑动后,让应该显示出来item显示出来,这里我们仍旧分两种情况

右移判断

往右位移我们只关注下一个item的位置,所以offsetRevealToLeading是下一个分页对应的标题item距离起始位置的距离

image.png

右侧不可见区域:

offsetRevealToLeading >= (scrollOffset + viewportWidth)
需要滚动到的坐标 = scrollOffset + nextItemToLeading + nextItemWidth - ( scrollOffset + viewPortLength );

image.png 右侧一部分可见:

offsetRevealToLeading < (scrollOffset + viewPortLength) && (offsetRevealToLeading + pageWidth) > (scrollOffset + viewPortLength)  
需要滚动到的坐标 = scrollOffset + nextItemWidth - ( scrollOffset + viewPortLength - nextItemToLeading);

image.png 左侧不可见区域或者一部分可见: 这种属于用户手动先滑动标题栏,导致下一个item在左侧不可见区域

(offsetRevealToLeading + pageWidth) <= (scrollOffset + viewPortLength)  
需要滚动到的坐标 = nextItemToLeading - viewPortLength + nextItemWidth;

左移判断

往左位移我们只关注上一个item的位置,所以offsetRevealToLeading是上一个分页对应的标题item距离起始位置的距离,并且左移不论哪种情况都是位移到要显示的item左边距离起始位置的坐标,只是判断的条件不一样而已

image.png

左侧不可见区域: 例如item0、1

(offsetRevealToLeading + pageWidth) <= scrollOffset  
需要滚动到的坐标 = offsetRevealToLeading;

左侧一部分可见:

offsetRevealToLeading < scrollOffset && (offsetRevealToLeading + pageWidth) > scrollOffset  例如item2  
需要滚动到的坐标 = offsetRevealToLeading;

右侧不可见区域或者一部分可见: 例如item0、1、2

offsetRevealToLeading >= (scrollOffset + viewportWidth)    
需要滚动到的坐标 = offsetRevealToLeading;

所以我们只需要知道以上的信息就可以计算出每个item的位置,知道每个item的位置,通过计算就可以得知它有没有显示、显示不完全、完全没显示。 整体计算代码如下

///ListView位移计算
///
/// [pageViewBean]:ListView中每个对应分页的标题元素,
/// [scrollOffset]:已滚动距离
/// [isToRight]:指示器是否是往右滑动,
/// [willIndex]:下一个将要跨越的元素下标
/// [total]:分页标题总数
/// [listScrollController]:分页控制器
void listViewDisplacement(Map<int,PageBean>? pageViewBean,double scrollOffset,bool isToRight,int willIndex,int total,ScrollController listScrollController){
  ///可视区域宽度
  double viewPortLength = pageViewBean?[0]?.viewportWidth??0.0;
  ///下一个item的下标,注意极值
  int nextItemIndex = (willIndex + 1) == total?willIndex:(willIndex + 1);
  ///下一个item距离起点的距离
  double nextItemToLeading = pageViewBean?[nextItemIndex]?.offsetRevealToLeading??0.0;
  ///下一个item宽度
  double nextItemWidth = pageViewBean?[nextItemIndex]?.pageWidth??0.0;
  ///上一个item的下标,注意极值
  int previousItemIndex = willIndex == 0?willIndex:(willIndex - 1);
  ///上一个item的距离起点的距离
  double previousItemToLeading = pageViewBean?[previousItemIndex]?.offsetRevealToLeading??0.0;
  ///下一个item宽度
  double previousItemWidth = pageViewBean?[previousItemIndex]?.pageWidth??0.0;
  ///移动距离
  double moveOffset = 0.0;
  ///动画耗时
  int milliseconds = 300;
  ///动画方式
  Curve listCurve = Curves.easeInOutQuint;

  ///指示器往右移动
  if(isToRight){
    if( nextItemToLeading >= (scrollOffset + viewPortLength)){///下一个item在右侧不可见区域
      ///计算需要滚动到的位置
      moveOffset = scrollOffset + nextItemToLeading + nextItemWidth - ( scrollOffset + viewPortLength );
      ///动画滚动
      listScrollController.animateTo(
          moveOffset,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }else if( nextItemToLeading < (scrollOffset + viewPortLength) && (nextItemToLeading + nextItemWidth) > (scrollOffset + viewPortLength)){///下一个item在右侧一部分可见
      ///计算需要滚动到的位置
      moveOffset = scrollOffset + nextItemWidth - ( scrollOffset + viewPortLength - nextItemToLeading);
      ///动画滚动
      listScrollController.animateTo(
          moveOffset,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }else if( (nextItemToLeading + nextItemWidth) <= scrollOffset){///下一个item在左侧不可见区域
      ///计算需要滚动到的位置
      moveOffset = nextItemToLeading - viewPortLength + nextItemWidth;
      ///动画滚动
      listScrollController.animateTo(
          moveOffset,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }
  }else{///指示器往左移动
    if( (previousItemToLeading + previousItemWidth) <= scrollOffset){///下一个item在左侧不可见区域
      ///动画滚动
      listScrollController.animateTo(
          previousItemToLeading,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }else if( previousItemToLeading < scrollOffset && (previousItemToLeading + previousItemWidth) > scrollOffset){///下一个item在左侧一部分可见
      ///动画滚动
      listScrollController.animateTo(
          previousItemToLeading,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }else if( previousItemToLeading >= (scrollOffset + viewPortLength)){///下一个item在右侧不可见区域
      ///动画滚动
      listScrollController.animateTo(
          previousItemToLeading,
          duration: Duration(milliseconds: milliseconds),
          curve: listCurve
      );
    }
  }
}

最后在_getDataCallBack中调用计算函数

///翻页过程中,翻页动作完毕触发
void _getDataCallBack(data) {
  ///ListView位移计算
  CostPageViewSingleton().listViewDisplacement(
      CostPageViewSingleton().allPageViewBeans[pageTitle],
      CostPageViewSingleton().allListViewDistanceRolled[pageTitle]??0.0,
      isToRight,
      spanIndex,
      pageTotal,
      listScrollController
  );
}

看一下效果

自动滚动.gif

点击跳转

之前标题底层组件CostPaginationIndicator已经预留好了暴露给外部的点击事件。

@override
Widget build(BuildContext context) {
  return GestureDetector(
    onTap: (){
      ///点击回调传当前点击的下标
      widget.onPressed(UISingleton().allPageViewBeans[widget.pageKey]![widget.index]);
    },
    child: Container(xxxx),
  );
}

外部点击的时候就知道要跳转的分页下标了

///item
return CostPaginationIndicator(
  XXXX
  onPressed: (value){
    pageController.animateToPage(value?.pageIndex??0, duration: Duration(milliseconds: 1000), curve: Curves.easeInOutBack);
  },
);

看效果

点击跳转bug.gif

我们可以看到有个bug,当我点击最左边的item时候,按理来说应该左滑 ->下一个在左侧全部没显示,最后上一个item自动位移显示出来,但没有。这是因为我们用的animateToPage,用的动画有回弹效果,所以最后根据我们的代码会判断成右移,既然是右移但然不会自动滑动显示。 所以我们需要一个额外变量来承接老的分页下标。

///上一次的page下标,用于有动画的时候判断左移右移
int oldIndex = 0;

然后在触发自动滑动显示中做判断

///翻页过程中,翻页动作完毕触发
void _getDataCallBack(data) {
  if(nowCurPosition >= oldIndex){
    isToRight = true;
  }else{
    isToRight = false;
  }
  oldIndex = nowCurPosition.toInt();
  XXXX
}

你以为就可以了?

同时滚动bug
同时滚动bug.gif 同时移动bug.gif

我们看到当触发翻页完成计算位移的一瞬间去滑动,就会位移错乱,这是因为标题的滚动和分页的滚动都会改变指示器的位移,当分页滑动的时候,下标还是上一个,所以一旦滑动标题,标题就是按照上一个下标开始移动指示器,这就是错位产生的原因

我们需要在ListView滚动监听的时候做近似下标处理

///ListView滚动监听,用于标题滑动的时候,指示器能随着移动
///
/// [percent];进度
/// [isToRight];是否右移
/// [spanIndex];即将跨越的item
/// [listMovePixels];ListView移动的距离
/// [pageTitle];当前分页标题
double listOffSetX(double percent,bool isToRight,int spanIndex,double listMovePixels,String pageTitle){
  double offSetX = 0.0;
  ///只有当进度在0.8~1.0之间时候,自动定位下一个item,主要因为percent在左移结束为1.0,右移结束为0.0
  if(percent > 0.8 && percent < 1.0){
    ///老样子判断左右位移
    int temporaryIndex = isToRight?spanIndex + 1:spanIndex - 1;
    offSetX = allPageViewBeans[pageTitle]![temporaryIndex]!.offsetRevealToLeading - listMovePixels;
  }else{///当percent为1.0和0.0时候,基本上分页滑动接近结束才会滑动标题,其他情况手速没这么快,所以直接判断两种情况
    offSetX = allPageViewBeans[pageTitle]![spanIndex]!.offsetRevealToLeading - listMovePixels;
  }
  return offSetX;
}
onNotification: (ScrollNotification notification){
  //开始滚动
  if(notification is ScrollStartNotification){
    XXXX
    ///
    offSetX = CostPageViewSingleton().listOffSetX(percent, isToRight, spanIndex, notification.metrics.pixels, pageTitle);
    XXXX
   }
},

这样就可以了

自定义标题效果

因为现在的计算和自定义标题内部大小毫无关系,所以我们可以随便替换标题内容

文字

image.png

图片

image.png

如果放图片请一开始就明确宽高,因为图片会延迟给到宽高,从而导致最后一帧回调拿到图片的size为0

总结

自定义图片标题还是不太完美,总的来说就数位移计算的地方最繁琐,其他地方有了思路就感觉还好,画图是个好东西,如果遇到问题卡壳了不如画图试试,下面就放全部源码链接 c1s1x1/PageViewDemo: 分页样例 (github.com)

今天的文章Flutter 分页指示器进阶分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/16200.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注