Flutter - 瀑布流交替播放视频 🎞

5,643 阅读9分钟

欢迎关注微信公众号:FSA全栈行动 👋

系列文章

开源库: flutter_scrollview_observer

  1. Flutter - 获取ListView当前正在显示的Widget信息
  2. Flutter - 列表滚动定位超强辅助库,墙裂推荐!🔥
  3. Flutter - 快速实现聊天会话列表的效果,完美💯
  4. Flutter - 船新升级😱支持观察第三方构建的滚动视图💪
  5. Flutter - 瀑布流交替播放视频 🎞
  6. Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
  7. Flutter - 滚动视图中的表单防遮挡 🗒
  8. Flutter - 秒杀1/2曝光统计 📊
  9. Flutter - 如何快速搓一个微信通讯录列表(azlist) 📓
  10. Flutter - 支持观察NestedScrollView,兼容性更强 😈

一、概述

最近忙的一个需求,是要做到如下效果:

  1. 瀑布流影音
  2. 用户滑动结束后,到达红色线处的为命中的视图,仅命中的视图才可进行影音播放
  3. 如果第二次滑动后命中的还是瀑布流上的原来两个 item ,则交替播放影音
  4. 瀑布流的中间会存在一个影音栏目模块,翻页播放视频
  5. 瀑布流与影音栏目的播放不可共存

具体效果如上图所示

二、布局

上图可以看到了这个需求的整体效果,下面我们来分析一下这个页面的布局实现

下面的代码如看到没有使用到的变量,请先忽略,这部分是了解整体的布局情况,所以把一些不相关的代码以 ... 代替了

1、滚动视图

滚动视图内有多种布局,所以这里使用 CustomScrollView 来组合各种 Sliver 的方式去搭建滚动视图

Widget _buildScrollView() {
  return CustomScrollView(
    slivers: [
      // Banner
      _buildBanner(),
      _buildSeparator(8),
      // 第一个瀑布流
      _buildGridView(isFirst: true, childCount: 5),
      _buildSeparator(8),
      // 影音栏目
      _buildSwipeView(),
      _buildSeparator(15),
      // 第二个瀑布流
      _buildGridView(isFirst: false, childCount: 20),
    ],
  );
}
Sliver说明
Banner一个简单的 SliverToBoxAdapter
瀑布流1使用 SliverWaterfallFlow 构建的瀑布流
影音栏目使用 PageView 构建的翻页视图
瀑布流2同 瀑布流1

2、瀑布流

使用第三方库实现瀑布流:github.com/fluttercand…

import 'package:waterfall_flow/waterfall_flow.dart';

Widget _buildGridView({
  bool isFirst = false,
  required int childCount,
}) {
  return SliverWaterfallFlow(
    gridDelegate: const SliverWaterfallFlowDelegateWithFixedCrossAxisCount(
      crossAxisCount: 2,
      mainAxisSpacing: 15,
      crossAxisSpacing: 10,
    ),
    delegate: SliverChildBuilderDelegate(
      (BuildContext context, int index) {
        ...
        return WaterfallFlowGridItemView(...);
      },
      childCount: childCount,
    ),
  );
}

看一下 WaterfallFlowGridItemView 的核心布局

Widget _buildBody() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      isHit ? _buildVideo() : _buildCover(),
      const SizedBox(height: 10),
      Text('grid item $selfIndex'),
      SizedBox(
        height: 50.0 + 50.0 * (selfIndex % 2),
      ),
    ],
  );
}

如果是命中的item,则展示影音视图,否则展示封面

3、栏目影音

为什么用 SliverLayoutBuilder,后面会解释

Widget _buildSwipeView() {
  if (isRemoveSwipe) return const SliverToBoxAdapter(child: SizedBox());
  return SliverLayoutBuilder(
    builder: (context, _) {
      ...
      return SliverToBoxAdapter(
        child: WaterfallFlowSwipeView(...),
      );
    },
  );
}

接下来看一下 WaterfallFlowSwipeView 中的 build 方法

@override
Widget build(BuildContext context) {
  Widget resultWidget = PageView.builder(
    controller: pageController,
    padEnds: false,
    itemBuilder: (context, index) {
      final isHit = ...;
      return Padding(
        padding: const EdgeInsets.only(right: 10),
        child: Container(
          color: Colors.blue,
          child: isHit ? _buildVideo() : const SizedBox.shrink(),
        ),
      );
    },
    itemCount: 4,
    onPageChanged: (index) {
      if (currentIndex == index) return;
      setState(() {
        currentIndex = index;
      });
    },
  );
  resultWidget = SizedBox(height: 200, child: resultWidget);
  return resultWidget;
}

如果是命中的item,则展示影音视图,否则什么都不展示。

三、滚动监听

页面的整体布局已经搞定了,现在要考虑如何实现这个功能最最关键的几个技术难点:

  1. 判断当前到达红线的是瀑布流还是影音栏目。
  2. 当瀑布流的同一水平位置上的多个视图,在列表来回滑动时,轮流播放。

这里我使用了我开发的一个列表滚动辅助库 scrollview_observer: github.com/LinXunFeng/…

