文章目录
- [Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考](#Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考)
-
- [一、CustomScrollView 核心原理与基础用法](#一、CustomScrollView 核心原理与基础用法)
-
- [1.1 为什么需要 CustomScrollView?](#1.1 为什么需要 CustomScrollView?)
- [1.2 核心组件关系](#1.2 核心组件关系)
- [1.3 基础案例:整合列表与网格](#1.3 基础案例:整合列表与网格)
- 二、高级滑动效果实战案例
-
- [案例 1:悬浮吸顶 + 多级列表(类似电商分类页)](#案例 1:悬浮吸顶 + 多级列表(类似电商分类页))
- [案例 2:视差滚动(Parallax Scrolling)](#案例 2:视差滚动(Parallax Scrolling))
- [案例 3:滚动触发动画(渐显、缩放)](#案例 3:滚动触发动画(渐显、缩放))
- [案例 4:自定义 Sliver 组件(瀑布流布局)](#案例 4:自定义 Sliver 组件(瀑布流布局))
- [案例 5:Flutter 与开源鸿蒙滑动效果对比实现](#案例 5:Flutter 与开源鸿蒙滑动效果对比实现)
- 三、性能优化技巧
-
- [3.1 避免过度绘制](#3.1 避免过度绘制)
- [3.2 懒加载优化](#3.2 懒加载优化)
- [3.3 动画性能](#3.3 动画性能)
- 四、总结与扩展
-
- [4.1 核心总结](#4.1 核心总结)
- [4.2 扩展方向](#4.2 扩展方向)
- [4.3 学习建议](#4.3 学习建议)
Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考
在移动应用开发中,流畅且富有创意的滑动交互是提升用户体验的核心要素之一。Flutter 中的 CustomScrollView 作为灵活度极高的滑动组件,能够整合多种滚动效果(如列表、网格、悬浮吸顶、视差动画等),实现复杂的页面滚动逻辑;而开源鸿蒙(OpenHarmony)作为分布式操作系统,其滑动组件体系也有着独特的设计思路。本文将从 CustomScrollView 的核心原理出发,通过 5 个实战案例拆解高级滑动效果的实现逻辑,并对比开源鸿蒙的类似方案,为跨端开发提供参考。
一、CustomScrollView 核心原理与基础用法
1.1 为什么需要 CustomScrollView?
Flutter 中的 ListView、GridView 等组件本质上都是封装好的滚动组件,但它们存在一个局限:单一组件只能实现单一的滚动布局 。如果想要在同一个滚动视图中同时包含列表、网格、悬浮头部等多种元素,并且实现统一的滚动效果(如联动滚动、共同响应滑动事件),ListView 或 GridView 就难以满足需求。
CustomScrollView 的核心价值在于:通过 Sliver 组件作为滚动单元,整合多种不同类型的滚动布局,实现统一的滚动交互 。其中,Sliver(中文意为"薄片")是 Flutter 滚动体系中的核心概念,它代表了一个可滚动的"片段",能够被 CustomScrollView 统一管理滚动状态。
1.2 核心组件关系
CustomScrollView 的结构可以概括为:
CustomScrollView(
// 滚动方向、物理效果等配置
scrollDirection: Axis.vertical,
physics: BouncingScrollPhysics(),
// 关键:Sliver 组件列表
slivers: [
SliverAppBar(), // 悬浮头部
SliverList(), // 列表片段
SliverGrid(), // 网格片段
SliverToBoxAdapter(), // 包裹非 Sliver 组件
],
)
- Sliver 组件分类 :
- 基础滚动 Sliver:
SliverList(列表)、SliverGrid(网格)、SliverFixedExtentList(固定高度列表)等; - 辅助 Sliver:
SliverAppBar(悬浮头部)、SliverPadding(内边距)、SliverToBoxAdapter(适配非 Sliver 组件)、SliverPersistentHeader(持久化头部)等; - 高级效果 Sliver:
SliverOpacity(透明动画)、SliverTransform(变换动画)等。
- 基础滚动 Sliver:
1.3 基础案例:整合列表与网格
下面通过一个简单案例,展示如何用 CustomScrollView 整合 SliverList 和 SliverGrid,实现"列表 + 网格"的统一滚动效果:
dart
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'CustomScrollView 基础案例',
theme: ThemeData(primarySwatch: Colors.blue),
home: const CustomScrollHomePage(),
);
}
}
class CustomScrollHomePage extends StatelessWidget {
const CustomScrollHomePage({super.key});
// 生成列表项
Widget _buildListItem(String text) {
return ListTile(
title: Text(text),
leading: const Icon(Icons.list, color: Colors.blue),
);
}
// 生成网格项
Widget _buildGridItem(int index) {
return Container(
color: Colors.primaries[index % Colors.primaries.length],
child: Center(
child: Text(
'Grid $index',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 关键:CustomScrollView 作为 body
body: CustomScrollView(
physics: const BouncingScrollPhysics(), // iOS 风格弹性滚动
slivers: [
// 1. 悬浮头部
const SliverAppBar(
title: Text('列表 + 网格 混合滚动'),
floating: true, // 滚动时快速显示
pinned: true, // 顶部固定
expandedHeight: 180, // 展开高度
flexibleSpace: FlexibleSpaceBar(
background: Image(
image: NetworkImage('https://picsum.photos/800/400'),
fit: BoxFit.cover,
),
),
),
// 2. 列表片段
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildListItem('列表项 ${index + 1}'),
childCount: 10, // 列表长度
),
),
// 3. 网格片段
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 列数
crossAxisSpacing: 8.0, // 列间距
mainAxisSpacing: 8.0, // 行间距
childAspectRatio: 1.5, // 宽高比
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridItem(index),
childCount: 6, // 网格数量
),
),
),
],
),
);
}
}
核心亮点:
SliverAppBar的floating和pinned属性实现了"滚动悬浮 + 展开折叠"效果;SliverList和SliverGrid共享同一个滚动状态,滑动时无缝衔接;SliverPadding为网格添加内边距,保证布局美观。
二、高级滑动效果实战案例
案例 1:悬浮吸顶 + 多级列表(类似电商分类页)
需求场景:
实现类似电商 App 分类页的效果:顶部为搜索栏(固定),中间为分类标签(滚动到顶部时吸顶),下方为分类对应的商品列表,支持标签与列表联动。
实现思路:
- 使用
SliverPersistentHeader实现分类标签的吸顶效果; - 通过
SliverList承载商品列表,每个分类对应一个SliverList; - 利用
ScrollController监听滚动位置,实现标签与列表的联动。
完整代码:
dart
import 'package:flutter/material.dart';
class StickyHeaderDemo extends StatefulWidget {
const StickyHeaderDemo({super.key});
@override
State<StickyHeaderDemo> createState() => _StickyHeaderDemoState();
}
class _StickyHeaderDemoState extends State<StickyHeaderDemo> {
final ScrollController _scrollController = ScrollController();
final List<String> _categories = ['热门推荐', '手机数码', '电脑办公', '家居生活', '美妆护肤'];
final List<List<String>> _products = [
['手机1', '电脑1', '耳机1', '手表1', '平板1'],
['手机2', '电脑2', '耳机2', '手表2', '平板2'],
['手机3', '电脑3', '耳机3', '手表3', '平板3'],
['手机4', '电脑4', '耳机4', '手表4', '平板4'],
['手机5', '电脑5', '耳机5', '手表5', '平板5'],
];
int _currentTabIndex = 0;
// 计算每个分类的滚动偏移量
List<double> _calculateOffsets() {
final offsets = <double>[0];
double currentOffset = 0;
for (int i = 0; i < _categories.length; i++) {
// 每个分类的高度 = 标签高度(50) + 商品列表高度(每个商品50,共5个)
currentOffset += 50 + (_products[i].length * 50);
offsets.add(currentOffset);
}
return offsets;
}
// 滚动到指定分类
void _scrollToCategory(int index) {
final offsets = _calculateOffsets();
_scrollController.animateTo(
offsets[index],
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
setState(() => _currentTabIndex = index);
}
@override
void initState() {
super.initState();
// 监听滚动,更新当前激活的标签
_scrollController.addListener(() {
final offsets = _calculateOffsets();
final currentPosition = _scrollController.offset;
for (int i = 0; i < _categories.length; i++) {
if (currentPosition >= offsets[i] && currentPosition < offsets[i + 1]) {
if (_currentTabIndex != i) {
setState(() => _currentTabIndex = i);
}
break;
}
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// 构建吸顶标签
Widget _buildStickyTab() {
return SliverPersistentHeader(
pinned: true, // 吸顶固定
delegate: _StickyHeaderDelegate(
minHeight: 50, // 最小高度(吸顶时)
maxHeight: 50, // 最大高度(滚动时)
child: Container(
color: Colors.white,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _categories.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => _scrollToCategory(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.center,
child: Text(
_categories[index],
style: TextStyle(
color: _currentTabIndex == index ? Colors.blue : Colors.black,
fontWeight: _currentTabIndex == index ? FontWeight.bold : FontWeight.normal,
),
),
),
);
},
),
),
),
);
}
// 构建商品列表
Widget _buildProductList(int categoryIndex) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final product = _products[categoryIndex][index];
return Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(product),
);
},
childCount: _products[categoryIndex].length,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
// 固定搜索栏
SliverToBoxAdapter(
child: Container(
height: 60,
padding: const EdgeInsets.all(8),
child: TextField(
decoration: InputDecoration(
hintText: '搜索商品...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[200],
prefixIcon: const Icon(Icons.search),
),
),
),
),
// 吸顶标签
_buildStickyTab(),
// 商品列表(多个 SliverList 拼接)
...List.generate(_categories.length, (index) {
return Column(
children: [
// 分类标题(仅在非吸顶时显示)
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
_categories[index],
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
_buildProductList(index),
],
);
}),
],
),
);
}
}
// 自定义 SliverPersistentHeader 代理
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final double minHeight;
final double maxHeight;
final Widget child;
_StickyHeaderDelegate({
required this.minHeight,
required this.maxHeight,
required this.child,
});
@override
double get minExtent => minHeight;
@override
double get maxExtent => maxHeight;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
bool shouldRebuild(_StickyHeaderDelegate oldDelegate) {
return minHeight != oldDelegate.minHeight ||
maxHeight != oldDelegate.maxHeight ||
child != oldDelegate.child;
}
}
)
关键技术点:
SliverPersistentHeader的pinned: true实现吸顶效果,delegate自定义头部样式和高度;ScrollController监听滚动偏移量,通过计算每个分类的高度范围,实现标签与列表的联动;- 多个
SliverList拼接实现分类列表的连续滚动。
案例 2:视差滚动(Parallax Scrolling)
需求场景:
实现图片视差效果------滚动时背景图片的滚动速度慢于前景内容,营造层次感(常见于 App 首页 Banner、详情页头部)。
实现思路:
- 使用
SliverAppBar的flexibleSpace承载背景图片; - 通过
LayoutBuilder获取SliverAppBar的展开/折叠状态(shrinkOffset); - 根据
shrinkOffset计算图片的偏移量,实现视差效果。
完整代码:
dart
import 'package:flutter/material.dart';
class ParallaxScrollDemo extends StatelessWidget {
const ParallaxScrollDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// 视差头部
SliverAppBar(
title: const Text('视差滚动效果'),
pinned: true,
expandedHeight: 300, // 展开高度
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
// shrinkOffset:折叠偏移量(0 ~ expandedHeight - minHeight)
final shrinkOffset = constraints.biggest.height - kToolbarHeight;
// 视差偏移量:图片滚动速度为内容的 0.3 倍
final parallaxOffset = shrinkOffset * 0.3;
return FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
// 视差图片
Positioned(
top: -parallaxOffset,
left: 0,
right: 0,
bottom: 0,
child: Image(
image: const NetworkImage('https://picsum.photos/id/103/800/600'),
fit: BoxFit.cover,
),
),
// 渐变遮罩(提升文字可读性)
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
],
),
);
},
),
),
// 前景内容
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
'前景内容 ${index + 1}:视差滚动通过背景与前景的滚动速度差异,营造出强烈的空间层次感。在 Flutter 中,我们可以通过监听 SliverAppBar 的折叠偏移量,动态调整背景图片的位置来实现这一效果。',
style: const TextStyle(fontSize: 16),
),
),
childCount: 20,
),
),
],
),
);
}
}
关键技术点:
LayoutBuilder用于获取SliverAppBar的实时高度,进而计算shrinkOffset(折叠偏移量);Positioned组件的top属性设置为-parallaxOffset,使图片滚动速度慢于内容(parallaxOffset系数越小,视差效果越明显);- 渐变遮罩提升文字可读性,避免图片与文字混淆。
案例 3:滚动触发动画(渐显、缩放)
需求场景:
滚动列表时,新进入视野的元素自动触发渐显 + 缩放动画,提升页面交互趣味性(常见于内容展示类 App)。
实现思路:
- 使用
SliverList承载列表项; - 自定义
AnimatedListItem组件,通过AnimationController和Animation控制动画; - 利用
ScrollController监听滚动位置,当列表项进入视野时触发动画。
完整代码:
dart
import 'package:flutter/material.dart';
class ScrollAnimationDemo extends StatefulWidget {
const ScrollAnimationDemo({super.key});
@override
State<ScrollAnimationDemo> createState() => _ScrollAnimationDemoState();
}
class _ScrollAnimationDemoState extends State<ScrollAnimationDemo> {
final ScrollController _scrollController = ScrollController();
final List<GlobalKey> _itemKeys = List.generate(20, (_) => GlobalKey());
final List<bool> _isAnimated = List.filled(20, false);
@override
void initState() {
super.initState();
// 监听滚动,判断列表项是否进入视野
_scrollController.addListener(() {
for (int i = 0; i < _itemKeys.length; i++) {
if (_isAnimated[i]) continue;
final key = _itemKeys[i];
final renderObject = key.currentContext?.findRenderObject() as RenderBox?;
if (renderObject == null) continue;
final offset = renderObject.localToGlobal(Offset.zero);
// 当列表项顶部进入屏幕下半部分时触发动画
if (offset.dy < MediaQuery.of(context).size.height * 0.8) {
setState(() => _isAnimated[i] = true);
}
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('滚动触发动画')),
body: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => AnimatedListItem(
key: _itemKeys[index],
isAnimated: _isAnimated[index],
child: Container(
height: 120,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
'动画列表项 ${index + 1}',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
childCount: 20,
),
),
),
],
),
);
}
}
// 自定义动画列表项组件
class AnimatedListItem extends StatefulWidget {
final Widget child;
final bool isAnimated;
const AnimatedListItem({
super.key,
required this.child,
required this.isAnimated,
});
@override
State<AnimatedListItem> createState() => _AnimatedListItemState();
}
class _AnimatedListItemState extends State<AnimatedListItem> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// 渐显动画(0 → 1)
_opacityAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// 缩放动画(0.8 → 1)
_scaleAnimation = Tween<double>(begin: 0.8, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// 如果初始状态已触发动画,直接播放
if (widget.isAnimated) {
_controller.forward();
}
}
@override
void didUpdateWidget(covariant AnimatedListItem oldWidget) {
super.didUpdateWidget(oldWidget);
// 当 isAnimated 从 false 变为 true 时,播放动画
if (widget.isAnimated && !oldWidget.isAnimated) {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
);
},
child: widget.child,
);
}
}
关键技术点:
- 每个列表项通过
GlobalKey获取自身的渲染位置,判断是否进入视野; AnimatedListItem组件使用AnimationController控制渐显(Opacity)和缩放(Transform.scale)动画;didUpdateWidget监听isAnimated状态变化,实现滚动时动态触发动画。
案例 4:自定义 Sliver 组件(瀑布流布局)
需求场景:
实现瀑布流布局(不同列的列表项高度不同,自动填充空白区域),常见于图片展示、商品列表等场景。
实现思路:
- 自定义
SliverWaterfallGrid组件,继承SliverMultiBoxAdaptorWidget; - 重写
createRenderObject方法,自定义RenderSliverWaterfallGrid渲染逻辑; - 通过计算每列的当前高度,将新列表项添加到高度最小的列中,实现瀑布流效果。
完整代码:
dart
import 'package:flutter/material.dart';
// 自定义瀑布流 Sliver 组件
class SliverWaterfallGrid extends SliverMultiBoxAdaptorWidget {
final int crossAxisCount; // 列数
final double crossAxisSpacing; // 列间距
final double mainAxisSpacing; // 行间距
final EdgeInsetsGeometry padding; // 内边距
const SliverWaterfallGrid({
super.key,
required super.delegate,
required this.crossAxisCount,
this.crossAxisSpacing = 0,
this.mainAxisSpacing = 0,
this.padding = EdgeInsets.zero,
});
@override
RenderObject createRenderObject(BuildContext context) {
final padding = this.padding.resolve(Directionality.of(context));
return RenderSliverWaterfallGrid(
childManager: childManager,
crossAxisCount: crossAxisCount,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
padding: padding,
);
}
@override
void updateRenderObject(BuildContext context, RenderSliverWaterfallGrid renderObject) {
final padding = this.padding.resolve(Directionality.of(context));
renderObject
..crossAxisCount = crossAxisCount
..crossAxisSpacing = crossAxisSpacing
..mainAxisSpacing = mainAxisSpacing
..padding = padding;
}
}
// 瀑布流渲染逻辑
class RenderSliverWaterfallGrid extends RenderSliverMultiBoxAdaptor {
int _crossAxisCount;
double _crossAxisSpacing;
double _mainAxisSpacing;
EdgeInsets _padding;
RenderSliverWaterfallGrid({
required super.childManager,
required int crossAxisCount,
required double crossAxisSpacing,
required double mainAxisSpacing,
required EdgeInsets padding,
}) : _crossAxisCount = crossAxisCount,
_crossAxisSpacing = crossAxisSpacing,
_mainAxisSpacing = mainAxisSpacing,
_padding = padding;
int get crossAxisCount => _crossAxisCount;
set crossAxisCount(int value) {
if (_crossAxisCount != value) {
_crossAxisCount = value;
markNeedsLayout();
}
}
// 其他 getter/setter 省略...
// 记录每列的当前高度
final List<double> _columnHeights = [];
@override
void performLayout() {
// 初始化列高度(包含顶部内边距)
_columnHeights.clear();
for (int i = 0; i < _crossAxisCount; i++) {
_columnHeights.add(_padding.top);
}
// 计算每列的宽度
final crossAxisExtent = constraints.crossAxisExtent - _padding.left - _padding.right;
final childCrossAxisExtent = (crossAxisExtent - (_crossAxisCount - 1) * _crossAxisSpacing) / _crossAxisCount;
// 布局子组件
int index = firstChildIndex;
while (index < childCount) {
final child = childAt(index);
// 强制子组件宽度为列宽
final childConstraints = constraints.copyWith(
crossAxisExtent: childCrossAxisExtent,
minCrossAxisExtent: childCrossAxisExtent,
maxCrossAxisExtent: childCrossAxisExtent,
);
child.layout(childConstraints, parentUsesSize: true);
// 找到高度最小的列
int minColumnIndex = 0;
for (int i = 1; i < _crossAxisCount; i++) {
if (_columnHeights[i] < _columnHeights[minColumnIndex]) {
minColumnIndex = i;
}
}
// 计算子组件的位置
final childMainAxisOffset = _columnHeights[minColumnIndex];
final childCrossAxisOffset = _padding.left + minColumnIndex * (childCrossAxisExtent + _crossAxisSpacing);
// 设置子组件位置
setChildParentData(child, index, childMainAxisOffset, childCrossAxisOffset);
// 更新列高度
_columnHeights[minColumnIndex] = childMainAxisOffset + child.size.mainAxisExtent + _mainAxisSpacing;
index++;
}
// 计算整个 Sliver 的高度(最大列高度 + 底部内边距)
final maxColumnHeight = _columnHeights.reduce((a, b) => a > b ? a : b);
final mainAxisExtent = maxColumnHeight - _mainAxisSpacing + _padding.bottom;
// 设置 Sliver 布局范围
geometry = SliverGeometry(
scrollExtent: mainAxisExtent,
paintExtent: min(mainAxisExtent, constraints.remainingPaintExtent),
maxPaintExtent: mainAxisExtent,
);
}
// 设置子组件的位置数据
void setChildParentData(RenderBox child, int index, double mainAxisOffset, double crossAxisOffset) {
final parentData = child.parentData as SliverMultiBoxAdaptorParentData;
parentData.index = index;
parentData.layoutOffset = mainAxisOffset;
parentData.crossAxisOffset = crossAxisOffset;
}
}
// 演示页面
class WaterfallDemo extends StatelessWidget {
const WaterfallDemo({super.key});
// 随机生成列表项高度(100 ~ 300)
double _getRandomHeight() => 100 + (DateTime.now().microsecond % 200).toDouble();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('自定义瀑布流')),
body: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverWaterfallGrid(
crossAxisCount: 2, // 2列瀑布流
crossAxisSpacing: 8,
mainAxisSpacing: 8,
delegate: SliverChildBuilderDelegate(
(context, index) {
final height = _getRandomHeight();
return Container(
height: height,
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
'Item $index\nHeight: ${height.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.white, fontSize: 16),
textAlign: TextAlign.center,
),
);
},
childCount: 20,
),
),
),
],
),
);
}
}
关键技术点:
- 自定义
SliverWaterfallGrid继承SliverMultiBoxAdaptorWidget,遵循 Sliver 组件规范; RenderSliverWaterfallGrid重写performLayout方法,通过记录每列高度,实现子组件的动态布局;- 强制子组件宽度为列宽,确保布局整齐,同时支持自定义间距和内边距。
案例 5:Flutter 与开源鸿蒙滑动效果对比实现
背景说明:
开源鸿蒙(OpenHarmony)作为分布式操作系统,其 UI 框架提供了 ListContainer、GridContainer 等滚动组件,同时支持通过 ScrollController 和自定义布局实现复杂滑动效果。下面以"悬浮吸顶"为例,对比 Flutter 和开源鸿蒙的实现思路,并提供开源鸿蒙的对应代码。
1. Flutter 与开源鸿蒙核心组件对比
| 功能场景 | Flutter 组件 | 开源鸿蒙组件 |
|---|---|---|
| 基础滚动列表 | ListView/SliverList |
ListContainer |
| 网格布局 | GridView/SliverGrid |
GridContainer |
| 悬浮吸顶 | SliverPersistentHeader |
ListContainer + 自定义头部 |
| 滚动控制 | ScrollController |
ScrollController |
| 自定义滚动布局 | 自定义 Sliver 组件 |
自定义 LayoutManager |
2. 开源鸿蒙实现悬浮吸顶效果(ArkTS)
typescript
import router from '@ohos.router';
import { ScrollController, ScrollDirection } from '@ohos/ui';
@Entry
@Component
struct StickyHeaderDemo {
private scrollController: ScrollController = new ScrollController();
private categories: string[] = ['热门推荐', '手机数码', '电脑办公', '家居生活', '美妆护肤'];
private products: string[][] = [
['手机1', '电脑1', '耳机1', '手表1', '平板1'],
['手机2', '电脑2', '耳机2', '手表2', '平板2'],
['手机3', '电脑3', '耳机3', '手表3', '平板3'],
['手机4', '电脑4', '耳机4', '手表4', '平板4'],
['手机5', '电脑5', '耳机5', '手表5', '平板5'],
];
@State currentTabIndex: number = 0;
private columnHeights: number[] = []; // 记录每列高度
// 初始化列高度
private initColumnHeights() {
this.columnHeights = [];
for (let i = 0; i < this.categories.length; i++) {
this.columnHeights.push(60 + 50); // 搜索栏高度(60) + 分类标题高度(50)
}
}
// 滚动到指定分类
private scrollToCategory(index: number) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += this.columnHeights[i] + this.products[i].length * 50;
}
this.scrollController.scrollTo({
xOffset: 0,
yOffset: offset,
animation: true,
duration: 300
});
this.currentTabIndex = index;
}
build() {
Column() {
// 搜索栏(固定)
TextField()
.hintText('搜索商品...')
.padding(8)
.backgroundColor('#f5f5f5')
.borderRadius(20)
.margin(8)
.height(60);
// 吸顶标签栏
ListContainer()
.scrollDirection(ScrollDirection.Horizontal)
.items(this.categories.map((item, index) => {
return ListItem() {
Text(item)
.fontSize(16)
.fontWeight(this.currentTabIndex === index ? FontWeight.Bold : FontWeight.Normal)
.textColor(this.currentTabIndex === index ? '#007aff' : '#333333')
.padding({ left: 16, right: 16 })
.onClick(() => this.scrollToCategory(index));
};
}).toArray())
.height(50)
.backgroundColor('#ffffff')
.sticky(true); // 关键:设置吸顶
// 商品列表
ListContainer()
.controller(this.scrollController)
.onScroll((scrollOffset) => {
// 监听滚动,更新当前标签
let currentOffset = scrollOffset.yOffset;
let totalHeight = 0;
for (let i = 0; i < this.categories.length; i++) {
const categoryHeight = 50 + this.products[i].length * 50;
if (currentOffset >= totalHeight && currentOffset < totalHeight + categoryHeight) {
this.currentTabIndex = i;
break;
}
totalHeight += categoryHeight;
}
})
.items(this.categories.flatMap((category, catIndex) => {
return [
// 分类标题
ListItem() {
Text(category)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.padding({ left: 16, right: 16 })
.height(50);
},
// 商品列表项
...this.products[catIndex].map((product) => {
return ListItem() {
Text(product)
.fontSize(16)
.padding({ left: 16, right: 16 })
.height(50);
};
})
];
}))
}
.backgroundColor('#fafafa')
.fillParent();
}
}
关键差异与思考:
- 吸顶实现 :Flutter 通过
SliverPersistentHeader实现,开源鸿蒙直接通过ListContainer的sticky(true)属性支持,API 更简洁; - 滚动联动 :两者均通过
ScrollController监听滚动位置,但 Flutter 需手动计算每个分类的高度范围,开源鸿蒙的ListContainer对滚动事件的处理更轻量化; - 自定义布局 :Flutter 可通过自定义
Sliver组件实现复杂布局(如瀑布流),开源鸿蒙则通过LayoutManager扩展,两者均支持灵活的布局定制; - 跨端适配 :Flutter 的
CustomScrollView可直接运行在 Android、iOS、Web 等平台,而开源鸿蒙的组件仅适用于鸿蒙系统,但分布式特性更突出。

