注册

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

前言


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


要是被我发现你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,以及最重要的:



作者:lwlizhe
链接:https://juejin.cn/post/7007254000307437598
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册