Author: Idle fish Technology — Cen Yu

background

Streaming layout, which is a popular page layout both front-end and Native. Especially for goods such as Feeds stream, whether taobao, JINGdong, Meituan, or Xianyu. Both are basically presented as cascades of multiple columns, with a fixed number of container columns, and then each card has a different height, creating an uneven multi-column layout.

For Native, no matter iOS or Android, CollectionView and RecyclerView can satisfy most of our scenes. However, at present, many business scenarios of Idle Fish are implemented on Flutter. At that time, Flutter officially only provides ListView and GridView implementations, but does not support waterfall streams.

Currently there are two open source solutions in the community, namely WaterFallFlow and FlutterStaggeredGridView. But there are some pain points that can’t be satisfied in the idle fish scene. In the former cannot support RecyclerView StaggeredGridLayoutManager setFullSpan such CARDS are mixed ability are the stripes across the full screen the ability, the latter without preset card ahead of the height of the more serious performance issues, And functionality issues with scrolling errors in multi-sliver scenarios. In the current xianyu business, whether it is the search results or the home page of the city, there will be mixed waterfall flow demand.

So we decided to in the reference RecyclerView StaggeredGridLayoutManager layout plans to implement a set of support common streaming CARDS and stripes across the full screen card mixed fluid layout, as shown in figure:

Principle analysis and layout process

Waterfall layout, like ListView and GridView, uses different strategies to size and position multiple cards, and then aligns them together to form a scrollable layout of more than one screen. So the whole layout strategy includes two processes, the first is to calculate the size of the card, the calculation results determine the size of the card in the rolling layout. The position of the card is then calculated, and the result determines the coordinates of the card in the scrolling layout. With the size and coordinates, you can complete the layout of the scrolling container. Below I will compare the layout strategy of GridView and FlowView, so that you can understand the details of the layout process.

Flutter in the grid layout the whole layout of the source code in the * * Flutter/lib/SRC/rendering/sliver_grid dart performLayout method in * *, we follow below source to analyze the whole layout process. Interested students can also be combined with the source code to eat this article, the flavor is better.

Grid layout

Dimensional calculation procedure

Let’s first analyze the card size calculation process of grid layout. This is a common GridView initialization parameter, and I have omitted some parameters that have nothing to do with sizing.

Gridview.count ({@required int crossAxisCount, double childAspectRatio = 1.0,})Copy the code

The parameters that affect the layout are crossAxisCount and childAspectRatio. With these two parameters, the size of the card is easy to calculate. First use crossAxisCount to divide the screen width to determine the width of the card, and then use childAspectRatio to calculate the height of the card. The card size of the grid layout can be determined. The calculation process is shown in the figure:

Position calculation process

On the side, because the number of cards in a scrolling container can be very large, it is impossible to lay out all the cards, and the memory and computation time are unacceptable. Only cards that are placed on the screen and in the cache are recycled. As the user swipes down, create and lay out the cards at the bottom of the screen, and recycle the cards that have been scratched out of the screen. The same thing happens when you slide up. So we will analyze the calculation of position from top to bottom and from bottom to top.

Let’s start by analyzing the layout process from top to bottom. For grid layouts, the width and height of each card can be calculated in advance before the position calculation process begins. Let’s call the upper-left corner of each card a layout coordinate, and let’s analyze how this coordinate is calculated in a grid layout.

Let’s calculate the ordinate first. Let’s divide crossAxisCount by the index of the card, and then multiply the result by the height of the card to get the ordinate of the card.

For the x-coordinate, we have bisected the screen width according to crossAxisCount, so the x-coordinate of each card is easy to obtain. We take the index of the card and mod crossAxisCount. This gives the order of the cards in a row. Then multiply by the width of the card, and you get the x-coordinate of the card.

For example, if the number of columns is 2 and the width and height of the card are 100, then the abscissa of the fourth card (index 3) is (3%2) ×100 is 1, and the ordinate is (3~/ 2) ×100 is 100, so the coordinate is (100,100).

