liquid_reveal

A liquid page reveal that measures its own box, not the screen. So the same widget is a full-screen onboarding flow, a 220 pixel hero card or an 84 pixel list row, and feels the same in each.

LiquidReveal(
  itemCount: pages.length,
  itemBuilder: (BuildContext context, int index) => pages[index],
)

Drag it either way. Forwards, the next page grows from one edge; backwards, the previous page grows from the other. The wave follows your finger and lets go into place.

Why another one

The liquid swipe effect has been around for years, and every implementation of it divides by MediaQuery.of(context).size — the screen. Put one in a card and the numbers stop meaning anything: a drag across a 200 pixel box is measured against an 800 pixel screen, so the reveal creeps along at a quarter speed and the commit threshold is never reached.

Here nothing asks how big the screen is. The render object measures itself:

  • a drag of half the box is half the reveal, in a card or on a page;
  • the wave's depth and width are fractions of the box, so it looks the same at any size;
  • the commit threshold is a fraction of the box too.

What it costs

One RenderBox. The drag and the settle animation only ever call markNeedsPaint, so no widget rebuilds while a reveal runs — the builder is called again exactly once, when the page changes. Two pages are laid out at a time, however long the list is.

Settings

LiquidReveal(
  settings: const LiquidRevealSettings(
    side: RevealSide.right,        // or left, top, bottom
    duration: Duration(milliseconds: 550),
    curve: Curves.easeOutCubic,    // while settling; a drag is never curved
    waveAmplitude: 0.18,           // fraction of the travel
    waveSpread: 0.45,              // fraction of the edge
    commitAt: 0.35,                // let go past this and the page turns
    flingVelocity: 700,            // px/s that commits whatever commitAt says
    dragEdgeFraction: null,        // null: drag anywhere; 0.25: only the edge
    loop: true,
    allowReverse: true,            // a drag the other way goes back a page
    ignoreDragWhileSettling: true,
  ),
  itemCount: 3,
  itemBuilder: builder,
)

LiquidRevealSettings.soft is a wide shallow wave for a hero card; .sharp is narrow and deep, closer to the original effect.

dragEdgeFraction is the one to reach for when the pages have their own horizontal gestures. Without it a drag anywhere in the box turns the page, which is what a carousel wants and the last thing a page with a chip row wants.

From code

final LiquidRevealController controller = LiquidRevealController();

LiquidReveal(controller: controller, itemCount: 3, itemBuilder: builder);

controller.next();          // reveal the next page
controller.previous();      // reveal the previous one, from the other edge
controller.page;            // the page at the front
controller.progress;        // 0 to 1, the reveal in flight
controller.offset;          // page + progress, for a sliding indicator

The controller is a Listenable, so an indicator can watch it while the pages stay still:

ListenableBuilder(
  listenable: controller,
  builder: (BuildContext context, Widget? _) => Dots(at: controller.offset),
)

The wave

The boundary between the two pages is a pure function — no widget, no context — which is why it can be tested rather than eyeballed:

final LiquidWave wave = LiquidWave(
  progress: 0.4,
  size: const Size(300, 200),
  side: RevealSide.right,
  focus: 120,          // where the finger is, along the edge
  amplitude: 54,
  spread: 90,
);

wave.boundaryAt(120);  // how far the boundary has come, at that point
wave.toPath();         // the revealed region

A straight line sweeps across the box, pulled towards the incoming page by a raised cosine centred on the finger. The bump's envelope is zero at both ends of the gesture, so a finished reveal has a straight edge rather than a frozen ripple, and it reaches exactly zero at spread — the boundary is provably flat outside the bump.

When the finger is near a corner, half the wave falls outside the box and you see a fin rather than a blob. That is deliberate: the wave follows the finger instead of sliding away from it.

Both directions

A drag decides its direction from the first movement, and cannot change its mind until the reveal is back at zero — so a gesture that turns round mid-flight undoes itself rather than leaving the wrong page underneath. Backwards is allowReverse, on by default; turn it off for a flow that should only go one way.

Up to three pages are built: the one at the front, the next and the previous. Never more, however long the list is.

What it will not do

  • A vertical reveal inside a vertical scroll view loses the gesture. Flutter's arena gives a vertical drag to the ListView, and there is nothing a child can do about it. Use a horizontal side inside a scrolling page, put the vertical one somewhere that does not scroll, or drive it with the controller.
  • No jumping to an arbitrary page. The reveal is a transition between two neighbours; jumpTo(7) would be a cut, not a reveal.
  • It is not liquid_swipe. No code is shared with it. If you want that package's full-screen behaviour and its slide icon, use that package.

Example

/example shows the same widget at three sizes with one set of settings, a dot indicator driven by the controller, and the four sides.

cd example && flutter run

License

MIT.

Libraries

liquid_reveal
A liquid page reveal that measures its own box rather than the screen.