三步实现一个自定义任意路径的嫦娥奔月(Flutter版)

3,204 阅读7分钟

“我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

前言

可能不少人看到这个标题,心里想的是:

要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧

不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货

要是真这么想的话,我只能说:

我看你是完全不懂哦 真拿你没办法

下面给大家整个活,为大家介绍一下我们“listView是万能的”教会的唯一真主和慈父——ListView,是如何通过自定义,来实现这个需求的;

先放上效果图:

最终效果

前期准备,需要自定义并提供给ListView的部分;

1. 首先,我们需要一个又大又圆的月亮:

这里呢,就先用一个背景图替代,所以把一个背景图放到stack底层中:

Stack(
  children: [
    Positioned.fill(
      child: Image.asset("img/bg_mid_autumn.jpg",fit: BoxFit.cover,),
    ),
    Positioned.fill(
      /// 自定义的ListView
      /// 先以RecyclerView的形式命个名,毕竟思路参考自Android 的RecyclerView
      child: RecyclerView.builder(...),
    ),
  ],

2. 以及主人公————嫦娥:

把它以item的形式加入到自定义ListView中

RecyclerView.builder(
  ...
  itemBuilder: (context, index) {
    return Container(
      width: 100,
      alignment: AlignmentDirectional.topCenter,
      child: Image.asset("img/img_chang_e.png",fit: BoxFit.cover,width: 100,height: 100,),
    );
  }
)

3. 搞一个提供规划登月路径的Widget:

class ImageEditor extends CustomPainter {
  ImageEditor();

  Path? drawPath;

  final Paint painter = new Paint()
    ..color = Colors.blue
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10;

  void update(Offset offset) {
    if (drawPath == null) {
      drawPath = Path()..moveTo(offset.dx, offset.dy);
    }

    drawPath?.lineTo(offset.dx, offset.dy);
  }

  @override
  void paint(Canvas canvas, Size size) {
    if (drawPath != null) {
      canvas.drawPath(drawPath!, painter);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

搞定~正好三步;

大家先别急着骂,先不提这个自定义的ListView以及一堆莫名其妙的东西从哪来的,就说是不是三步吧

虽然要实现这三步,需要做如下工作来实现:

关于奔月动画的实现原理、方式

这块参考自android的RecyclerView的自定义LayoutManager的部分,具体详细步骤可以看这个大佬的文章:

# Android自定义LayoutManager第十一式之飞龙在天

这块仅供提供思路,虽说Flutter中没有RecyclerView这种神器,也没有layoutManager这种东西,甚至onMeasure、onLayout这块的触发时机等方面跟android都不同;

但是下沉到onMeasure、onDraw、onLayout这个层面,其实都是一样的,并非不可参考

分析与实现,需要改造ListView哪些地方:

1. 首先,我们先从 ListView 本身开始:

ListView的结构其实并不复杂,或者嚣张点,大部分可滑动的View,也无非就在那几个类上面修修改改,换句话说:

学姐

当然我知道各位一点都不喜欢看代码(其实是因为这部分太多了……放一篇介绍文章中放不下),那我简化一下,只提一下这次涉及的部分和浅层解析,毕竟这块东西我也是简单了解一下(纯属个人理解,有错误请狠狠的打我脸):

  1. ListView、nestedScrollView、CustomScrollView等滑动View,都是直接或者间接继承自ScrollView,ScrollView这个抽线类,就是黑龙江职业学院,那几个可滑动View都是受ScrollView管控;

  2. ScrollView 中管事的就是Scrollable ,把它当成学生会就行;

  3. 在这次中,Scrollable 中有这么几个类要知道:ViewPortScrollControllScrollPostion; ViewPort负责管理提供可视范围视图(学生会生活部?负责提供我们去哪里查寝)、ScrollPostion负责记录滚动位置、最大最小距离之类的信息(学生会书记?记录一下查寝结果)、ScrollControll负责统筹滚动视图的展示、动画等部分(这个我懂,这个是主席,张美玉学姐好);

2. 打破ListView不可滚动溢出的限制,并控制初始位置:

要是嫌麻烦,直接往listView的item列表的头尾处,加个listView大小的空白页,也是可以实现同样效果的

用于装逼,了解listView逻辑思路的写法:

  1. 按照上面的分析,如果要让listView可以滚动溢出,那么需要做的事,就是去找ViewPort的麻烦;

下面我们来回忆一下,一个控件,想要显示,不可避免要经过的三个步骤是:

1、measure;2、layout;3、draw

要想获取滚动限制、明显是measure或者layout部分的东西,结合ScrollPostion的_minScrollExtent和_maxScrollExtent的来源,可以定位可以修改的位置是在 RenderViewPortperformLayout 方法中,调用 scrollPosition 的 applyContentDimensions 方法的地方;

比如说这样修改,将ListView本身大小作为滚动溢出范围:

do {
 assert(offset.pixels != null);
 correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
     offset.pixels + centerOffsetAdjustment);
 if (correction != 0.0) {
   offset.correctBy(correction);
 } else {
   if (offset.applyContentDimensions(
   
     /// 在这里调整可溢出范围,比如说下面就把size.width 作为可溢出范围,最小范围减少Size.width,最大范围增加Size.width;
     math.min(-size.width, _minScrollExtent + mainAxisExtent * anchor),
     math.max(0.0,
         _maxScrollExtent - mainAxisExtent * (1.0 - anchor) + size.width),
   )) break;
 }
 count += 1;
} while (count < _maxLayoutCycles);
  1. 然后让ListView的初始展示位置,设置到-Size.width的位置;

在这里我的做法是通过 LayoutBuilder 获取约束范围,然后将约束最大值直接赋值给 ScrollController,例如下面代码:

 LayoutBuilder(builder: (_context, _constraint) {

   return RecyclerView.builder(
     scrollDirection: Axis.horizontal,
     
     /// 这里将约束的最大值的负数提供到ScrollController的initialScrollOffset中
     controller: ScrollController(
         initialScrollOffset: -_constraint.maxWidth),
     itemCount: 3,
     reverse: true,
     addRepaintBoundaries: false,
     ....
     )
   }

PS : 这块的源码,虽说我们只需要改这么一个小点,但是像override这种方式都会因为一堆私有变量什么的无法获取,所以直接从 RenderViewportBase 到 RenderViewPort 都完整复制出来吧

  1. 最后将自定义好的ViewPort的Render部分,传给ViewPort的Widget部分,最后放到自定义ListView的buildViewPort部分:(在这里,我将这个提供溢出滚动的ViewPort命名为OverScrollViewPort)
@override
Widget buildViewport(BuildContext context, ViewportOffset offset, AxisDirection axisDirection, List<Widget> slivers) {
  if (shrinkWrap) {
    return OverScrollShrinkWrappingViewport(
      axisDirection: axisDirection,
      offset: offset,
      slivers: slivers,
      clipBehavior: clipBehavior,
    );
  }

  return OverScrollViewPort(
    axisDirection: axisDirection,
    offset: offset,
    slivers: slivers,
    cacheExtent: cacheExtent,
    center: center,
    anchor: anchor,
    clipBehavior: clipBehavior,
  );
}

如果我没有遗漏部分的话,这时候运行一下代码,应该是这种效果:

最终效果

3. 修改绘制,按path要求绘制:

如果你做好了准备工作,提供了一个自定义路径出来,那么将这个path传到负责绘制的 RenderObject 中,在paint方法中获取滑动比例对应path的位置,就调整绘制位置:

@override
void paint(PaintingContext context, Offset offset) {
 ...
 
 /// 在这里处理path。
 Path? customPath;
 PathMetric? pm;
 double? length;
 if (layoutManager is PathLayoutManager) {
   customPath = layoutManager.path;

   var pathMetrics = customPath.computeMetrics(forceClosed: false);
   pm = pathMetrics.elementAt(0);
   length = pm.length;
 }
 
 while (child != null) {
   double mainAxisDelta = childMainAxisPosition(child);
   final double crossAxisDelta = childCrossAxisPosition(child);
   ...
   
   /// 关于这块去掉原先 mainAxisDelta < constraints.remainingPaintExtent 部分的原因
   /// 是因为之前第一个item会在滚动到边界前就被移除绘制
   /// 具体是什么地方修改导致的,忘了(๑>؂<๑)
   if (mainAxisDelta + paintExtentOf(child) > 0) {
        if (customPath != null) {
         var percent = (childOffset.dx + child.size.width) /
             (child.size.width + constraints.viewportMainAxisExtent);
         var tf = pm!.getTangentForOffset(length! * percent);
         print("test :${tf?.position}");

         var childItemOffset = childOffset;

         if (tf?.position != null) {
           /// 这里的50 魔法数,是因为之前设置item的height为100,
           /// 因为listView好像强制将item的高度固定为listView的高度(横向情况)
           /// 这块找个时间研究下怎么搞
           /// 强调下,好孩子不要学我这写法
           childItemOffset = Offset(
               tf!.position.dx - child.size.width / 2, tf.position.dy - 50);
         }

         context.pushTransform(
           needsCompositing,
           childItemOffset,
           Matrix4.identity(),
           // Pre-transform painting function.
           painter,
         );
       } else {
         context.paintChild(child, childOffset);
       }
   }
   
   ...
 }
 
 ...
}

PS:我这里弄了个LayoutManager,其实就是新建个类,把它从widget传到 renderObject &@&%……#;path的处理这块也是有问题的,不应该放在这里搞,好孩子不要学我这么搞,我这是实验性代码…………

当然,要想做到完美复刻RecyclerView,还有不少地方要改动

比如说,你给item加个点击事件,你会发现……现在这种方式,仅仅是改变了绘制的位置,item本身并未移动:

注意看弹toast前的点击位置,明明是左上角

现存问题

我猜想:这里就要涉及到listView 的 insertAndLayout 部分了,进而涉及到整体的滑动逻辑…………或者是hitTest的部分?(或许这是part 2新篇预告?)

在现在这个基础上,还有可以拓展的方面:

除了嫦娥奔月效果,其实还可以实现一些其他效果,例如:

覆盖翻页效果 覆盖翻页效果

item变换 item变换

另外在ParentData等部分中,也有一些有点意思的东西,个人感觉都挺有用的

题外话,上面正文的做法,为什么我个人并不推荐

在我看来,现在文中的这种自定义方式是不符合flutter的推荐方式的:

在我的理解中,在做flutter的自定义的时候,有个比较重要的一句话是需要遵守的:

万物均为widget

所以,如果可以的话,尽量使用widget来代替回调、方法这种,如果无法避免,也尽量约束到一个widget、及其对应element、renderObject;

所以,现在文中的方式,在我看来,虽然能实现需求,但是是通过各种回调、耦合了各个widget的及其对应的element、renderObject,因此不是flutter的良好代码,

这段代码,应急可以,偷懒也行,用于学习思路,分析步骤也是没问题的,但是,不推荐真这么搞哈

这篇文章的主要目的,是参考Android的实现方式,来分享思路与分析flutter中的listView,以及最重要的:

正好最近想换个杯子,我看6个赞就可以换的掘金新IP杯子就挺不错的