本文将分享我在Flutter项目中实现类似闲鱼、美团首页那种变宽变高滑动效果的完整历程,从踩坑到最终解决方案的全过程。
需求背景
作为一个Flutter开发者,最近有个"看似简单"的需求:
"我们要做一个像闲鱼、美团首页那样的滑动效果,每个页面宽度不一样,滑动的时候高度还要跟着变,最好能像PageView那样有吸附效果。"
听完我第一反应:这不就是PageView加个高度动画吗?应该不难吧?
然而,真正动手后才发现,这个需求远没有想象中那么简单...
第一次尝试:PageView的滑铁卢
PageView的基本使用
我首先想到的就是Flutter自带的PageView,毕竟它天生支持页面切换和吸附效果:
PageView.builder(
itemCount: pages.length,
itemBuilder: (context, index) {
return Container(
width: pageWidths[index], // 每个页面不同宽度
height: pageHeights[index], // 每个页面不同高度
child: pages[index],
);
},
)
遇到的问题
运行后发现几个问题:
- PageView强制全屏宽度:PageView的设计初衷就是全屏滑动,每个page都会被强制撑满父容器宽度
- 高度无法动态调整:PageView的高度是固定的,无法根据当前页面内容高度变化
- 滑动体验不一致:PageView的滑动计算基于等宽页面,无法处理变宽场景
尝试改造PageView
我开始尝试各种PageView的改造方案:
// 尝试设置不同的viewportFraction
PageView.builder(
controller: PageController(viewportFraction: 0.8),
// ...
)
// 尝试自定义PageScrollPhysics
class CustomPageScrollPhysics extends PageScrollPhysics {
// 重写各种方法...
}
结果都不理想,PageView的架构就是基于等宽页面设计的,改造起来非常痛苦。
第二次尝试:SingleChildScrollView + 自定义Physics
换个思路
既然PageView不行,那我能不能用SingleChildScrollView + Row来实现呢?这样我就可以完全控制每个页面的宽度和布局了。
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(pages.length, (index) {
return Container(
width: pageWidths[index],
height: currentHeight, // 需要动态计算
child: pages[index],
);
}),
),
)
实现吸附效果
现在的问题是如何实现类似PageView的吸附效果。这就需要自定义ScrollPhysics了。
ScrollPhysics的工作原理
在深入实现前,我先研究了ScrollPhysics的工作原理:
- createBallisticSimulation:创建滚动动画模拟
- createBallisticSimulation:计算滚动停止时的目标位置
- shouldAcceptUserOffset:决定是否接受用户滚动
实现VariableWidthScrollPhysics
class FlexPageScrollPhysics extends ScrollPhysics {
const FlexPageScrollPhysics(this.pageWidths, {super.parent});
final List<double> pageWidths;
@override
FlexPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FlexPageScrollPhysics(pageWidths, parent: buildParent(ancestor));
}
// 关键:计算当前滚动位置对应的页面
double _getPage(ScrollMetrics position) {
var pixels = position.pixels;
for (var i = 0; i < pageWidths.length; i++) {
if (pixels < pageWidths[i]) {
return i + pixels / pageWidths[i];
}
pixels -= pageWidths[i];
}
return 0;
}
// 关键:计算页面对应的滚动位置
double _getPixels(ScrollMetrics position, double page) {
var pixels = 0.0;
for (var i = 0; i < pageWidths.length && i < page; i++) {
pixels += pageWidths[i];
}
return pixels;
}
// 关键:计算滚动停止时的目标位置
double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) {
double page = _getPage(position);
if (velocity < -tolerance.velocity) {
page -= 0.5;
} else if (velocity > tolerance.velocity) {
page += 0.5;
}
return _getPixels(position, page.roundToDouble());
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
if (position.outOfRange) {
return super.createBallisticSimulation(position, velocity);
}
final Tolerance tolerance = toleranceFor(position);
final double target = _getTargetPixels(position, tolerance, velocity);
if (target != position.pixels) {
return ScrollSpringSimulation(
spring,
position.pixels,
target,
velocity,
tolerance: tolerance,
);
}
return null;
}
}
关键点解析
- 页面计算:
_getPage方法根据当前滚动位置计算所在的页面索引 - 位置计算:
_getPixels方法根据页面索引计算对应的滚动位置 - 目标位置:
_getTargetPixels方法根据滚动速度决定吸附到哪个页面 - 动画模拟:使用ScrollSpringSimulation创建平滑的滚动动画
第三次尝试:动态高度调整
高度变化的挑战
解决了吸附问题后,现在需要处理高度动态变化。需求是:
- 滚动过程中,容器高度要根据当前页面和滚动进度平滑变化
- 不能出现跳跃,要流畅过渡
实现方案
我使用了ValueNotifier来监听滚动位置,然后计算当前应该显示的高度:
class FlexPageSlider extends StatefulWidget {
final List<double> pageWidths;
final List<double> pageHeights;
final Widget child;
const FlexPageSlider({
super.key,
required this.pageWidths,
required this.pageHeights,
required this.child,
});
@override
State<FlexPageSlider> createState() => _FlexPageSliderState();
}
class _FlexPageSliderState extends State<FlexPageSlider> {
final ValueNotifier<double> _heightNotifier = ValueNotifier(0);
late ScrollController _controller;
@override
void initState() {
super.initState();
_controller = ScrollController();
_controller.addListener(_updateHeight);
_heightNotifier.value = widget.pageHeights.first;
}
void _updateHeight() {
var position = _controller.position;
int index = 0;
double p = widget.pageWidths[index];
// 找到当前所在的页面
while (p <= position.pixels && index < widget.pageWidths.length - 1) {
index += 1;
p += widget.pageWidths[index];
}
// 计算滚动进度
var width1 = widget.pageWidths[index];
var height1 = widget.pageHeights[index];
var height2 = widget.pageHeights[min(index + 1, widget.pageHeights.length - 1)];
if (height1 == height2) return;
var deltaH = height2 - height1;
var percent = (position.pixels - (p - width1)) / width1;
var target = deltaH * percent + height1;
_heightNotifier.value = target;
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _controller,
physics: FlexPageScrollPhysics(widget.pageWidths),
scrollDirection: Axis.horizontal,
child: ValueListenableBuilder(
valueListenable: _heightNotifier,
builder: (context, double height, _) {
return SizedBox(
height: height,
child: widget.child,
);
},
),
);
}
}
性能优化
使用ValueNotifier而不是setState,避免不必要的rebuild,提高性能。
第四次尝试:Sliver实现
新的需求
随着项目发展,需要支持大量数据的场景,普通的Row实现性能堪忧。于是我开始研究Sliver实现。
Sliver的挑战
Sliver的实现比普通Widget复杂得多,需要:
- 动态计算每个item的宽度
- 处理Sliver的layout协议
- 支持懒加载
最终实现
class FlexSliverSlider extends StatefulWidget {
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
const FlexSliverSlider({
super.key,
required this.itemCount,
required this.itemBuilder,
});
@override
State<FlexSliverSlider> createState() => _FlexSliverSliderState();
}
class _FlexSliverSliderState extends State<FlexSliverSlider> {
Map<int, GlobalKey> _keys = {};
FlexPageScrollPhysics _physics = FlexPageScrollPhysics([]);
void _getWidgetSize() {
WidgetsBinding.instance.addPostFrameCallback((_) {
List<double> widths = [];
for (GlobalKey key in _keys.values) {
final renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderBox) {
widths.add(renderObject.size.width);
}
}
setState(() {
_physics = FlexPageScrollPhysics(widths);
});
});
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: _physics,
scrollDirection: Axis.horizontal,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var key = _keys[index] ??= GlobalKey();
_getWidgetSize();
return KeyedSubtree(
key: key,
child: widget.itemBuilder(context, index),
);
},
childCount: widget.itemCount,
),
)
],
);
}
}
总结与心得
技术要点
- ScrollPhysics定制:理解ScrollPhysics的工作原理是关键,特别是createBallisticSimulation方法
- 性能优化:使用ValueNotifier而不是setState,避免不必要的rebuild
- Sliver协议:对于大量数据,使用Sliver实现可以获得更好的性能
- 动画平滑:使用ScrollSpringSimulation创建自然的滚动动画
踩坑记录
- PageView不适合变宽场景:PageView是为等宽页面设计的,强行改造会很难受
- Row+ScrollView更灵活:对于变宽需求,使用Row+SingleChildScrollView更灵活
- 高度计算要精确:滚动过程中的高度计算要考虑边界情况,避免数组越界
- Sliver的异步布局:Sliver的layout是异步的,需要addPostFrameCallback获取尺寸
最终效果
经过几轮迭代,最终实现了:
- ✅ 可变宽度的页面滑动
- ✅ 平滑的高度过渡动画
- ✅ 自然的吸附效果
- ✅ 支持大量数据的高性能实现
源码地址
完整的代码已经开源,欢迎大家star和提issue: GitHub 仓库
如果这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题也可以在评论区交流~