DEV Community

Anthony Tambrin
Anthony Tambrin

Posted on

Creating a Smooth Transitioning Dialog Component in React (Part 4/4)

Part 4: Final Refinements

In Part 3, I enhanced the DialogAnimation component to calculate both expanded and minimised dimensions. This approach ensured accurate and visually appealing transitions by using successive render cycles to measure the dimensions. However, it introduced complexity and potential performance issues, particularly causing jank or flicker during the dimension calculation process.

Introducing the Invisible Container

To tackle the flickering issue, I'm introducing a secondary, invisible container exclusively for dimension calculations. This concept is inspired by techniques used in game development, such as double buffering, blitting, or offscreen rendering. These techniques help manage rendering by performing calculations offscreen and then applying the final result to the visible screen.

Understanding Double Buffering, Blitting, and Offscreen Rendering

  • Double Buffering: In game development, double buffering involves using two buffers to hold frame data. While one buffer is displayed, the other is used to prepare the next frame. This reduces flickering and provides smoother visuals by ensuring that only fully rendered frames are shown to the user.
  • Blitting: Blitting (block transfer) is the process of transferring blocks of data (usually images) from one buffer to another. This technique is used to quickly update the screen with pre-rendered images, enhancing performance and visual stability.
  • Offscreen Rendering: Offscreen rendering involves rendering content to an offscreen buffer rather than directly to the screen. Once rendering is complete, the content is transferred to the visible screen in one operation. This prevents incomplete renders from being seen by the user and helps in managing complex animations or visual updates smoothly.

Why This Approach Could Be Worth Trying

  1. Eliminates Jank and Flicker: By calculating dimensions offscreen, we can avoid the visible jumps that occur during the transition. This makes the user experience smoother and more polished.
  2. Accurate Measurements: The invisible container can be manipulated freely without affecting the user's view, allowing for precise measurements using getBoundingClientRect.
  3. Cleaner UI Transitions: The final dimensions can be applied to the visible dialog in one go, ensuring a clean and seamless transition without intermediate visual states.

Although this approach does introduce some performance overhead by rendering the DOM twice, the trade-off can be worth it for the improved visual quality and user experience.

By implementing this approach, I aim to enhance the reliability and visual quality of the dialog animations, addressing the primary concerns identified in the previous parts.

Implementing the Invisible Container Approach

With this approach, I introduce significant changes to the DialogAnimation component to tackle the flickering issue observed in Part 3. Here's how it works.

Step 1: Context and Providers

First, I introduce a new context, DialogAnimationContext, and a provider, DialogAnimationProvider, to manage state specific to animation calculations.

const DialogAnimationContext = createContext();

export const useDialogAnimation = () => useContext(DialogAnimationContext);

export const DialogAnimationProvider = ({ children, isExpandedForCalculation }) => {
  return <DialogAnimationContext.Provider value={{ isExpandedForCalculation }}>{children}</DialogAnimationContext.Provider>;
};
Enter fullscreen mode Exit fullscreen mode

This setup allows me to manage the expanded state for dimension calculations separately from the visible dialog.

Step 2: Calculation Container

Next, I add a CalculationDialogContainer, a secondary invisible container used for offscreen calculations.

const CalculationDialogContainer = styled.div`
  position: fixed;
  bottom: 0;
  left: 0;
  opacity: 0;
  max-width: ${({ maxWidth }) => `${maxWidth}px`};
  pointer-events: none;
  user-select: none;
`;
Enter fullscreen mode Exit fullscreen mode

This container is fixed at the bottom of the screen, invisible, and non-interactive, ensuring it doesn't affect user interactions or layout.

Step 3: State Management

I introduce a new state variable, isExpandedForCalculation, to manage the expanded state for the calculation container.

const [isExpandedForCalculation, setIsExpandedForCalculation] = useState(isExpanded);
Enter fullscreen mode Exit fullscreen mode

This allows the calculation container to be expanded and minimised independently of the visible dialog.

Step 4: Calculation Logic

I shift the dimension calculation logic to the invisible container instead of the original container, using calculationContainerRef to reference it.

