Flutter - Play alternately waterfall flow video 🎞

LinXunFeng
9 min readNov 5, 2023

--

Overview

I have been busy developing a page recently, which needs to achieve the following effects:

  1. The page is a waterfall flow video list.
  2. After the page stops scrolling, the video item that reaches the red line is the hit item. Only the video of hit item can be played.
  3. If the original two items on the waterfall flow are hit after the page stops scrolling for the second time, the video of another hit item will be played alternately.
  4. There is a video PageView in the middle of the waterfall flow, turning pages to play video.
  5. Videos on the waterfall flow and videos on the PageView cannot be played at the same time.

The specific effect is shown in the figure above

Layout

Now let’s analyze the layout implementation of this page.

If you see unused variables in the following code, please ignore them. This part is to understand the overall layout, so some irrelevant codes have been replaced with ellipses.

1 ScrollView

There are multiple layouts in the ScrollView, so CustomScrollView is used here to combine various Sliver to build the ScrollView.

Widget _buildScrollView() {
return CustomScrollView(
slivers: [
// Banner(A simple SliverToBoxAdapter)
_buildBanner(),
_buildSeparator(8),
// The first waterfall built using SliverWaterfallFlow
_buildGridView(isFirst: true, childCount: 5),
_buildSeparator(8),
// The video PageView
_buildSwipeView(),
_buildSeparator(15),
// The second waterfall
_buildGridView(isFirst: false, childCount: 20),
],
);
}

2 Waterfall flow

Use third-party package to implement waterfall flow: https://github.com/fluttercandies/waterfall_flow

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,
),
);
}

Take a look at the core layout of 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),
),
],
);
}

If it is a hit item, the video view will be displayed, otherwise the cover will be displayed.

3 Video PageView

It will be explained later why SliverLayoutBuilder is used

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

Next, take a look at the build method in WaterfallFlowSwipeView

@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;
}

If it is a hit item, the video view will be displayed, otherwise nothing will be displayed.

Scrolling listener

The overall layout of the page has been finalized. Now we need to consider the most critical technical points on how to implement this function of alternate video play:

  1. Determine whether it is the waterfall flow or the video PageView that has reached the red line.
  2. After each ScrollView ends scrolling, if multiple items of the waterfall flow are on the red line, they need to be played alternately.

Here I use the scrollview_observer I developed: https://github.com/fluttercandies/flutter_scrollview_observer

Although there are some packages on pub.dev that can monitor the scrolling position of the ScrollView, they are very intrusive and not powerful enough to meet the above requirements, especially the second function point, which is simply a devilish requirement. However, using The package scrollview_observer I developed can easily cope with these two technical difficulties.

  • For difficulty 1: You only need to wrap the CustomScrollView with SliverViewObserver to observe it. This will return all the Sliver and item information in the visible area in a timely manner, and then use the leadingOffset parameter to set the observation offset, so you can easily determine which item of SliverWaterfallFlow reaches the red line, and then in the onObserveViewport callback, you can know whether the waterfall flow or the video PageView reaches the red line.
  • For difficulty 2: In the onObserveAll callback parameter of SliverViewObserver, you can get the information of all items hit in the waterfall flow at this time, and then through simple calculation, you can complete the function of alternate video play.

Next we enter the practical part.

Implement logic

We used CustomScrollView to implement the ScrollView part, which is divided into the upper and lower waterfall flow sliver, and a video PageView in the middle. After the ScrollView ends scrolling, you only need to do the following main logic:

  1. Which Sliver reaches the red line
  2. If it is a waterfall flow, get the index of the hit item, and then play the video on the item.
  3. If it is a video PageView, the video will be played based on the item currently being displayed.

First define the hit Sliver type, and use two properties to record the hit index and type.

enum WaterFlowHitType {
// The first waterfall
firstGrid,
// The video PageView
swipe,
// The second waterfal
secondGrid,
}

// The index of the hit item
int hitIndex = 0;
// The type of the hit item
WaterFlowHitType hitType = WaterFlowHitType.firstGrid;

1 Usage of SliverViewObserver

Use SliverViewObserver to wrap CustomScrollView for observation.

// The offset of the red line from the top of the viewport
double observeOffset = 150;

SliverViewObserver(
child: _buildBody(),
// Set the offset of the observation
leadingOffset: observeOffset,
// Set the timing to trigger observation, here is the end of scrolling
autoTriggerObserveTypes: const [
ObserverAutoTriggerObserveType.scrollEnd,
],
// Set the timing to trigger the callback to return the observation results.
// The results are returned directly here.
triggerOnObserveType: ObserverTriggerOnObserveType.directly,
extendedHandleObserve: (context) {
// Expand the original observation processing logic.
// The waterfall flow is built using a third-party package,
// so here you need to tell scrollview_observer how to observe it.
final _obj = ObserverUtils.findRenderObject(context);
if (_obj is RenderSliverWaterfallFlow) {
return ObserverCore.handleGridObserve(
context: context,
fetchLeadingOffset: () => observeOffset,
);
}
return null;
},
sliverContexts: () {
// Return the BuildContext corresponding to the target Sliver
return [
if (grid1Context != null) grid1Context!,
if (swipeContext != null) swipeContext!,
if (grid2Context != null) grid2Context!,
];
},
onObserveViewport: (result) {
// Observe the viewport, here you can know which Sliver is the first
...
},
onObserveAll: (resultMap) {
// Observe waterfall flow items
...
},
),

When onObserveViewport and onObserveAll exist at the same time, the onObserveViewport callback will be called first!

In the sliverContexts parameter callback, return the Sliver that needs to be observed. In this scenario, both the waterfall flow and the video PageView need to be observed, so their corresponding BuildContext is returned here. Accordingly, we need to record them first.

