Use [React17] [Vue3.0] to realize [waterfall layout]

Start with the previous rendering:

What is waterfall flow?

Waterfall flow is a common layout at the front end. The specific form is multi column layout, with fixed width and variable height. At the same time, as the page scrolls, new elements will be added to the lowest column at the end of the waterfall. There are three common waterfall flows:

  • Stream card:
    This style is common in mobile shopping apps, where only one item is displayed in a row. With the loading of the page, more content gradually appears, that is, the drop-down list we often call, which is the simplest waterfall flow:
  • Fixed waterfall flow:
    In this style, the image area size and height remain the same. The uniform height will make the whole interface look neat.
    PC Taobao homepage:

    Mobile Taobao page:
  • Staggered waterfall flow:
    Staggered waterfall flow is the most commonly used layout format for the home pages of major apps at present, and it is also the most visually comfortable layout for users:

Several common layout methods to realize waterfall flow

At present, the common waterfall flow open source plug-ins in the market mainly adopt the following layout schemes:

  • Absolute positioning: this layout needs to know the width and height of the picture before rendering, so that it can be inserted into the column with the lowest height every time the picture is inserted.
  • Flex layout: this layout also inserts new pictures into the column with the lowest height, but not through absolute positioning.

Hands on practice

Since it is unrealistic to let the background interface directly return the width and height of the picture. Therefore, the waterfall flow in this practice adopts the scheme of Flex layout.

The process of realizing waterfall flow with Flex layout

  • Step 1: specify the number of columns of waterfall flow, assuming x
  • Step 2: take out the first x elements from the waterfall flow picture interface list and render them to the first row of each column in turn
  • Step 3: start with the x+1 element (this step is the essence)
    • First calculate the height of each column
    • Then add the current element to the column with the lowest height
  • Loop the third step until all elements are added to the waterfall flow

Necessary knowledge before practice

Because we need to know the height of each column before rendering the current picture, we need to ensure that the previous added picture has appeared on the page before adding the current picture to the waterfall stream. How do you know that the previous picture has been rendered successfully? And how to add a new picture after the previous picture is successfully rendered?
This involves the life cycle of Vue and React and the concept of IntersectionObserver API:

What is IntersectionObserver?

IntersectionObserver structure

The intersectionobserver can automatically monitor whether elements enter the visual area of the device without frequent calculation (for example, we often judge whether the scroll bar touches the bottom). Because the essence of visibility is that the target element and the viewport produce a cross area, this API is called "cross viewer".
Through the above description, in fact, the lazy loading of pictures we often mention can be realized by using the IntersectionAPI, which also eliminates the calculation of the distance from the scroll bar, which is the best policy~

let observerObj = new IntersectionObserver(callback, option);

Intersection is a constructor provided by the browser natively and receives two parameters:

  • Callback: callback function when visibility changes, including two parameters: changes and observer
  • option: configuration object (optional)
    The return value of the constructor is an instance of an observer. There are four methods in the instance:
  • observe: start listening for specific elements
  • unobserve: stop listening for specific elements
  • disconnect: turn off listening
  • takeRecords: returns an array of objects for all observation targets
    The first parameter changes of the callback function is an array, and each item of the array is an IntersectionObserverEntry object
IntersectionObserverEntry object

The IntersectionObserverEntry object is one of the important attributes of the tail append element we implement in this paper! The object contains the following attributes:

  • boundingClientRect: information about the rectangular area of the target element
  • intersectionRatio: the visible proportion of the target element, that is, the proportion of intersectionRect to boundingClientRect. 1 when fully visible, less than or equal to 0 when completely invisible
  • intersectionRect: information about the intersection area between the target element and the viewport (or root element)
  • isIntersecting: Boolean value, whether the target element intersects with the root node of the intersection observer (common)
  • isVisible: Boolean value, whether the target element is visible (this attribute is still in the experimental stage and is not recommended to be used in the production environment)
  • Rootboundaries: information about the amount of the rectangular area of the root element. The return value of the getBoundingClientRect() method. If there is no root element (i.e. scrolling directly relative to the viewport), null is returned
  • Target: the observed target element, which is a DOM node object (commonly used)
  • Time: the time when the visibility changes. It is a high-precision timestamp, in milliseconds
    In the waterfall flow to be implemented in this paper, the use of IntersectionObserver is as follows:
// Waterfall flow layout: take the top one of the data sources and add it to the column with the lowest waterfall flow height. Repeat the cycle after the picture is fully loaded
let observerObj = new IntersectionObserver(
    (entries) => {
        for (const entry of entries) {
            const { target, isIntersecting } = entry
            if (isIntersecting) {
                // Add next picture
                addPicture()
                // Cancel listening for currently loaded pictures
                observerObj.unobserve(target)
            }
        }
    },
    {
      rootMargin: '0px 0px 300px 0px', // Advance loading
    },
)
In Vue, nextTick is used to realize asynchronous addition