虽然 pub.dev 上有可以监听列表滚动位置的库,但是侵入性很强,并且功能还不够强大,应付不来上面的需求,特别是第2个功能点,简直是魔鬼需求,然而,用我开发的库 scrollview_observer 就可以轻松应付这2个技术难点。

  • 针对难点1: 只需要使用 SliverViewObserver 包裹 CustomScrollView 就可以对其进行观察,这样会适时返回可视区域中所有的 Sliveritem 信息,再配合 leadingOffset 参数设置观察的偏移量,即可轻松实现达到红线的需求,然后在 onObserveViewport 回调中可以得知达到红线的是瀑布流还是影音栏目。
  • 针对难点2: 在 SliverViewObserveronObserveAll 回调参数中可以拿到此时瀑布流中被命中的所有 item 的信息,再通过简单的计算,即可完成轮流播放的功能。

接下来我们进入实战部分。

四、实现逻辑

我们上边使用 CustomScrollView 来实现滚动视图部分,其中分了上下两个瀑布流 Sliver,中间一个栏目影音 Sliver,在滚动视图结束滚动后,只需要做如下主要逻辑:

  1. 哪个 Sliver 到达了红线
  2. 如果是瀑布流,则获取命中的 item 下标,然后进行影音播放
  3. 如果是影音栏目,则按当前正在展示的 item 进行影音播放

先定义命中的 Sliver 类型,并使用两个属性来记录命中的下标和类型

enum WaterFlowHitType {
  // 第一个瀑布流
  firstGrid,
  // 影音栏目
  swipe,
  // 第二个瀑布流
  secondGrid,
}

// 命中的下标
int hitIndex = 0;
// 命中的类型
WaterFlowHitType hitType = WaterFlowHitType.firstGrid;

1、SliverViewObserver 的使用

使用 SliverViewObserver 包裹 CustomScrollView 来进行观察。

// 红线距离滚动视图顶部的偏移量
double observeOffset = 150;

SliverViewObserver(
  child: _buildBody(),
  // 设置观察的偏移量
  leadingOffset: observeOffset,
  // 设置触发观察的时机,这里为滚动结束
  autoTriggerObserveTypes: const [
    ObserverAutoTriggerObserveType.scrollEnd,
  ],
  // 设置触发返回观察结果回调的时机,这里直接返回结果
  triggerOnObserveType: ObserverTriggerOnObserveType.directly,
  extendedHandleObserve: (context) {
    // 拓展原来的观察处理逻辑,瀑布流是使用第三方库构建的,
    // 所以在这里需要告知 scrollview_observer 如何去观察它。
    final _obj = ObserverUtils.findRenderObject(context);
    if (_obj is RenderSliverWaterfallFlow) {
      return ObserverCore.handleGridObserve(
        context: context,
        fetchLeadingOffset: () => observeOffset,
      );
    }
    return null;
  },
  sliverContexts: () {
    // 返回目标 Sliver 对应的 BuildContext
    return [
      if (grid1Context != null) grid1Context!,
      if (swipeContext != null) swipeContext!,
      if (grid2Context != null) grid2Context!,
    ];
  },
  onObserveViewport: (result) {
    // 观察 viewport,在这里可以知道第一个 Sliver 是哪个
    ...
  },
  onObserveAll: (resultMap) {
    // 观察瀑布流的 item
    ...
  },
),

onObserveViewportonObserveAll 同时存在时,onObserveViewport 回调先走!

sliverContexts 参数回调中,返回需要被观察的 Sliver,在这个场景下,瀑布流和影音栏目都需要被观察,所以这里返回了他们相应的 BuildContext,相应的,我们就需要先把它们记录起来。

记录瀑布流的 BuildContext

Widget _buildGridView({
  bool isFirst = false,
  required int childCount,
}) {
  return SliverWaterfallFlow(
    ...
    delegate: SliverChildBuilderDelegate(
      (BuildContext context, int index) {
        WaterFlowHitType selfType;
        if (isFirst) {
          // 记录BuildContext
          if (grid1Context != context) grid1Context = context;
          // 自身的类型
          selfType = WaterFlowHitType.firstGrid;
        } else {
          if (grid2Context != context) grid2Context = context;
          selfType = WaterFlowHitType.secondGrid;
        }
        // 瀑布流 item
        return WaterfallFlowGridItemView(
          selfIndex: index,
          selfType: selfType,
          hitIndex: hitIndex,
          hitType: hitType,
        );
      },
      ...
    ),
  );
}

记录影音栏目的 BuildContext

实现 SliverLayoutBuilder 得到BuildContext

Widget _buildSwipeView() {
  return SliverLayoutBuilder(
    builder: (context, _) {
      // 记录BuildContext
      if (swipeContext != context) swipeContext = context;
      return SliverToBoxAdapter(
        child: WaterfallFlowSwipeView(hitType: hitType),
      );
    },
  );
}

2、找出命中的 Sliver

现在开始来处理观察 Viewport 的逻辑,通过观察 Viewport 就可以得知哪个是第一个 Sliver

