How to implement a FolderFlip 2

Update

2022-11-15: add the images to explain the steps


As we presented in the previous article, we have showed how to implement the FolderFlip with limited number (like 3) items with the position: sticky and IntersectionObserver.

The Problem

But it only allows limited number, if it comes more items or the screen is smaller, the items would not be able to scroll. So how would we display if the items are more and the titles take most of the screen.

Folder Flip with 6 items

Clear the logic first

If you want the final answer, just jump to here. Otherwises, I would explain the solutions and the procedures below, also some problems I met.

The idea is we only display a certain number of items in the screen, when the items' number reaches the limit with scrolling down/up, the next/previous one would float out to leave the room for the new item, which can be implemented by changing postion to relative like what we have done in the previous blog. So it's like the state transition. I call the state sticky before some item reaches the threshold, when it reaches, the whole component would transit to a state named float. And in the float state, the first item (according to the scroll direction) would be moved out of screen.

As we see in the graph,

  1. when the content3 hasn't reach 100% in the screen, the state should be position: sticky;
  2. If we scroll down and the content3 reaches threshold 100%, it changes to the state position: relative;
    1. If we scroll up, then it will go back the state position: sticky again
  3. If we keep scrolling down until it reaches the threshold 0% of content4, the state would be update to position: sticky
    1. scroll up would go back the state position: relative

And as we know, React is a declarative library, which means you just need to give the required state, React would render it for you anyway, you do not need to know how it's implemented. So it would be good to use state machine diagram to explain the different states and easy to convert the diagram to codes.

State machine diagram

Here are the diagram:

Folder Flip State machin diagram
State machine diagram

Define variables and states

We define some concepts first:

  1. We define a window here, which means the items shown in the screen, and we set it as 3 here;
  2. WS or windowStart is the start value of window and its value is START. The original value is 0 and betwee 0 and LENGTH - windowSize;
  3. edge element is the upper element which would be checked if it reaches threshold 100%
  4. showup element is the lower element which would be checked if it reaches threshold 0

And we can see the variables as well:

  1. reach100 -> boolean, indicates if the current observed edge element reach threshold 100%
  2. reach0 -> boolean, indicates if the current observed showup element reaches threshold 0
  3. edgeIndex -> indicates the observed edge element index in edge element array, the original value is windowStart + windowSize - 1
  4. showupIndex -> indicates the observed showup element index in showup element array, the original value is windowStart + windowSize
  5. sectionState -> STICKY or FLOAT, indicates the current state of component
  6. windowStart -> window start value, initial value is 0 and range is >= 0 and <= LENGTH - window size

According to the state machine diagram, there are three types of states:

  1. the normal state, it's normally stable (yellow one)
  2. the state triggered by user scroll behavior (pink one)
  3. the state should be updated internally (blue one)

We will handle each scenario which triggered and started by the scroll event which is solid line in the diagram. One entire process should be end to the normal state whose color is yellow. From the diagram, we can see one process should have three states, except two edge situations.

  1. The process is triggered by scrolling down, starting from the original state and transiting to the normal state directly, without scroll state (pink) and internal state (blue);
Start state scroll down
2. The process is triggered by scrolling up from the final normal state and transites to the normal state directly as well.
Final state scroll up

We need to handle these two situations separately.

With only two IntersectionObservers

Although there are 6 (for example) items in the list, actually we only need two active observers. One is for the edge element, the other for showup element, although these two elements are not fixed. So why wouldn't we just create two observers and update the observer's observed element dynamically to get the correct state.

As you see, it does work if we scroll showly and carefully. But if we swipe the page fast, something begins going wrong. Why? Because when we swipe too fast, the observer could not change to the correct element before observing the changing.

IntersectionObserver observes multiple elements???

OK, the solution with only two observers does not work. But actually one IntersectionObserver can observe multiple elements, like this:

 1// Create a new observer
 2let observer = new IntersectionObserver(function (entries) {
 3	entries.forEach(function (entry) {
 4		console.log(entry.target);
 5		console.log(entry.isIntersecting);
 6	});
 7});
 8
 9// The elements to observe
10let div1 = document.querySelector('#div-1');
11let div2 = document.querySelector('#div-2');
12
13// Attach them to the observer
14observer.observe(div1);
15observer.observe(div2);

