Flutter - 瀑布流交替播放视频 🎞
# 一、概述
最近忙的一个需求,是要做到如下效果:
- 瀑布流影音
- 用户滑动结束后,到达红色线处的为命中的视图,仅命中的视图才可进行影音播放
- 如果第二次滑动后命中的还是瀑布流上的原来两个
item
,则交替播放影音 - 瀑布流的中间会存在一个影音栏目模块,翻页播放视频
- 瀑布流与影音栏目的播放不可共存
具体效果如上图所示
# 二、布局
上图可以看到了这个需求的整体效果,下面我们来分析一下这个页面的布局实现
下面的代码如看到没有使用到的变量,请先忽略,这部分是了解整体的布局情况,所以把一些不相关的代码以
...
代替了
# 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、瀑布流
使用第三方库实现瀑布流:https://github.com/fluttercandies/waterfall_flow (opens new window)
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
方法
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
,则展示影音视图,否则什么都不展示。
# 三、滚动监听
页面的整体布局已经搞定了,现在要考虑如何实现这个功能最最关键的几个技术难点:
- 判断当前到达红线的是瀑布流还是影音栏目。
- 当瀑布流的同一水平位置上的多个视图,在列表来回滑动时,轮流播放。
这里我使用了我开发的一个列表滚动辅助库 scrollview_observer
: https://github.com/LinXunFeng/flutter_scrollview_observer (opens new window),
虽然 pub.dev
上有可以监听列表滚动位置的库,但是侵入性很强,并且功能还不够强大,应付不来上面的需求,特别是第2个功能点,简直是魔鬼需求,然而,用我开发的库 scrollview_observer
就可以轻松应付这2个技术难点。
- 针对难点1: 只需要使用
SliverViewObserver
包裹CustomScrollView
就可以对其进行观察,这样会适时返回可视区域中所有的Sliver
和item
信息,再配合leadingOffset
参数设置观察的偏移量,即可轻松实现达到红线的需求,然后在onObserveViewport
回调中可以得知达到红线的是瀑布流还是影音栏目。 - 针对难点2: 在
SliverViewObserver
的onObserveAll
回调参数中可以拿到此时瀑布流中被命中的所有item
的信息,再通过简单的计算,即可完成轮流播放的功能。
接下来我们进入实战部分。
# 四、实现逻辑
我们上边使用 CustomScrollView
来实现滚动视图部分,其中分了上下两个瀑布流 Sliver
,中间一个栏目影音 Sliver
,在滚动视图结束滚动后,只需要做如下主要逻辑:
- 哪个
Sliver
到达了红线 - 如果是瀑布流,则获取命中的
item
下标,然后进行影音播放 - 如果是影音栏目,则按当前正在展示的
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
...
},
),
onObserveViewport
和onObserveAll
同时存在时,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
属性记录了起来。
当命中的是影音栏目时,则更新 hitType
为 WaterFlowHitType.swipe
并刷新页面,进行影音栏目的视频播放
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];
});
}
# 五、最后
通过上述示例的讲解,相信你对 scrollview_observer
的使用又更加清楚,如果你也觉得这个库好用,请不吝给个 Star
👍
GitHub: https://github.com/LinXunFeng/flutter_scrollview_observer (opens new window)
- 01
- Flutter - 危!3.24版本苹果审核被拒!11-13
- 02
- Flutter - 轻松搞定炫酷视差(Parallax)效果09-21
- 03
- Flutter - 轻松实现PageView卡片偏移效果09-08