// 记录到达红线的 Sliver
BuildContext? firstChildCtxInViewport;

onObserveViewport: (result) {
  // 记录第一个 Sliver
  firstChildCtxInViewport = result.firstChild.sliverContext;
  if (firstChildCtxInViewport == grid1Context) {
    // 第一个瀑布流
    if (WaterFlowHitType.firstGrid == hitType) return;
    // 记录命中类型
    hitType = WaterFlowHitType.firstGrid;
    // 重置命中的下标,在 onObserveAll 回调中给 hitIndex 赋值时会使用到
    hitIndex = -1;
    // 这里不调用 setState,只记录数据,在 onObserveAll 更新命中下标后再刷新页面
  } else if (firstChildCtxInViewport == swipeContext) {
    // 影音栏目
    if (WaterFlowHitType.swipe == hitType) return;
    // 直接刷新页面,播放影音栏目的视频
    setState(() {
      hitType = WaterFlowHitType.swipe;
    });
  } else if (firstChildCtxInViewport == grid2Context) {
    // 第二个瀑布流
    // 处理逻辑同第一个瀑布流
    if (WaterFlowHitType.secondGrid == hitType) return;
    hitType = WaterFlowHitType.secondGrid;
    hitIndex = -1;
  }
},

通过上述 onObserveViewport 中的逻辑,我们已经确定了当前命中的 Sliver 是哪个,并通过 firstChildCtxInViewport 属性记录了起来。

当命中的是影音栏目时,则更新 hitTypeWaterFlowHitType.swipe 并刷新页面,进行影音栏目的视频播放

@override
Widget build(BuildContext context) {
  Widget resultWidget = PageView.builder(
    ...
    itemBuilder: (context, index) {
      // 判断当前的 item 是否命中
      final isHit =
            WaterFlowHitType.swipe == widget.hitType && currentIndex == index;
      return Padding(
        ...
        child: Container(
          ...
          // 命中时播放视频
          child: isHit ? _buildVideo() : const SizedBox.shrink(),
        ),
      );
    },
    ...
    onPageChanged: (index) {
      // 翻页后如果下标发生变化,则刷新影音栏目视图
      if (currentIndex == index) return;
      setState(() {
        currentIndex = index;
      });
    },
  );
  ...
  return resultWidget;
}

此时 hitType 非瀑布流类型,所以瀑布流会将影音视图变成封面。

注:本例为功能示例,所以以上都是通过 setState 简单地进行视图变化处理,在实际应用场景中可根据自身的情况,实现局部刷新逻辑

影音栏目的播放逻辑就完成了,接下来就是处理瀑布流的影音。

3、处理瀑布流的命中 item 下标

通过 onObserveAll 观察 Sliver,通过它我们可以知道瀑布流 Slive 中哪些是命中的 item

onObserveAll: (resultMap) {
  // 根据记录的第一个 Sliver 的 BuildContext,取出相应的观察结果
  final result = resultMap[firstChildCtxInViewport];
  if (firstChildCtxInViewport == grid1Context) {
    // 第一个瀑布流
    // 如果当前的第一个 Sliver 不是相应的类型,则直接返回
    if (WaterFlowHitType.firstGrid != hitType) return;
    // 数据类型校验
    if (result == null || result is! GridViewObserveModel) return;
    // 取出命中的 item 的下标(瀑布流中一次命中的可能有多个)
    final firstIndexList = result.firstGroupChildList.map((e) {
      return e.index;
    }).toList();
    // 处理瀑布流命中的逻辑(由于第二个瀑布流中的处理逻辑相同,所以抽了方法)
    handleGridHitIndex(firstIndexList);
  } else if (firstChildCtxInViewport == grid2Context) {
    // 第二个瀑布流
    if (WaterFlowHitType.secondGrid != hitType) return;
    if (result == null || result is! GridViewObserveModel) return;
    final firstIndexList = result.firstGroupChildList.map((e) {
      return e.index;
    }).toList();
    handleGridHitIndex(firstIndexList);
  }
},

handleGridHitIndex 方法中计算得到命中下标,并刷新页面。

/// 处理瀑布流命中的逻辑
handleGridHitIndex(List<int> firstIndexList) {
  if (firstIndexList.isEmpty) return;
  // 根据上一次的命中下标,找出对应的结果数组中的下标
  int targetIndex = firstIndexList.indexOf(hitIndex);
  if (targetIndex == -1) {
    // 没找着,置为0
    targetIndex = 0;
  } else {
    // 找着了,则取下一个,为了实现交替播放
    targetIndex = targetIndex + 1;
    if (targetIndex >= firstIndexList.length) {
      // 但是如果超过了结果数组的最大下标,则置为0
      targetIndex = 0;
    }
  }
  // 更新 hitIndex 并刷新页面
  setState(() {
    hitIndex = firstIndexList[targetIndex];
  });
}

Demo链接:waterfall_flow_demo

五、最后

通过上述示例的讲解,相信你对 scrollview_observer 的使用又更加清楚,如果你也觉得这个库好用,请不吝给个 Star 👍

GitHub: github.com/LinXunFeng/…

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~