Records the BuildContext of waterfall flow

Widget _buildGridView({
bool isFirst = false,
required int childCount,
}) {
return SliverWaterfallFlow(
...
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
WaterFlowHitType selfType;
if (isFirst) {
// Record BuildContext
if (grid1Context != context) grid1Context = context;
// Own type
selfType = WaterFlowHitType.firstGrid;
} else {
if (grid2Context != context) grid2Context = context;
selfType = WaterFlowHitType.secondGrid;
}
// Waterfall flow item
return WaterfallFlowGridItemView(
selfIndex: index,
selfType: selfType,
hitIndex: hitIndex,
hitType: hitType,
);
},
...
),
);
}

Records the BuildContext of video PageView

Use SliverLayoutBuilder to get the BuildContext

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

2 Find the hit Sliver

Now let’s deal with the logic of observing the Viewport. By observing the Viewport, you can know which one is the first Sliver.

// Record Sliver reaching the red line.
BuildContext? firstChildCtxInViewport;

onObserveViewport: (result) {
// Record the first Sliver
firstChildCtxInViewport = result.firstChild.sliverContext;
if (firstChildCtxInViewport == grid1Context) {
// The first waterfall flow
if (WaterFlowHitType.firstGrid == hitType) return;
// Record hit type
hitType = WaterFlowHitType.firstGrid;
// Reset the index of the hit item, which will be used when assigning
// a value to hitIndex in the onObserveAll callback.
hitIndex = -1;
// For the waterfall flow view, We do not call setState here, only record
// the data, and refresh the ScrollView after updating the hit index
// in the onObserveAll callback.
} else if (firstChildCtxInViewport == swipeContext) {
// The video PageView
if (WaterFlowHitType.swipe == hitType) return;
// Refresh the ScrollView directly after updating hitType.
setState(() {
hitType = WaterFlowHitType.swipe;
});
} else if (firstChildCtxInViewport == grid2Context) {
// The second waterfall flow
// The processing logic is the same as the first waterfall flow
if (WaterFlowHitType.secondGrid == hitType) return;
hitType = WaterFlowHitType.secondGrid;
hitIndex = -1;
}
},

Through the above logic in onObserveViewport, we have determined which Sliver is currently hit and recorded it through the firstChildCtxInViewport property.

When the hit view is the video PageView, update the hitType to WaterFlowHitType.swipe and refresh the ScrollView to play the video in the video PageView.

@override
Widget build(BuildContext context) {
Widget resultWidget = PageView.builder(
...
itemBuilder: (context, index) {
// Determine whether the current item is hit
final isHit =
WaterFlowHitType.swipe == widget.hitType && currentIndex == index;
return Padding(
...
child: Container(
...
// Play video on hit
child: isHit ? _buildVideo() : const SizedBox.shrink(),
),
);
},
...
onPageChanged: (index) {
// If the index changes after turning the page, refresh the video PageView
if (currentIndex == index) return;
setState(() {
currentIndex = index;
});
},
);
...
return resultWidget;
}

At this time, the hitType is not a waterfall flow type, so all items on the waterfall flow will turn the video view into a cover.

Note: This example is a functional example, so the above is a simple view change processing through setState. In actual application scenarios, you can only refresh the ScrollView according to your own situation.

The play logic of the video PageView is completed, and the next step is to process the play logic of waterfall flow videos.

3 Handling waterfall hit item index

Through the onObserveAll callback, we can know which items are hit in the waterfall flow Sliver.

onObserveAll: (resultMap) {
// According to the BuildContext of the first recorded Sliver,
// take out the corresponding observation results
final result = resultMap[firstChildCtxInViewport];
if (firstChildCtxInViewport == grid1Context) {
// The first waterfall flow
// If the current first Sliver is not of the corresponding type,
// return it directly
if (WaterFlowHitType.firstGrid != hitType) return;
// Data type check
if (result == null || result is! GridViewObserveModel) return;
// Get the index of the hit item.
// there may be multiple hits at one time in the waterfall flow
final firstIndexList = result.firstGroupChildList.map((e) {
return e.index;
}).toList();
// Logic for processing waterfall hits
// since the processing logic in the second waterfall is the same,
// the processing logic is extracted into one method.
handleGridHitIndex(firstIndexList);
} else if (firstChildCtxInViewport == grid2Context) {
// The second waterfall flow
if (WaterFlowHitType.secondGrid != hitType) return;
if (result == null || result is! GridViewObserveModel) return;
final firstIndexList = result.firstGroupChildList.map((e) {
return e.index;
}).toList();
handleGridHitIndex(firstIndexList);
}
},

In the handleGridHitIndex method, the hit index is calculated and the ScrollView is refreshed.

/// Logic for handling waterfall hits
handleGridHitIndex(List<int> firstIndexList) {
if (firstIndexList.isEmpty) return;
// Find the index in the corresponding result array based on the last hit index.
int targetIndex = firstIndexList.indexOf(hitIndex);
if (targetIndex == -1) {
// If not found, set the targetIndex to 0
targetIndex = 0;
} else {
// If found, take the next index
targetIndex = targetIndex + 1;
if (targetIndex >= firstIndexList.length) {
// But if the index is out of range, set the targetIndex to 0
targetIndex = 0;
}
}
// Update hitIndex and refresh the ScrollView
setState(() {
hitIndex = firstIndexList[targetIndex];
});
}

At this point, the function we want is achieved.

Through the explanation of the above example, I believe you will have a clearer understanding of the use of scrollview_observer. If you also find this package useful, please give it a star 👍

GitHub: https://github.com/fluttercandies/flutter_scrollview_observer

Author: https://github.com/LinXunFeng

Series

--

--