Part 3: Improving Animation Reliability
In Part 2, I enhanced our dialog component by adding smooth animations for minimise and expand actions using max-width
and max-height
. This approach ensured the dialog dynamically adapted to its content, providing fluid and natural transitions. However, one key limitation was the assumption that minimised dimensions are zero, which caused the transition to not scale down smoothly and look less natural.
Also in Part 2, I added DialogAnimation
inside DialogContainer
to animate DialogBody
and DialogFooter
individually. For Part 3, I've unified the animation to affect both components at a higher level, inside the Dialog
component. This change simplifies the structure and eliminates the need for animate
props in DialogBody
and DialogFooter
in preparation to the improvement that I was planning to make from the last approach.
Here are the changes I've made:
// src/components/FluidDialog/Dialog.js
// The children are now wrapped within DialogAnimation
<DialogComponent
role="dialog"
aria-labelledby={`${dialogId}_label`}
aria-describedby={`${dialogId}_desc`}
ref={rootRef}
maxWidth={maxWidth}
>
<DialogAnimation>{children}</DialogAnimation>
</DialogComponent>
// src/components/FluidDialog/DialogContainer.js
// The DialogAnimation has been removed
export default function DialogContainer({ children }) {
const { isExpanded } = useDialog();
return <DialogContainerComponent isVisible={isExpanded}>{children}</DialogContainerComponent>;
}
I'm eager to hear your thoughts on this approach and whether you find it an improvement. Your feedback will be invaluable in refining this component.
Improvement From Last Approach
In the last approach, the assumption that minimised dimensions are zero (max-width: 0, max-height: 0
) caused the transition to not scale down smoothly. This is because the actual minimised dimensions are never zero, leading to the transition overcompensating and making the animation look less natural.
As an improvement, I'm going to calculate both expanded and minimised dimensions so that the DialogAnimation
component can use those dimensions to reliably transition between the two states.
What Changes
-
Calculate Both Expanded and Minimised Dimensions: The
DialogAnimation
component will now calculate dimensions for both expanded and minimised states. This is crucial for ensuring the animation transitions smoothly between the correct dimensions. -
Successive Render Cycles: To obtain accurate dimensions, the dialog needs to be expanded and minimised in successive render cycles. This allows the DOM element dimensions to be calculated using
getBoundingClientRect
.
What Remains
-
Fluid Height: The dialog still needs to hug the content correctly, so
max-width
andmax-height
need to be unset when the animation transition is not happening.
Step-by-Step Dimension Calculation
Here's the step-by-step process for calculating the dimensions (consider each numbered bullet represent the nth render cycle):
- Expand the Dialog: The dialog is expanded first to measure its full size.
-
Calculate and Store Dimensions: The dimensions are calculated using
getBoundingClientRect
and stored asexpandedDimensions
. - Minimise the Dialog: The dialog is then minimised to measure its compact size.
-
Calculate and Store Dimensions: The dimensions are recalculated and stored as
minimisedDimensions
. -
Prepare for Transition: The dialog is set to the correct state (either expanded or minimised) with
max-width
andmax-height
set to the current state dimensions, and thetransition
property unset. -
Animate the Transition: Finally,
max-width
andmax-height
are set to the target state dimensions with thetransition
property enabled, initiating the animation.
Rewriting and Improving DialogAnimation
Now that I'm armed with that strategy, I'm going to rewrite (probably most of) the DialogAnimation.js
code. I’ll explain it as I go, bear with me.
Imports and Constants
import { useState, useEffect, useRef, useTransition } from 'react';
import { styled } from 'styled-components';
import { useDialog } from './DialogContext';
const transitionSpeed = 0.3; // second
Nothing much changed here except that I've set a constant for the transition speed.
Component and State Initialization
export function DialogAnimation({ children }) {
const containerRef = useRef(null);
const { isExpanded, setIsExpanded, rootRef } = useDialog();
const [isAnimatedExpanded, setIsAnimatedExpanded] = useState(isExpanded);
const [_, startTransition] = useTransition();
const [isAnimating, setIsAnimating] = useState(false);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [minimisedDimensions, setMinimisedDimensions] = useState({
width: 0,
height: 0,
});
const [expandedDimensions, setExpandedDimensions] = useState({
width: 0,
height: 0,
});
const [dimensionCheckState, setDimensionCheckState] = useState(0);
I set up my state variables and refs. containerRef
points to the dialog container. isExpanded
and rootRef
come from useDialog
. I use state variables to track animation states and dimensions.
Here's a detailed breakdown of the variables and what they are used for:
-
containerRef
: A reference to the dialog container DOM element. Used to access and manipulate the DOM directly for dimension calculations. -
isExpanded
: Tracks if the dialog is expanded. -
rootRef
: A reference to the root element of the dialog. -
isAnimatedExpanded
: Tracks the animated state of the dialog separately fromisExpanded
. This helps manage the animation timing and ensures smooth transitions. -
isAnimating
: A boolean state to indicate if an animation is in progress. This is used to conditionally apply transition properties. -
dimensions
: Stores the current dimensions (width and height) of the dialog. This is dynamically updated during the animation process. -
minimisedDimensions
: Stores the dimensions when the dialog is minimised. Calculated and stored to ensure smooth transitions to this state. -
expandedDimensions
: Stores the dimensions when the dialog is expanded. Calculated and stored to ensure smooth transitions to this state. -
dimensionCheckState
: Manages the state of the dimension calculation process. This state machine controls the sequence of expanding, measuring, minimising, and animating the dialog.
A Quick Note on useTransition
The useTransition
hook in React is used to manage state transitions smoothly without blocking the UI. In DialogAnimation
, startTransition
is employed to handle state updates, supposedly ensuring that the dialog remains responsive during dimension calculations and animations. By marking updates as transitions, React prioritizes keeping the UI fluid and preventing rendering delays. For more details, check out the official useTransition documentation. (A colleague suggested this approach but I have yet benchmarked the performance. What do you think? Would it help in this case?)
First Effect Hook: Handling Expansion State Changes
useEffect(() => {
if (dimensionCheckState === 0 && isExpanded != isAnimatedExpanded) {
const container = rootRef?.current;
container.style.opacity = 0; // Make transparent to avoid flicker
setIsAnimating(false);
setIsAnimatedExpanded(isExpanded);
setDimensionCheckState(1);
}
}, [isExpanded, isAnimatedExpanded]);
When the expansion state changes, I make the dialog transparent to avoid flickering, then update my state to start calculating dimensions.
Main Effect Hook: Managing Dimension Calculation and Animation
useEffect(() => {
const container = rootRef?.current;
switch (dimensionCheckState) {
// Expand
case 1:
startTransition(() => {
setIsExpanded(true);
setDimensionCheckState(2);
});
break;
// Set expanded dimensions
case 2:
{
const { width, height } = container.getBoundingClientRect();
startTransition(() => {
setExpandedDimensions({ width, height });
setDimensionCheckState(3);
});
}
break;
// Minimise
case 3:
startTransition(() => {
setIsExpanded(false);
setDimensionCheckState(4);
});
break;
// Set minimised dimensions
case 4:
{
const { width, height } = container.getBoundingClientRect();
startTransition(() => {
setMinimisedDimensions({ width, height });
setIsExpanded(true);
setDimensionCheckState(5);
});
}
break;
// Prepare animation
case 5:
setIsAnimating(true);
setDimensions(
isAnimatedExpanded ? minimisedDimensions : expandedDimensions
);
setTimeout(() => {
startTransition(() => setDimensionCheckState(6));
});
break;
// Animate
case 6:
startTransition(() => {
setDimensions(
isAnimatedExpanded ? expandedDimensions : minimisedDimensions
);
setDimensionCheckState(0);
});
container.style.opacity = 1;
// Finalize animation state after transition
setTimeout(() => {
startTransition(() => {
setIsExpanded(isAnimatedExpanded);
setIsAnimating(false);
});
}, transitionSpeed * 1000);
break;
// Idle
default:
break;
}
}, [dimensionCheckState, startTransition]);
This is where the magic happens. The effect hook manages the entire animation lifecycle through a switch case:
- Expand the dialog.
- Measure and store expanded dimensions.
- Minimise the dialog.
- Measure and store minimised dimensions.
- Prepare for animation by setting current dimensions.
- Perform the animation and reset states.
Rendering the Animated Container
return (
<AnimatedDialogContainer
ref={containerRef}
dimensions={dimensions}
isAnimating={isAnimating}
>
<FixedContainer dimensions={expandedDimensions} isAnimating={isAnimating}>
{children}
</FixedContainer>
</AnimatedDialogContainer>
);
}
Here, I render the AnimatedDialogContainer
and FixedContainer
, passing the necessary props to manage dimensions and animation states.
Styled Components
const AnimatedDialogContainer = styled.div`
overflow: hidden;
transition: ${({ isAnimating }) =>
isAnimating
? `max-width ${transitionSpeed}s, max-height ${transitionSpeed}s`
: undefined};
max-width: ${({ dimensions, isAnimating }) =>
isAnimating ? `${dimensions.width}px` : undefined};
max-height: ${({ dimensions, isAnimating }) =>
isAnimating ? `${dimensions.height}px` : undefined};
`;
const FixedContainer = styled.div`
width: ${({ dimensions, isAnimating }) =>
isAnimating ? `${dimensions.width}px` : '100%'};
height: ${({ dimensions, isAnimating }) =>
isAnimating ? `${dimensions.height}px` : '100%'};
`;
-
AnimatedDialogContainer: Manages the transition properties based on whether an animation is happening. The
max-width
andmax-height
are unset when it's not animating so the dialog can hug thecontent correctly. - FixedContainer: Ensures the minimised content maintains its dimensions during the animation to avoid appearing squashed.
Try the Demo!
You can access the whole source code for this approach on CodeSandbox.
You can also see a live preview of the implementation below. Play around with the dynamic adaptability of the dialog and also pay close attention to the occasional (or frequent?) flickering jank when you minimise and expand the dialog.
Pros and Cons of This Approach
Before we wrap up, let's dive into the pros and cons of this approach compared to the one in Part 2.
Pros
- Accurate Transitions: By calculating both expanded and minimised dimensions, this approach ensures the dialog transitions to the exact right size, making the animation smooth and visually appealing.
-
Clean Structure: Wrapping the
DialogAnimation
around the children of theDialog
component simplifies the code structure, eliminating the need for individual animate props inDialogBody
andDialogFooter
. - Dynamic Adaptability: The dialog adapts to content changes more reliably, as dimensions are recalculated during specific render cycles.
Cons
- Increased Complexity: Managing state transitions and dimension calculations in multiple steps adds complexity to the codebase.
- Performance Overhead: Expanding and minimising the dialog in successive render cycles could introduce performance overhead, particularly with frequent (complex) content changes.
-
Jank/Flicker During Calculation: The biggest drawback is the introduction of jank or flicker when calculating dimensions. The dialog needs to be expanded and minimised to measure with
getBoundingClientRect
, causing visible jumps in the UI. - Initial Calculation Delay: The initial dimension calculation process involves multiple steps, which may introduce a slight delay before the animation starts.
Conclusion and Next Steps
In Part 3, I improved the DialogAnimation
component to calculate both expanded and minimised dimensions for more accurate and visually appealing transitions. This approach involved using successive render cycles to expand and minimise the dialog, allowing for precise dimension calculations. However, it also introduced some complexity and potential performance issues, particularly the jank or flicker during dimension calculations.
Key Takeaways:
- Accurate Transitions: Calculating both expanded and minimised dimensions ensures smooth animations.
- Jank/Flicker Issue: Expanding and minimising the dialog for dimension calculations can cause visible UI jumps.
Next, in Part 4, I'll tackle the flickering issue by introducing a secondary, invisible container exclusively for dimension calculations. This approach aims to eliminate the jank while maintaining smooth and reliable transitions. Stay tuned as we continue to refine and perfect the dialog component!
I invite feedback and comments from fellow developers to help refine and improve this approach. Your insights are invaluable in making this proof of concept more robust and effective.
Top comments (0)