Layout Animation: FLIP and Reparenting

Note: This article is automatically translated, please turn to the Chinese version for more accurate expression if possible.

When I was developing Kiwi recently, I encountered some problems with the front-end layout animation. It is quite interesting to study it in depth. The following two points are related to the animation transition when the layout changes.

The first is when an item displayed in the content bar changes, just put the new item to the end when adding, but if the previous item is removed, the item below will slide up, as shown in the following figure:

How can this effect be achieved? The most straightforward way is to traverse all the items below it every time an item is deleted, and add an animation to them, moving from the bottom to the top where they should go. But this operation is very inelegant, crosses the boundary of the component, and brings additional complexity. So is it possible to use a simpler way to make a component automatically start the animation to transition to a new position when its position changes?

FLIP operation

The difficulty with this requirement is that HTML does not provide built-in events that are triggered when an element moves. If we want to directly check whether an element has moved, we need to poll an element every once in a while and remember its previous position to determine whether its position has changed. This has two fatal disadvantages. The first is that the animation is not updated in time, and the element has moved to a new position, but suddenly flashes back to the original position and plays the animation; the second is that the performance is worrying, and everything is checked every polling interval. The position of the element.

The polling method does not work. We need to find the trigger from the source of the layout change. If JS directly manipulates the DOM, then we can check the new position when all elements may change. For example, like the two elements 1 and 2 in the above figure, assuming their position is AB, 1 is deleted, and the position of 2 is moved from A to B, then whenever an element is deleted, the elements will take the initiative Check your new position, 2 find that your position is A, which is different from the last position B, then play an animation to change your position from B to A, that is, the CSS transition property changes from $-(AB )$ to $0$.

This technique is called FLIP and it was first used inThis blogThe main steps are as follows:

  1. First: the initial state of the element(s) involved in the transition.
  2. Last: the final state of the element(s).
  3. Invert: here’s the fun bit. You figure out from the first and last how the element has changed, so – say – its width, height, opacity. Next you apply transforms and opacity changes to reverse, or invert, them. If the element has moved 90px down between First and Last, you would apply a transform of -90px in Y. This makes the elements appear as though they’re still in the First position but, crucially, they’re not.
  4. Play: switch on transitions for any of the properties you changed, and then remove the inversion changes. Because the element or elements are in their final position removing the transforms and opacities will ease them from their faux First position, out to the Last position.

There is actually a problem here. Since the DOM has changed, we actually read the animation to the new position first and then execute the animation. Why don’t we see the element jump to the new position first and then flash back to play the animation? This is because the location update occurs during the execution of our user script. After we delete element 1, if we try to passgetBoundingClientRect This type of API to get the position of element 2 will trigger a browser’sreflow , Recalculate the position of all DOM elements and return, so that rendering will not be performed until the end of our synchronization script execution. Therefore, there will be no element jumping back and forth.

How to determine whether an element is deleted in React? Use the one that is triggered synchronously at the end of the DOM operationuseLayoutEffect Hook is enough. After all, we don’t care how many times we check, as long as we set up the animation when the position changes.

Reparenting

The above solution works fine under the layout of one content bar, but a new problem appears under the layout of multiple content bars (waterfall). This problem is related to React.

Let me first talk about how the multi-column layout works. The laziest implementation is naturally to put the stream with the shortest length when a new entry comes, and no other operations will be done later, but this will make the length of several streams very unbalanced after several operations of closing the entry. Therefore, every time there is an item change, the flow layout will be recalculated according to the following process:

  1. Maintain a list of open entries, push when adding entries, and splice when removing entries
  2. Assign a list to each content stream
  3. Traverse the list of entries, and put the current entry into the stream with the shortest length each time
  4. Update the length of this stream

When rendering, each stream corresponds to a div, which contains all the elements of the current stream.

For example, if the above picture is the content of the waterfall at a certain time, if item 6 is deleted, and the layout recalculated through the above process is as follows:

At this point, the expected animation direction is as follows:

However, it was not actually played. This is because the code structure is a React component for each stream. Inside the stream is a list of items. After we re-render, React’s virtual DOM diff algorithm cannot identify the internal element movement process in the above figure. , So the previous item component will be destroyed and one will be reconstructed. This causes the “previous position” memorized in item 7 to be cleared, and the position change cannot be detected and the animation is played. This question has one in the React repositoryissue It has been discussed that it will cause serious problems when creating a component is expensive or needs to remember the state. There is currently no official solution to this problem, but fortunately someone has implemented a libraryreact-reparenting To solve this problem.

Let’s break away from this library and think about the solution. able to pass Portal To do this, render all the entries into a Portal list, and then put them into the corresponding stream, so that the Portal will only be constructed and destructed when the element is actually created and closed. But this solution is contrary to the structured design of React itself. Another solution is to manually adjust the virtual DOM node inside React before rendering to trick React into thinking that this element has not changed, and naturally it will not be re-constructed. This requires changes to the internal data structure of React, but it is a more conceptually complete implementation with minimal impact on the existing code, and it is also the implementation adopted by react-reparenting mentioned above. The downside is that it is an internal implementation. If React makes changes to this structure, it does not need to update the semver version of React. Therefore, there is a possibility that the code may stop working if only updating the minor version of React (without introducing API changes). Therefore, the best solution is for React to officially include this implementation.

What exactly do we need to do? When the code causes a component to be transferred from one parent to another parent, we have to manually tell react-reparenting to change the virtual DOM (codenamefiber) Perform the corresponding transfer operation, so that it will not be noticed by the diff algorithm during the next rendering. Take the above picture as an example, that is

  • Flow 2 / entry 7 -> flow 3 second position
  • Flow 3 / entry 8 -> flow 2 third position

As for the deleted entry 6, it will be discovered and destroyed by the diff algorithm.

In this way, the layout problem is completely solved.