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.
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,
- when the
content3
hasn't reach 100% in the screen, the state should beposition: sticky
; - If we scroll down and the
content3
reaches threshold100%
, it changes to the stateposition: relative
;- If we scroll up, then it will go back the state
position: sticky
again
- If we scroll up, then it will go back the state
- If we keep scrolling down until it reaches the threshold
0%
ofcontent4
, the state would be update toposition: sticky
- scroll up would go back the state
position: relative
- scroll up would go back the state
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:
Define variables and states
We define some concepts first:
- We define a window here, which means the items shown in the screen, and we set it as 3 here;
WS
orwindowStart
is the start value of window and its value isSTART
. The original value is 0 and betwee 0 andLENGTH - windowSize
;edge element
is the upper element which would be checked if it reaches threshold 100%showup element
is the lower element which would be checked if it reaches threshold 0
And we can see the variables as well:
reach100
-> boolean, indicates if the current observed edge element reach threshold 100%reach0
-> boolean, indicates if the current observed showup element reaches threshold 0edgeIndex
-> indicates the observed edge element index in edge element array, the original value iswindowStart + windowSize - 1
showupIndex
-> indicates the observed showup element index in showup element array, the original value iswindowStart + windowSize
sectionState
-> STICKY or FLOAT, indicates the current state of componentwindowStart
-> 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:
- the normal state, it's normally stable (yellow one)
- the state triggered by user scroll behavior (pink one)
- 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.
- The process is triggered by scrolling down, starting from the original state and transiting to the normal state directly, without
scroll
state (pink) andinternal
state (blue);
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.