nextTick is a solution to call DOM asynchronously in Vue. It can ensure that we can render the previous image on the page before performing the next rendering. Personally, it is a bit similar to the onMounted hook function:

nextTick(() => {
        columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        // Add cross listener
        observerObj.observe(columnArray[columnArray.length - 1])
 })
In React, useEffect is used to add after rendering

In React, the execution time of useEffect is after the page is rendered. Therefore, we only need to introduce the function of monitoring whether the previous image is rendered and loading the next image into useEffect:

useEffect(() => {
    if (dataList.length > 0) {// Skip page initialization
        console.log('Add picture')
        addPicture()
    }
}, [hasGet])

Vue3.0 using flex to realize waterfall flow source code

<template>
    <!-- that 's ok -->
    <div class="flex-row">
        <!-- column -->
        <!-- There are four columns, and the elements in each column are filled separately -->
        <div class="flex-column" v-for="(item, index) in allColumnData" :key="index">
            <div class="flex-column-ele" v-for="(curItem, index) in item" :key="curItem.id">
                <img :src="curItem.imgUrl">
                <p>{{ curItem.desc }}</p>
            </div>
        </div>
    </div>
</template>

<script lang='ts' setup>
import { axios } from './server'
import { nextTick, reactive, ref, watch, onMounted } from 'vue'
type waterFallItem = {
    id: number,
    imgUrl: string,
    desc: string
}
const columnCount = 4;
let data = await axios('./waterFall.json');
let allColumnData = reactive<waterFallItem[][]>(Array.from(new Array(4), () => new Array()));
for (let i = 0; i < data.length && i < columnCount; i++) {
    allColumnData[i].push(data[i]);
}

// Waterfall flow layout: take the top one of the data sources and add it to the column with the lowest waterfall flow height. Repeat the cycle after the picture is fully loaded
let observerObj = new IntersectionObserver(
    (entries) => {
        for (const entry of entries) {
            const { target, isIntersecting } = entry
            if (isIntersecting) {
                addPicture()
                observerObj.unobserve(target)
            }
        }
    },
    {
      rootMargin: '0px 0px 300px 0px', // Advance loading height
    },
)

let dataIndex = columnCount;
const addPicture = () => {
    if (dataIndex >= data.length) {
        alert('Picture loading completed')
        return
    }
    
    let columnArray: NodeListOf<HTMLElement> = document.querySelectorAll('.flex-column');
    let eleHeight = [];
    for (let i = 0; i < columnArray.length; i++) {
        eleHeight.push(columnArray[i].offsetHeight)
    }
    
    // Find the smallest one at a time
    let minEle = Math.min(...eleHeight)
    let index = eleHeight.indexOf(minEle)
    
    // Then add the next data element to the column with the lowest height above
    allColumnData[index].push(data[dataIndex++]);
    
    // To prevent rendering confusion, we need to wait for the element currently added to the lowest column to appear in the visual window before loading the next element
    nextTick(() => {
        columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        observerObj.observe(columnArray[columnArray.length - 1])
    })
}

onMounted(() => {
    addPicture();
})
</script>
 
<style lang = "less" scoped>
.flex-row {
    display: flex;
    flex-direction: row;
    width: 90vw;
    margin-left: 5vw;
    justify-content: space-around;
    align-items: flex-start;
}

// You can use the meta attribute to make a response. For example, if the screen width exceeds the width, 5 columns will be displayed, and if the screen width exceeds the width, 4 columns will be displayed
.flex-column {
    display: flex;
    flex-direction: column;
    width: 25%;
    margin: 10px;
}

.flex-column-ele {
    img {
        width: 100%;
        max-height: 500px;
        object-fit: contain;
    }

    padding: 5px;
    margin: 5px;
    background-color: #f8f5f5;
    border-radius: 5px;
    box-shadow: 2px 5px 5px 0px #f3f3f3;
}
</style>
// waterFall.json
[{
        "id": 0,
        "imgUrl": "https://qcloud.dpfile.com/pc/q7QsMdJq_DS7J4xCUgesjjeicLbUbAFCPHHb8mBoN9o4jyZZRObLs5ym-WtN-3N1G45IiB1YIyNuDTtqzVRwesm_qA1Pf8rFcayTY-n-rG8.jpg",
        "desc": "1 Fold up🔥The largest super discount store in Chengdu‼️Pick up the reason"
    },
    ...
    {
        "id": 10,
        "imgUrl": "https://qcloud.dpfile.com/pc/dHilnjl51w_qQEnsJ83shVOtIGNsQSgLBA8AUgWZrXeipuAflbCJKK6UI9lwcqKpwHHsQ-9MP97gy410T7ZcBMm_qA1Pf8rFcayTY-n-rG8.jpg",
        "desc": "Chengdu urban area|Subway direct free Hydrangea sea 🌸What a shock"
    }
]
// server.ts
type waterFallItem = {
    id: number,
    imgUrl: string,
    desc: string
}