The calculation process is shown in the figure:

The key source code of the entire layout is as follows:

/ / card size calculation final double usableCrossAxisExtent = constraints. CrossAxisExtent - crossAxisSpacing * (crossAxisCount - 1); final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio; SliverGridGeometry getGeometryForChildIndex(int index) {final double crossAxisStart = (index %) crossAxisCount) * crossAxisStride; Return SliverGridGeometry(scrollOffset: (index ~/ crossAxisCount) * mainAxisStride, crossAxisOffset: _getOffsetFromStartInCrossAxis(crossAxisStart), mainAxisExtent: childMainAxisExtent, crossAxisExtent: childCrossAxisExtent, ); } for (int index = indexOf(firstChild) -1; index >= firstIndex; --index) { final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index); / / get the size and location information final RenderBox child = insertAndLayoutLeadingChild (gridGeometry. GetBoxConstraints (constraints),); Final SliverGridParentData childParentData = child.parentData; childParentData.layoutOffset = gridGeometry.scrollOffset; / / card vertical axis coordinate assignment childParentData. CrossAxisOffset = gridGeometry. CrossAxisOffset; Assert (childparentData.index == index); assert(childparentData.index == index); trailingChildWithLayout ?? = child; trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset); }Copy the code

Thus, in the grid layout, there is a one-to-one correspondence between the position coordinates of each card and index. So either scrolling down to lay out the cards that follow, or scrolling up to lay out the cards that precede. Use this strategy to get the coordinates of all the cards.

Waterfall flow layout

Dimensional calculation procedure

Then we analyze the calculation process of the card size of the waterfall layout and deduce the initialization parameters that we need to pass in. First, we need to consider that there are two types of cards in the waterfall layout: normal cards whose width is evenly divided by the number of layout columns, and special cards whose width covers the entire screen, which we will later call horizontal cards. We will calculate the size of these two kinds of cards separately.

Ordinary card

First of all, for normal cards, the size and width of the card is determined by the number of columns and the width of the screen, just as for cards in grid layout, so we also need crossAxisCount. Once the width is determined, we need to determine the height of the card. In a waterfall layout, the height of each card is different, which is the biggest difference between a waterfall layout and a grid layout. So we can actually determine the height of each card, which means we don’t need to pass in parameters like childAspectRatio that affect the card during layout initialization. However, in actual business scenarios, special height Settings are usually carried out for cards in some special positions. For example, for the two cards above the horizontal bar cards in two streams, UED has the requirement to ensure the consistent position of the bottom of the two cards, otherwise, cracks between the cards will be caused and the appearance will be affected. So we need a method that defines a parameter mainAxisExtentBuilder.

typedef double IndexedMainAxisExtentBuilder(int index);
Copy the code

This is a method argument that returns a double value. The waterfall layout attempts to obtain the developer’s return value in this method based on index. If the return value is null, the card’s internal layout is used to determine the card’s height, and vice versa. The calculation process is shown in the figure:

The stripes card

The height determination process of the horizontal card is the same as that of ordinary cards, except that the width of the horizontal card is always the same as the width of the screen, and is not restricted by crossAxisCount. The calculation process is shown in the figure:

So we just need to be able to distinguish between these two types of cards in the layout process, and we can use different strategies to calculate their size. Similar to mainAxisExtentBuilder, we define an IndexedFullSpanBuilder parameter.

typedef bool IndexedFullSpanBuilder(int index);
Copy the code

This is a bool method parameter. The waterfall layout attempts to obtain the developer’s return value based on index. If the return value is null or false, the normal card width strategy is used; otherwise, the horizontal card width strategy is used.

So we have defined the three parameters that define the layout during waterfall layout initialization.

FlowView.count({
  @required int crossAxisCount,
  IndexedFullSpanBuilder fullSpanBuilder,
  IndexedMainAxisExtentBuilder mainAxisExtentBuilder,
})
Copy the code

