三步实现一个自定义任意路径的嫦娥奔月(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,也无非就在那几个类上面修修改改,换句话说:
当然我知道各位一点都不喜欢看代码(其实是因为这部分太多了……放一篇介绍文章中放不下),那我简化一下,只提一下这次涉及的部分和浅层解析,毕竟这块东西我也是简单了解一下(纯属个人理解,有错误请狠狠的打我脸):
- ListView、nestedScrollView、CustomScrollView等滑动View,都是直接或者间接继承自ScrollView,ScrollView这个抽线类,就是黑龙江职业学院,那几个可滑动View都是受ScrollView管控;
- ScrollView 中管事的就是Scrollable ,把它当成学生会就行;
- 在这次中,Scrollable 中有这么几个类要知道:ViewPort、ScrollControll、ScrollPostion;
ViewPort负责管理提供可视范围视图(学生会生活部?负责提供我们去哪里查寝)、ScrollPostion负责记录滚动位置、最大最小距离之类的信息(学生会书记?记录一下查寝结果)、ScrollControll负责统筹滚动视图的展示、动画等部分(这个我懂,这个是主席,张美玉学姐好);
2. 打破ListView不可滚动溢出的限制,并控制初始位置:
要是嫌麻烦,直接往listView的item列表的头尾处,加个listView大小的空白页,也是可以实现同样效果的
用于装逼,了解listView逻辑思路的写法:
- 按照上面的分析,如果要让listView可以滚动溢出,那么需要做的事,就是去找ViewPort的麻烦;
下面我们来回忆一下,一个控件,想要显示,不可避免要经过的三个步骤是:
1、measure;2、layout;3、draw
要想获取滚动限制、明显是measure或者layout部分的东西,结合ScrollPostion的_minScrollExtent和_maxScrollExtent的来源,可以定位可以修改的位置是在 RenderViewPort 的 performLayout 方法中,调用 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);
- 然后让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 都完整复制出来吧
- 最后将自定义好的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变换
另外在ParentData等部分中,也有一些有点意思的东西,个人感觉都挺有用的
题外话,上面正文的做法,为什么我个人并不推荐
在我看来,现在文中的这种自定义方式是不符合flutter的推荐方式的:
在我的理解中,在做flutter的自定义的时候,有个比较重要的一句话是需要遵守的:
万物均为widget
所以,如果可以的话,尽量使用widget来代替回调、方法这种,如果无法避免,也尽量约束到一个widget、及其对应element、renderObject;
所以,现在文中的方式,在我看来,虽然能实现需求,但是是通过各种回调、耦合了各个widget的及其对应的element、renderObject,因此不是flutter的良好代码,
这段代码,应急可以,偷懒也行,用于学习思路,分析步骤也是没问题的,但是,不推荐真这么搞哈
这篇文章的主要目的,是参考Android的实现方式,来分享思路与分析flutter中的listView,以及最重要的:
链接:https://juejin.cn/post/7007254000307437598
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。