skip to content

In Flutter, how would you animate an FAQ card expanding to reveal its answer, and how does AnimatedCrossFade handle the size change?

level: middleimportance: should knowfreq 30%

answer

  1. height null cannot be tweened
  2. two children, crossFadeState
  3. an internal AnimatedSize
  4. bottom child ignores taps
  5. or AnimatedSize around the answer

basics

~20 s

Use AnimatedCrossFade between a collapsed and an expanded child, or wrap the answer in AnimatedSize. AnimatedCrossFade fades the two and animates its own size between them through an internal AnimatedSize, clipping by default, while the hidden child ignores input.

solid answer

~40 s

An expanding card animates from a small height to the answer's natural height, which you usually do not know in advance, so `AnimatedContainer(height: open ? null : 0)` is the wrong tool: a null property is not tweened and the change snaps. `AnimatedCrossFade` takes `firstChild`, `secondChild`, a `crossFadeState` (`showFirst` or `showSecond`) and a `duration`; it fades one child out and the other in while an internal `AnimatedSize` animates between their sizes, with `sizeCurve`, `alignment` (default `Alignment.topCenter`) and `clipBehavior` (default `Clip.hardEdge`). Both children stay built; the outgoing one ignores pointers, is excluded from semantics and, by default, from focus. The simpler alternative is `AnimatedSize` around a child that is either the answer or an empty box, which animates size alone.

code

dart · 43 lines
dart
import 'package:flutter/material.dart';

class FaqCard extends StatefulWidget {
  const FaqCard({super.key, required this.question, required this.answer});

  final String question;
  final String answer;

  @override
  State<FaqCard> createState() => _FaqCardState();
}

class _FaqCardState extends State<FaqCard> {
  bool _open = false;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onTap: () => setState(() => _open = !_open),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Text(widget.question, style: Theme.of(context).textTheme.titleMedium),
              AnimatedCrossFade(
                duration: const Duration(milliseconds: 250),
                sizeCurve: Curves.easeOut,
                crossFadeState: _open ? CrossFadeState.showSecond : CrossFadeState.showFirst,
                firstChild: const SizedBox(width: double.infinity),
                secondChild: Padding(
                  padding: const EdgeInsets.only(top: 12),
                  child: Text(widget.answer),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

go deeper

for a junior

Recall AnimatedCrossFade's two children and crossFadeState, and that AnimatedSize animates a child's size.

for a middle

Explain why null heights snap, how the internal AnimatedSize and clip produce the growth, and how the hidden child is excluded.

for a senior

Weigh the cost of keeping both children built in long lists and handle scroll position while cards grow.

for a principal

Set an expand-and-collapse pattern for the design system that works with dynamic text sizes and assistive technology.

## The problem In a help-centre app, each FAQ card shows a question; tapping it reveals the answer below, and the card should grow smoothly instead of jumping. The difficulty is the **target height**: the answer's height depends on its text, the font scale and the screen width, so you do not know a number to animate to. The tempting fix, `AnimatedContainer(height: isOpen ? null : 0, …)`, does not work. An implicit widget only tweens properties that are **non-null on both sides**: going from `0` to `null` drops the tween and the card **snaps** open; going from `null` to `0` creates a tween that starts and ends at `0`, so it snaps shut. Hard-coding a height instead clips long answers or leaves gaps under short ones. ## Option 1: AnimatedCrossFade **`AnimatedCrossFade`** switches between **exactly two children** and animates the size between them. - **`firstChild`** and **`secondChild`** (required) — for example the collapsed card and the expanded card, or `SizedBox.shrink()` and the answer. - **`crossFadeState`** (required) — `CrossFadeState.showFirst` or `CrossFadeState.showSecond`. - **`duration`** (required) and **`reverseDuration`**. - **`firstCurve`**, **`secondCurve`** and **`sizeCurve`** — all default to `Curves.linear`. - **`alignment`** — defaults to `Alignment.topCenter`, so the card grows downward. - **`clipBehavior`** — defaults to `Clip.hardEdge`, hiding the part of the larger child that does not yet fit. - **`excludeBottomFocus`** — defaults to true; **`layoutBuilder`** controls how the two children are stacked; **`onEnd`** reports completion. How it works: 1. Both children are always in the tree. The one being shown is on top; the other sits underneath. 2. Each is wrapped in a `FadeTransition`, so one fades out while the other fades in, each with its own curve. 3. The pair sits inside an **`AnimatedSize`** wrapped in a `ClipRect`. The default layout lets the top child set the size and pins the bottom child to the top edge, so as the top child changes, `AnimatedSize` animates to its new size with `sizeCurve`. 4. The bottom child is wrapped in `IgnorePointer`, `ExcludeSemantics` and, by default, `ExcludeFocus`, so hidden content cannot be tapped, read by a screen reader or focused. Its tickers are paused once the animation ends. ## Option 2: AnimatedSize **`AnimatedSize`** animates **only the size** of its child. Wrap the answer area and switch its content: ```dart AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, alignment: Alignment.topCenter, child: isOpen ? Text(answer) : const SizedBox(width: double.infinity), ) ``` The content appears at once and the box grows around it, clipped by default (`clipBehavior: Clip.hardEdge`). Its `alignment` defaults to `Alignment.center`, so set `topCenter` for a card that should grow downwards. Combine it with an `AnimatedOpacity` on the answer if it should also fade in. ## Choosing | Need | Use | |---|---| | Fade between two distinct layouts and animate the size | `AnimatedCrossFade` | | Grow or shrink around changing content | `AnimatedSize` | | Replace content of varying identity with a transition | `AnimatedSwitcher` (size is not animated by default) | | Animate to a known height or padding | `AnimatedContainer` / `AnimatedPadding` | ## Pitfalls - **Both children cost something.** `AnimatedCrossFade` builds and lays out both, so a heavy hidden child in every card of a long list adds up; `AnimatedSize` with an empty box does not. - **Toggling rapidly** reverses from the current point rather than restarting, since the cross-fade is driven by one controller. - **In a scrolling list**, a growing card pushes later cards down. That is expected, but scroll offsets change during the animation; bring the card into view after `onEnd` if it matters. ## Accessibility notes - **Text scaling** changes the answer's natural height; both `AnimatedCrossFade` and `AnimatedSize` measure the laid-out child, so they follow larger text without code changes, whereas a hard-coded height would clip it. - **Screen readers** hear only the visible child: `AnimatedCrossFade` excludes the bottom child's semantics, and with `AnimatedSize` the collapsed answer is simply not in the tree. - **State for assistive technology**: expose whether the card is expanded on the tappable header (for example through `Semantics(expanded: ...)`), since the animation itself conveys nothing to a screen reader.

  • Why can a hidden AnimatedCrossFade child not steal a tap or a screen-reader focus?
    The bottom child is wrapped in `IgnorePointer` and `ExcludeSemantics`, and in `ExcludeFocus` while `excludeBottomFocus` is true, which is the default. So only the visible child receives input, is announced, or can take keyboard focus.
  • When would you pick AnimatedSize over AnimatedCrossFade for the FAQ card?
    When you only need the box to grow around new content and do not want a cross-fade. `AnimatedSize` keeps only the current child built, which matters in a long list of cards, whereas `AnimatedCrossFade` keeps both children built.

saying these in an interview costs you the question

  • AnimatedContainer can animate height from null to the content's height.
  • AnimatedCrossFade only fades and leaves the size to jump.
  • The hidden AnimatedCrossFade child is removed from the tree.
  • AnimatedSize fades the new content in by default.
  • Hard-coding the expanded height is fine for text answers.