Now that we can calculate the size of each card in the layout, we just need to determine the coordinates of the top left corner of the card, and then we can complete the layout of the card.

Position calculation process

For waterfall flow, the location calculation process is much more complex than grid layout, so let’s analyze the layout process from top to bottom. As mentioned earlier, there are two types of cards in the mixed cascade layout, horizontal and normal. We want the layout of the cards to have as few gaps as possible.

So for a normal card, the y-coordinate of the card looks like this. We need to find the card that has the lowest ordinate + card height (the bottom ordinate of the card). This card is called the lowest card. Then place the next card directly below the lowest card, so the next card’s ordinate is the lowest card’s ordinate + the height of the card. The layout should be directly below the lowest card, so the x-coordinate should be the same as the lowest card’s x-coordinate.

For the horizontal card, we only need to calculate its ordinate because it is always the same width as the screen width. Its abscissa is always 0, his ordinate and ordinary card just opposite, need to complete the layout of the card to search, find one of the ordinate + card height (namely card bottom ordinate) value of the largest card, we call this card is the highest card. Then place the horizontal card under the top card, otherwise the horizontal card will cover up the other cards. Here we generate a list of columns with an initial value of 0, adding the column offset to the card height for each card layout.

The calculation process is shown in the figure:

The cascading process is different from a GridView or a ListView, where the position of the top card can be determined by the position of the next card, and when we scroll up, we just place the card on top of the card, The GridView can do the calculation directly from index. Waterfall streams are special because the layout of the card depends on the layout of the card above it and cannot infer the layout of the previous card from the layout of the latter card. In this case, there are two general approaches.

  • Maintain a Map of index and crossAxisIndex

RecyclerView and WaterFallFlow currently work this way, as the user swips down, the layout is normal, and then records which column each card belongs to. Then, when the user swipes up, the card that is to be laid out is first obtained from the relational table in which column it belongs, and then placed above the uppermost card in this column, so that the card layout is always consistent for the user. However, in this way, it is necessary to do special treatment for the horizontal card in the mixed waterfall flow, because the card on the horizontal card is not necessarily close to the horizontal card in the layout, there may be gaps. Therefore, we also need to record the gap between the horizontal card and the next card, and add this gap to the layout, so as to ensure the correct layout.

  • Use the idea of pagination, always layout from top to bottom.

FlutterStaggeredGridView adopts this approach, and our implementation of mixed waterfall flow also uses this idea. We set a height PageSize for the entire waterfall flow layout, and then maintain a corresponding table for pageIndex and pageInfo, with each page recording its own mainAxisOffsets and firstChildIndex.

The mainAxisOffsets on the first page are obviously a list of length crossAxisCount with a value of 0. Then update the mainAxisOffsets as you lay them out from top to bottom. For example, if page 1 lays out the first plain card with height 100 in the first column, the mainAxisOffsets are updated to {100,0}. Then a second normal card of height 150 is laid out in the second column, and the mainAxisOffsets are updated to {100,150}. We then laid out a horizontal card with a height of 200 and the mainAxisOffsets were updated to {350,350}. There is then a gap of 50 between the horizontal card and the first card, and the mainAxisOffsets are the starting point for the next card layout. Then, when the mainAxisOffsets all exceed PageSize, we start to split the page. The initialOffsets on the next page are the mainAxisOffsets on the previous page, and then start the card layout on the second page.

So when we scroll up, when we need to lay out the last card, we start with the first card on the page that the card belongs to, so that the waterfall flow is always laid out from top to bottom. I can make sure the layout is correct.

Then we implemented a RenderSliverFlow in accordance with RenderSliverGrid. The key source code for the entire layout is as follows:

// SliverFlowGeometry getGeometryForChildIndex(int index,List<double> startOffsets) {bool isFullSpan = _getIsFullSpan(index); // Double maxOffset = starToffsets.reduce (math.max); // Double minOffset = starToffsets.reduce (math.min); // Double minOffset = StarToffsets.reduce (math.min); Var scrollOffset = minOffset; var crossAxisIndex = startOffsets.indexOf(minOffset); Int needCrossAxisCount = isFullSpan? crossAxisCount : 1; if(isFullSpan){ scrollOffset = maxOffset; crossAxisIndex = 0; } if (reverseCrossAxis) { crossAxisIndex = crossAxisCount - needCrossAxisCount - crossAxisIndex; } var crossAxisOffset = crossAxisIndex * crossAxisStride; var mainAxisExtent = _getChildMainAxisExtent(index); Return SliverFlowGeometry(scrollOffset: scrollOffset, // vertical crossAxisOffset: crossAxisOffset, // horizontal mainAxisExtent: mainAxisExtent, crossAxisExtent: crossAxisStride * needCrossAxisCount - crossAxisSpacing, isFullSpan: isFullSpan, crossAxisIndex: crossAxisIndex, ); }Copy the code

Memory reclamation and performance optimization

Recycling mechanism

As mentioned earlier, on the side, because the number of cards in a scrolling container can be very large, it is impossible to lay out and draw all the cards at once, and the memory and computation time are unacceptable.

We always want to lay out as few cards as possible, so let’s analyze which cards we can start laying out at the latest. As we know from the above, we paginated the whole waterfall stream, and each page contained multiple cards. We recorded the initial offsets of each page, so we needed to find the card at the top of the visible area, mark the position of this card as firstIndex, and then start the layout from the first card on the page to which this card belongs. Then we analyze when the layout ends, because the cards in front of us don’t depend on the cards behind us, so we can stop the layout outside of the visual area, and mark the position of the last card as lastIndex. Each layout produces a firstIndex and lastIndex.

As we slide down, we determine which Page firstIndex belongs to, which indicates that this Page is at the top, so we can recycle cards from previous pages. As we swipe up, we’ll just recycle all the cards after lastIndex.

Performance optimization

Although the paging mechanism is able to guarantee the correctness of the layout, but in fact, in many cases, we all need to cache area outside of the card layout, an extreme example, the first card is in the viewing area belongs to a particular page at the end of the card, this time we are going to have to put all the CARDS are carried on in this page layout. This actually affects the sliding performance a little bit. The initial design of PageSize is fixed to the height of one screen, with each screen divided into one page. Later, we optimized the performance to get a page value based on the height of most of the cascades, trying to ensure that each page contained as many cards as possible in a row. This way, the first card in the visible area is often the first card in the page, which reduces unnecessary layout.

We then ran performance tests on GridView and FlowView, using scripts to scroll down the two scroll containers five times and then five more times. Finally, we get the performance data, and then we mainly pay attention to two data, namely the maximum number of lost frames and the worst frame time, which are often the two data that most affect the sense of motion. By dynamically adjusting the pages based on the average card size height, the final performance data is as consistent as possible with the GridView. Using the same model, the performance test data are as follows:

Effect and landing

This is a current Demo project using FlowView that supports various features of the Flutter rolling system. ScrollController (scroll to offset), reverse (reverse order), scrollDirection (scrollDirection vertical or horizontal), etc.

In the Xianyu project, it mainly lands on the home page and search results page. Currently, however, the Home page of Flutter is only grayscale online.

Summary and Prospect

At present, the whole waterfall flow has been preliminarily landed with PowerScrollView. In the whole layout process, there are still some areas that can be expanded and optimized in terms of functions.

In terms of extensible functions, it is hoped that different column numbers can be mixed in a layout in the future. For example, there can be one column, two columns, three columns or even six columns in a Sliver, similar to GridLayoutManager in RecyclerView.

Then in terms of performance, hopefully you can optimize it later in the layout logic to minimize unnecessary calculations and layouts. Provides better motion in sliding.

I hope that the official will support such a commonly used layout, which can also bring ideas to the layout optimization behind.