useEffect(() => {
  const container = calculationContainerRef?.current;

  switch (dimensionCheckState) {
    // expand
    case 1:
      startTransition(() => {
        setIsExpandedForCalculation(true);
        setDimensionCheckState(2);
      });
      break;

    ...
});
Enter fullscreen mode Exit fullscreen mode

This ensures dimensions are calculated in the invisible container, avoiding visual disruptions in the visible dialog.

Step 5: Minimise and Expand Functions

I introduce new functions, minimiseFn and expandFn, to handle the transition states smoothly. These custom functions override the default behavior in the DialogProvider.

const minimiseFn = useCallback(() => {
  setIsAnimating(false);
  setIsAnimatedExpanded(false);
  setDimensionCheckState(1);
}, []);

const expandFn = useCallback(() => {
  setIsAnimating(false);
  setIsAnimatedExpanded(true);
  setDimensionCheckState(1);
}, []);
Enter fullscreen mode Exit fullscreen mode

These functions ensure the dialog’s state transitions are managed seamlessly. Instead of directly changing the isExpanded variable, the header component now directly calls the minimise and expand functions. This change is reflected in the DialogHeader component:

export default function DialogHeader({ children, expandedTitle }) {
  const { dialogId, isExpanded, expand, minimise } = useDialog();

  return (
    <DialogHeaderComponent id={`${dialogId}_label`}>
      <ExpandedState isVisible={isExpanded}>
        <Title>{expandedTitle ?? children}</Title>
        <IconButtons>
          <IconButton icon="chevron-down" onClick={() => minimise()} />
        </IconButtons>
      </ExpandedState>
      <MinimizedState isVisible={!isExpanded} onClick={() => expand()}>
        <Title>{children}</Title>
        <IconButtons>
          <IconButton icon="chevron-up" />
        </IconButtons>
      </MinimizedState>
    </DialogHeaderComponent>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Children Wrapping and Invisible Container Rendering

Finally, I wrap the children in DialogAnimationProvider within the CalculationDialogContainer, rendering the secondary invisible container for dimension calculations.

return (
  <>
    <AnimatedDialogContainer ref={containerRef} dimensions={dimensions} isAnimating={isAnimating}>
      <FixedContainer dimensions={expandedDimensions} isAnimating={isAnimating}>
        {children}
      </FixedContainer>
    </AnimatedDialogContainer>
    <CalculationDialogContainer ref={calculationContainerRef} maxWidth={maxWidth}>
      <DialogAnimationProvider isExpandedForCalculation={isExpandedForCalculation}>{children}</DialogAnimationProvider>
    </CalculationDialogContainer>
  </>
);
Enter fullscreen mode Exit fullscreen mode

This setup ensures that the invisible container is used for all dimension calculations, improving the smoothness and reliability of the animation transitions.

Step 7: Adjusting DialogContainer

I wrap the children in DialogAnimationProvider within the CalculationDialogContainer. The CalculationDialogContainer is the secondary invisible container, used for offscreen calculations. By doing this, the children's expand/minimise state is affected by isExpandedForCalculation rather than isExpanded. Here’s how it works:

  • isExpanded: This state is used for the actual DOM when the dialog is visibly expanded or minimised.
  • isExpandedForCalculation: This state is used during the successive calculation cycle and only affects the DialogContainer when wrapped within DialogAnimationProvider.
export default function DialogContainer({ children }) {
  const { isExpanded } = useDialog();
  const dialogAnimation = useDialogAnimation();

  const isVisible = dialogAnimation?.isExpandedForCalculation ?? isExpanded;

  return (
    <DialogContainerComponent isVisible={isVisible}>
      {children}
    </DialogContainerComponent>
  );
}
Enter fullscreen mode Exit fullscreen mode

This ensures that the invisible container is used for all dimension calculations, improving the smoothness and reliability of the animation transitions.

Try the Demo!

Now that you have a detailed understanding of the improvements and changes made in Part 4, it's time to see the implementation in action. You can try out the demo to experience the smooth and reliable transitions of the dialog component.

Check out the live demo below or access the whole source code for this approach on CodeSandbox.

Pros and Cons of This Approach

Before wrapping up, let's dive into the pros and cons of using the invisible container approach compared to the previous implementation in Part 3.

Pros

  1. Eliminates Jank and Flicker: By performing dimension calculations in an invisible container, we avoid the visible jumps that occurred during transitions in Part 3, resulting in a smoother user experience.
  2. Accurate Measurements: Just like the approach from Part 3, it allows for precise dimension calculations using getBoundingClientRect, ensuring the dialog transitions to the exact size needed.
  3. Cleaner UI Transitions: The final dimensions are applied to the visible dialog in one go, ensuring a clean and seamless transition without intermediate visual states.
  4. Separation of Concerns: By separating the visual state (isExpanded) from the calculation state (isExpandedForCalculation), we manage the dialog's transitions more effectively.

Cons

  1. Increased Complexity: The introduction of an additional invisible container and context adds complexity to the codebase, making it harder to maintain.
  2. Performance Overhead: Rendering the dialog twice (once invisibly for calculations and once visibly) can introduce performance overhead, especially with frequent content changes.
  3. Initial Setup: The initial setup and understanding of this approach require more effort, as it involves additional state management and context usage.

While this approach addresses the primary concern of jank and flicker, it does come with trade-offs in terms of complexity and performance. However, the improved user experience and smooth transitions make it a compelling solution for creating a polished dialog component.

Conclusion and Next Steps

In Part 4, I introduced the invisible container approach to enhance the dialog's animation transitions, eliminating flicker and providing a smoother user experience. By performing dimension calculations offscreen and separating visual and calculation states, this approach addresses the primary concerns of the previous implementation.

Key Takeaways:

  • Eliminates Jank and Flicker: Offscreen calculations result in smoother transitions.
  • Accurate Measurements: Ensures precise dimension calculations.
  • Cleaner UI Transitions: Provides seamless visual transitions.
  • Increased Complexity: Adds complexity to the codebase.
  • Performance Overhead: Involves rendering the dialog twice.

Feel free to explore the code and interact with the demo to see how the invisible container approach effectively eliminates flickering and provides a polished user experience.

Thank you for following along this journey of refining the dialog component. I look forward to hearing your feedback and comments as they help in refining and improving the implementation further.

Top comments (0)