so would using one observer on multiple elements save some resources?

The answer is no. Not just because there is no big difference, but also it does not work as we expect. According to this blog, only elements that have changed show up in the entries array. So if the element's state not changed, the state would not be in the parameters of observer's callback function, which means we cannot get the correct state of desired element.

So actually when we change the state by scrolling behavior or internal updating, we need the state of the observed element which can be saved in an array. When we change the observed element, we just read the state from that array.

Observer Array

So we need to setup an array for every type elment (edge, showup) which saves the value of if the elements reaches the threshold, 0 or 100%. Therefore, we have to create observer for each element. Would it effect the performance? Luckily the answer is also no. According to previous mentioned blog, there is no difference of using many observers with one element each.

A few years ago, there was a discussion about the performance implications of using this approach on the w3c GitHub repository for this specification.
The general conclusion was that using many observers with one element each and one observer with many elements should be about equally performant...

 1const [edgeElementIndex, setEdgeElementIndex] = useState(windowSize - 1);
 2const [showupElementIndex, setShowupElementIndex] = useState(windowSize);
 3
 4// save the edge and showup elements, it should be stable
 5const edgeElements = useMemo(
 6  () => [].slice.call(contentElements, windowSize - 1, stepLength),
 7  [contentElements, stepLength]
 8);
 9
10const showupElements = useMemo(
11  () => [].slice.call(contentElements, windowSize - 1, stepLength),
12  [contentElements, stepLength]
13);
14
15// save all the states of edge and showup elements in the array and
16// get their states update whenever observers are triggered, init values are false
17const [edgeStates, setEdgeStates] = useState([]);
18const [showupStates, setShowupStates] = useState([]);
19
20// initial the content elements
21useEffect(() => {
22  let tags = elementRef.current.getElementsByClassName("FolderFlip-Tag");
23  // get the height of the tag
24  setTagHeight(tags[0].getBoundingClientRect().height);
25  setContentElements(
26    elementRef.current.getElementsByClassName("FolderFlip-Content")
27  );
28}, []);
29
30// the callback function to handle when the folder reaches edge with scrolling down
31// keep updating the state according to observers no matter if the element's state is used
32const reachEdgeFunc = ([entry], index) => {
33  setEdgeStates((v) => {
34    let value = [...v];
35    value[index] = entry.isIntersecting || entry.boundingClientRect.top < 0;
36    return value;
37  });
38};
39
40const folderShowUpFunc = ([entry], index) => {
41  setShowupStates((v) => {
42    let value = [...v];
43    value[index] = entry.isIntersecting;
44    return value;
45  });
46};
47
48// set up the observer for edge element with threshold 100%
49useIntersection(edgeElements, reachEdgeFunc, {
50  threshold: 1.0
51});
52
53// set up the observer for showup element with threshold 0%
54useIntersection(showupElements, folderShowUpFunc, {
55  threshold: 0
56});

We use useMemo to save the edgeElements and showupElements to avoid re-rendering. And create arrays edgeStates and showupStates to save the states of all the elements. To get the correct observed element's state, we also need edgeIndex and showupIndex. When certain element reaches the threshold and triggers the callback function, it passes entry and the index in state array.

useIntersection needs to update as well:

 1function useIntersection(nodeElements, callbackFunc, options) {
 2  let observers = useMemo(() => {
 3    if (typeof IntersectionObserver === "undefined") return;
 4
 5    return nodeElements.map(
 6      (_, i) =>
 7        new IntersectionObserver(
 8          (entries) => {
 9            callbackFunc(entries, i);
10          },
11          {
12            threshold: options.threshold
13          }
14        )
15    );
16  }, [nodeElements, callbackFunc, options.threshold]);
17
18  useEffect(() => {
19    observers.forEach((observer, i) => {
20      if (nodeElements[i]) {
21        if (observer) observer.observe(nodeElements[i]);
22      }
23    });
24
25    return () => {
26      observers.forEach((observer) => {
27        if (observer) observer.disconnect();
28      });
29    };
30  }, [nodeElements, observers]);
31}

But here comes another problem, it seems too many useState, and each of them hangs out with others, to handle the logic, it's better to put them in one function. The state transition could be processed there. How to implement this?