export const axios = (url:string):Promise<waterFallItem[]> => {
    return new Promise((resolve) => {
        let xhr: XMLHttpRequest = new XMLHttpRequest()

        xhr.open('GET', url)

        xhr.onreadystatechange = () => {
            if(xhr.readyState === 4 && xhr.status === 200){
                setTimeout(() => {
                    resolve(JSON.parse(xhr.responseText))
                }, 500)
            }
        }

        xhr.send()
    })
}

React17 uses flex to realize waterfall flow source code

import {axios} from "./server";
import {useEffect, useReducer, useState} from "react";
import './index.scss'

interface WaterFallItem {
    id: number;
    imgUrl: string;
    desc: string;
}

export const WaterFall = () => {
    const columnCount = 4;
    const [dataList, setDataList] = useState<WaterFallItem[]>([]);
    const [hasGet, setHasGet] = useState(false)
    const [allColumnData, setAllColumnData] = useState<WaterFallItem[][]>(Array.from(new Array(4), () => new Array()))
    const [_, forceUpdate] = useReducer(x => x + 1, 0)
    const [dataIndex, setDataIndex] = useState(4);
    const getData = async () => {
        return await axios('./waterFall.json');
    }

    useEffect(() => {
        getData().then(data => setDataList(data))
    }, [])

    useEffect(() => { // useEffect is executed during page initialization and dependency change. There is no need to append pictures during page initialization
        if (dataList.length > 0) {
            initFirstRow()
        }
    }, [dataList])

    const initFirstRow = () => {
        let curData = allColumnData;
        for (let i = 0; i < dataList.length && i < columnCount; i++) {
            curData[i].push(dataList[i]);
        }
        setAllColumnData(curData)
        // Forced refresh is required here
        forceUpdate()
        setHasGet(prevState => !prevState)
    }

    useEffect(() => {
        if (dataList.length > 0) {// Skip page initialization
            addPicture()
        }
    }, [hasGet])

    const addPicture = () => {
        if (dataIndex >= dataList.length) {
            alert('Picture loading completed')
            return
        }
        let columnArray: NodeListOf<HTMLElement> = document.querySelectorAll('.flex-column');
        let eleHeight = [];
        for (let i = 0; i < columnArray.length; i++) {
            eleHeight.push(columnArray[i].offsetHeight)
        }
        // Find the smallest one at a time
        let minEle = Math.min(...eleHeight)
        let index = eleHeight.indexOf(minEle)
        // Then add the next data element to the column with the lowest height above
        let curData = allColumnData
        curData[index].push(dataList[dataIndex])
        setDataIndex(n => n + 1)
        setAllColumnData(curData)
        forceUpdate()
        startObserve(index)
    }

    const startObserve = (index: number) => {
        let columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        // Waterfall flow layout: take the top one of the data sources and add it to the column with the lowest waterfall flow height. Repeat the cycle after the picture is fully loaded
        let observerObj = new IntersectionObserver(
            (entries) => {
                for (const entry of entries) {
                    const {target, isIntersecting} = entry
                    if (isIntersecting) {
                        observerObj.unobserve(target)
                        setHasGet(prevState => !prevState)
                    }
                }
            }
        )
        observerObj.observe(columnArray[columnArray.length - 1])
    }

    return (
        <div>
            <div className={'flex-row'}>
                {
                    allColumnData.map((item, index) => (
                        <div className={'flex-column'} key={index}>
                            {
                                item.map((curItem) => (
                                    <div className={'flex-column-ele'} key={curItem.id}>
                                        <img src={curItem.imgUrl}/>
                                        <p>{curItem.desc}</p>
                                    </div>
                                ))
                            }
                        </div>
                    ))
                }
            </div>

        </div>
    );
}

summary

In this paper, a simple version of waterfall flow is completed by using flex layout, IntersectionObserver API and asynchronous loading DOM. Interested friends can continue to improve on the basis of the source code, and realize the infinite rolling waterfall flow through the combination of lazy loading and waterfall flow.
Some friends may have questions. Isn't waterfall flow an additional element one by one? Why do I need to combine lazy loading? This is because considering the efficiency problem, the background is likely to return data to the foreground in pages, while the waterfall flow only loads the current pages one by one, so it also needs to cooperate with lazy loading to request the data of each page.

Tags: Front-end React Vue.js

Posted by chefmars on Mon, 16 May 2022 22:57:08 +0300