三、性能优化技巧
3.1 避免过度绘制
- 减少
Sliver组件的嵌套层级,避免不必要的Container包裹; - 使用
RepaintBoundary隔离频繁重绘的组件(如动画列表项),避免整个页面重绘。
3.2 懒加载优化
- 使用
SliverChildBuilderDelegate而非SliverChildListDelegate,实现子组件懒加载; - 对于长列表,结合
ListView.builder的cacheExtent属性,控制预加载范围。
3.3 动画性能
- 尽量使用
AnimatedBuilder而非setState控制动画,减少不必要的重建; - 动画效果优先使用
Transform、Opacity等无需重建布局的组件,避免LayoutBuilder频繁触发布局。
四、总结与扩展
4.1 核心总结
CustomScrollView 是 Flutter 中实现复杂滑动效果的核心组件,其通过 Sliver 体系整合多种滚动布局,支持悬浮吸顶、视差滚动、动画触发等高级效果。本文通过 5 个实战案例,从基础用法到自定义组件,完整覆盖了 CustomScrollView 的核心应用场景,并对比了开源鸿蒙的类似实现方案。
4.2 扩展方向
- 下拉刷新与上拉加载 :结合
RefreshIndicator和SliverChildBuilderDelegate,实现下拉刷新和上拉加载更多功能; - 分布式滑动同步:基于开源鸿蒙的分布式能力,实现多设备间的滑动状态同步(如手机和平板同步滚动位置);
- 复杂交互效果 :结合
GestureDetector实现滑动手势识别(如侧滑删除、滑动选择),提升交互体验。
4.3 学习建议
- 深入理解
Sliver组件的工作原理,掌握SliverPersistentHeader、SliverList等核心组件的属性配置; - 动手实践自定义
Sliver组件,提升对 Flutter 渲染机制的理解; - 对比不同平台(如 Flutter、开源鸿蒙、React Native)的滚动组件实现,形成跨端开发思维。
通过本文的学习,相信你已经能够灵活运用 CustomScrollView 实现各类高级滑动效果,并为跨端开发提供参考。建议结合实际项目需求,进一步探索 CustomScrollView 的更多可能性,打造流畅、有趣的用户交互体验。