useReducer makes my day

The answer is useReducer in React.

reducer and dispatcher are the concepts from Redux, even though we do not use it here, but useReducer was introduced to React as well.

1const [state, dispatch] = useReducer(reducer, initialArg, init);

So we can handle all the variables in the reducer and update the UI according to the returned state. Also use dispatch to send the state from observer's callback function.

 1// the callback function to handle when the folder reaches edge with scrolling down
 2// keep updating the state according to observers no matter if the element's state is used
 3const reachEdgeFunc = useCallback(
 4  ([entry], index) =>
 5    dispatchFunc({
 6      type: REDUCER_TYPE.edge,
 7      payload: {
 8        index,
 9        value: entry.isIntersecting || entry.boundingClientRect.top < 0
10      }
11    }),
12  [dispatchFunc]
13);
14
15const folderShowUpFunc = useCallback(
16  ([entry], index) =>
17    dispatchFunc({
18      type: REDUCER_TYPE.showup,
19      payload: { index, value: entry.isIntersecting }
20    }),
21  [dispatchFunc]
22);

Here we use useCallback to avoid re-rendering in hooks useIntersection. And the payload contains the element index and the state of element.

The reducer takes state and action as parameters and should be pure, which means with the same input, the output should also not change. Note: Within StrictMode of React, the reducer would be called twice with same value.

 1function FolderFlipReducer(state, action) {
 2  if (!action) return state;
 3
 4  // set the value of edge state with given index
 5  if (action.type === REDUCER_TYPE.edge) {
 6    state.edgeStates[action.payload.index] = action.payload.value;
 7  } else if (action.type === REDUCER_TYPE.showup) {
 8    state.showupStates[action.payload.index] = action.payload.value;
 9  }
10
11  let edgeIndex = state.edgeIndex,
12    showupIndex = state.showupIndex,
13    sectionState = state.sectionState,
14    windowStart = state.windowStart;
15
16  const reach0 = state.showupStates[state.showupIndex - windowSize + 1],
17    reach100 = state.edgeStates[state.edgeIndex - windowSize + 1];
18
19  // if the prev state is initial stable state, just update the section state
20  if (!reach0 && reach100 && edgeIndex + 1 === showupIndex) {
21    sectionState = SECTION_STATE.float;
22    return {
23      ...state,
24      sectionState
25    };
26  }
27
28  // if the prev state is final stable state
29  if (reach0 && !reach100 && edgeIndex === showupIndex) {
30    sectionState = SECTION_STATE.sticky;
31    return {
32      ...state,
33      sectionState
34    };
35  }
36
37  // all the other four situations would need to be handled under state type scroll
38  // handle the pink ones in state machine diagram
39
40  // if edge and showup observed elements are the same, set the state as float
41  if (edgeIndex === showupIndex) sectionState = SECTION_STATE.float;
42
43  // otherwises, sticky
44  if (edgeIndex + 1 === showupIndex) sectionState = SECTION_STATE.sticky;
45
46  // need to update the edge and showup index in the internal state type
47
48  if (sectionState === SECTION_STATE.float) {
49    if (reach0 && reach100) {
50      if (windowStart + windowSize < state.stepLength)
51        showupIndex = windowStart + windowSize;
52    } else if (!reach0 && !reach100) {
53      windowStart = state.windowStart > 0 ? state.windowStart - 1 : 0;
54      edgeIndex = state.windowStart + windowSize - 2;
55    }
56  }
57
58  if (sectionState === SECTION_STATE.sticky) {
59    if (!reach0 && !reach100) {
60      if (windowStart > 0) showupIndex = windowStart + windowSize - 1;
61    } else if (reach0 && reach100) {
62      edgeIndex = windowStart + windowSize;
63      windowStart =
64        state.windowStart + windowSize < state.stepLength
65          ? state.windowStart + 1
66          : state.stepLength - windowSize;
67    }
68  }
69
70  return {
71    ...state,
72    windowStart,
73    edgeIndex,
74    showupIndex,
75    sectionState
76  };
77}

So til now, we have explained and presented the solution, the page works quite stable. Below is the full codes and welcome any comments.

comments powered by Disqus