Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考

文章目录

  • [Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考](#Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考)

Flutter 高级滑动效果实战:CustomScrollView 深度解析与开源鸿蒙跨端思考

在移动应用开发中,流畅且富有创意的滑动交互是提升用户体验的核心要素之一。Flutter 中的 CustomScrollView 作为灵活度极高的滑动组件,能够整合多种滚动效果(如列表、网格、悬浮吸顶、视差动画等),实现复杂的页面滚动逻辑;而开源鸿蒙(OpenHarmony)作为分布式操作系统,其滑动组件体系也有着独特的设计思路。本文将从 CustomScrollView 的核心原理出发,通过 5 个实战案例拆解高级滑动效果的实现逻辑,并对比开源鸿蒙的类似方案,为跨端开发提供参考。

一、CustomScrollView 核心原理与基础用法

1.1 为什么需要 CustomScrollView?

Flutter 中的 ListViewGridView 等组件本质上都是封装好的滚动组件,但它们存在一个局限:单一组件只能实现单一的滚动布局 。如果想要在同一个滚动视图中同时包含列表、网格、悬浮头部等多种元素,并且实现统一的滚动效果(如联动滚动、共同响应滑动事件),ListViewGridView 就难以满足需求。

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(变换动画)等。

1.3 基础案例:整合列表与网格

下面通过一个简单案例,展示如何用 CustomScrollView 整合 SliverListSliverGrid,实现"列表 + 网格"的统一滚动效果:

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, // 网格数量
              ),
            ),
          ),
        ],
      ),
    );
  }
}
核心亮点:
  • SliverAppBarfloatingpinned 属性实现了"滚动悬浮 + 展开折叠"效果;
  • SliverListSliverGrid 共享同一个滚动状态,滑动时无缝衔接;
  • 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;
  }
}

关键技术点:
  • SliverPersistentHeaderpinned: true 实现吸顶效果,delegate 自定义头部样式和高度;
  • ScrollController 监听滚动偏移量,通过计算每个分类的高度范围,实现标签与列表的联动;
  • 多个 SliverList 拼接实现分类列表的连续滚动。

案例 2:视差滚动(Parallax Scrolling)

需求场景:

实现图片视差效果------滚动时背景图片的滚动速度慢于前景内容,营造层次感(常见于 App 首页 Banner、详情页头部)。

实现思路:
  • 使用 SliverAppBarflexibleSpace 承载背景图片;
  • 通过 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 组件,通过 AnimationControllerAnimation 控制动画;
  • 利用 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 框架提供了 ListContainerGridContainer 等滚动组件,同时支持通过 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 实现,开源鸿蒙直接通过 ListContainersticky(true) 属性支持,API 更简洁;
  • 滚动联动 :两者均通过 ScrollController 监听滚动位置,但 Flutter 需手动计算每个分类的高度范围,开源鸿蒙的 ListContainer 对滚动事件的处理更轻量化;
  • 自定义布局 :Flutter 可通过自定义 Sliver 组件实现复杂布局(如瀑布流),开源鸿蒙则通过 LayoutManager 扩展,两者均支持灵活的布局定制;
  • 跨端适配 :Flutter 的 CustomScrollView 可直接运行在 Android、iOS、Web 等平台,而开源鸿蒙的组件仅适用于鸿蒙系统,但分布式特性更突出。

三、性能优化技巧

3.1 避免过度绘制

  • 减少 Sliver 组件的嵌套层级,避免不必要的 Container 包裹;
  • 使用 RepaintBoundary 隔离频繁重绘的组件(如动画列表项),避免整个页面重绘。

3.2 懒加载优化

  • 使用 SliverChildBuilderDelegate 而非 SliverChildListDelegate,实现子组件懒加载;
  • 对于长列表,结合 ListView.buildercacheExtent 属性,控制预加载范围。

3.3 动画性能

  • 尽量使用 AnimatedBuilder 而非 setState 控制动画,减少不必要的重建;
  • 动画效果优先使用 TransformOpacity 等无需重建布局的组件,避免 LayoutBuilder 频繁触发布局。

四、总结与扩展

4.1 核心总结

CustomScrollView 是 Flutter 中实现复杂滑动效果的核心组件,其通过 Sliver 体系整合多种滚动布局,支持悬浮吸顶、视差滚动、动画触发等高级效果。本文通过 5 个实战案例,从基础用法到自定义组件,完整覆盖了 CustomScrollView 的核心应用场景,并对比了开源鸿蒙的类似实现方案。

4.2 扩展方向

  • 下拉刷新与上拉加载 :结合 RefreshIndicatorSliverChildBuilderDelegate,实现下拉刷新和上拉加载更多功能;
  • 分布式滑动同步:基于开源鸿蒙的分布式能力,实现多设备间的滑动状态同步(如手机和平板同步滚动位置);
  • 复杂交互效果 :结合 GestureDetector 实现滑动手势识别(如侧滑删除、滑动选择),提升交互体验。

4.3 学习建议

  • 深入理解 Sliver 组件的工作原理,掌握 SliverPersistentHeaderSliverList 等核心组件的属性配置;
  • 动手实践自定义 Sliver 组件,提升对 Flutter 渲染机制的理解;
  • 对比不同平台(如 Flutter、开源鸿蒙、React Native)的滚动组件实现,形成跨端开发思维。

通过本文的学习,相信你已经能够灵活运用 CustomScrollView 实现各类高级滑动效果,并为跨端开发提供参考。建议结合实际项目需求,进一步探索 CustomScrollView 的更多可能性,打造流畅、有趣的用户交互体验。

相关推荐
song50113 小时前
鸿蒙 Flutter 图像编辑:原生图像处理与滤镜开发
图像处理·人工智能·分布式·flutter·华为·交互
●VON13 小时前
从零构建可扩展 Flutter 应用:v1.0 → v2.0 全代码详解 -《已适配开源鸿蒙》
学习·flutter·开源·openharmony·开源鸿蒙
心随雨下13 小时前
Flutter自适应布局部件(SafeArea 和 MediaQuery)总结
flutter·typescript
5008413 小时前
鸿蒙 Flutter 超级终端适配:多设备流转与状态无缝迁移
java·人工智能·flutter·华为·性能优化·wpf
帅气马战的账号113 小时前
OpenHarmony 与 Flutter 深度集成:分布式能力与跨端 UI 实战进阶
flutter
小a彤13 小时前
Flutter的核心优势
flutter
子春一13 小时前
Flutter 与 AI 融合开发实战:集成大模型、智能图像识别与端侧推理,打造下一代智能应用
人工智能·flutter
song50113 小时前
鸿蒙 Flutter 应用签名:证书配置与上架实战
人工智能·分布式·python·flutter·华为·开源鸿蒙
吃好喝好玩好睡好13 小时前
基于 Electron+Flutter 的跨平台桌面端实时屏幕标注与录屏工具深度实践
javascript·flutter·electron
微祎_13 小时前
Flutter 架构演进实战:从 MVC 到 Clean Architecture + Modular,打造可维护、可扩展、可测试的大型应用
flutter·架构·mvc