page_curl_flip
A realistic cylindrical page-curl animation for Flutter. Pages peel away like real paper — perspective, diffuse shadow, and a specular sheen included.
Zero dependencies. The package is a skeleton you decorate: every colour, label, and icon is replaceable, and sound, speech, and storage are callbacks you plug anything into. RTL-aware. Tested on Android and iOS — the two platforms the listing promises; it is pure Flutter with zero dependencies, so it may well render elsewhere, but only what is tested is claimed.

Three entry points
| Widget | Use it for |
|---|---|
FlipBook |
A complete multi-page book: navigation, table of contents, read-aloud, reader marks, bookmarks, export |
PageCurlRoute |
A Navigator transition that peels the previous screen away |
CurlOverlay |
The raw curl, driven by any progress value you control |
Quick start
FlipBook(
onClose: () => Navigator.of(context).pop(),
pages: FlipBookPages(
items: const [
FlipBookPage(
title: 'A Tiny Book', // in the contents AND on the page
tagline: 'a line under it', // optional smaller line
bodySegments: ['Hello.', 'Goodbye.'], // the text, unit by unit
),
FlipBookPage(
title: 'Images too',
body: Center(child: FlutterLogo(size: 160)), // body is ANY widget
),
],
),
)
Every feature is an object you pass, and it exists only if you pass it.
A book with nothing but pages has no voice controls, no pencil, and no
settings for things it does not do. Pass readAloud:, marker:,
bookmarks:, or contents.export: and everything about that feature —
behaviour, icons, colours, words — lives inside that one object.
Pages
Everything about presentation lives in FlipBookPages:
pages: FlipBookPages(
items: [...],
paperColor: const Color(0xFFFBFAF6),
style: const FlipBookPageStyle(titleStyle: TextStyle(fontSize: 28)),
textDirection: TextDirection.rtl, // null follows the locale
initialPage: prefs.getInt('lastPage') ?? 0,
onChanged: (i) => prefs.setInt('lastPage', i),
showNumber: true,
)
A page is built from optional parts:
title/tagline— printed at the top and listed in the contents.showTitleOnPage: falsekeeps the title in the contents only.bodySegments— the text as a list of reading units. The accurate way: one entry is one unit, marked and spoken as-is, in every script.bodyText— one string the book splits on full stops.pub.devand$23.54survive;Dr. Smithdoes not. PreferbodySegments.bodyWidgets— widgets dropped between the units:{1: Image.asset(...)}inserts before segment 1. The text around them stays markable and readable.background— decoration behind a text page (ruled paper, parchment); it scrolls with the text.body— ANY widget as the whole page. Full layout control; the book can read itsbodyTextaloud but cannot mark or auto-scroll it.style— a per-pageFlipBookPageStyle, overriding the book's.id— stable identity; required for reader marks and saved pages.
Text pages the book lays out itself scroll when they exceed the screen.
Reading direction
LTR under English and other LTR locales; automatically mirrored under
Arabic and any RTL locale — buttons mirror, chevrons flip, and pages curl
the other way. Force it with textDirection: regardless of locale.
Swipe
A horizontal fling turns the page; a vertical one scrolls it. On by default,
mirrored under RTL. A hint greets new pages and retires after maxShows
appearances — persist onRetired to stop greeting a reader who has learned:
swipe: FlipBookSwipe(
hint: FlipBookSwipeHint( // null = no hint
showFor: const Duration(seconds: 3),
maxShows: 3,
onRetired: () => prefs.setBool('swipeLearned', true),
child: Image.asset('assets/swipe.gif'), // or any widget of yours
),
)
Flip speed and sound
FlipBook(flipSpeed: FlipSpeed.slow, ...) // preset
FlipBook(flipSpeed: const FlipSpeed.custom(Duration(seconds: 2))) // your own
The package ships no audio. Give the footer a sound object and a speaker button appears; omit it and the book is silent:
footer: FlipBookFooter(sound: FlipBookSound(onFlip: _mySound.play)),
Read aloud
Wire any speech engine; the book reads unit by unit and calls you once per unit:
readAloud: FlipBookReadAloud(
onRead: (unit) => tts.speak(unit), // future completes when the unit ends
onStop: tts.stop,
onPause: tts.pause, // optional pair — enables pause
onResume: tts.resume,
playAll: true, // PLAY ALL: the whole book, audiobook-style
),
- Read marker — the unit being spoken is marked and the page
auto-scrolls to it. It moves when the engine reports a unit done — never
on a timer — so it cannot drift. Styles:
highlight(a translucent band) orfocus(the rest of the page dims). Configure viaFlipBookHighlight, or take over rendering with itsbuilder. - Speed — pass
speed: FlipBookSpeedControl(...)and a0.5x · 1x · 1.5xpill appears while reading. The package reports the choice; you apply it to your engine. - What is read — title, tagline, body: each can be switched off
(
readTitle:…) and each can be excluded from the focus dim (fadeTitle:…). - Play-all works in the foreground; backgrounding stops the voice and the
chain. The example wires
flutter_ttscompletely, including pause-by-word-position and a missing-voice dialog.
Reader marks
A pencil in the footer; a drag marks whole words; SAVE / CANCEL float at the marked passage itself:
marker: FlipBookMarker(
marks: _marks, // loaded from YOUR storage
onChanged: (marks) => save(marks),
),
- CANCEL drops the draft and exits marking mode — marking owns the horizontal drag, so staying in it would block page turns.
- A trash button appears only on pages that have marks, and clears that page only.
- Marks are stored against
FlipBookPage.id, never the page number, so reordering pages cannot move them. Noid, no pencil. - The package stores nothing.
ReaderMarkis plain primitives (toMap/fromMap) — persist with whatever your app already uses.
Bookmarks and saved pages
Two ways of keeping a place, both reported through callbacks and handed back as values:
bookmarks: FlipBookBookmarks(
bookmarkedPage: _bookmark, // the page to carry on from
onBookmark: (page) => keep(page), // tap again to remove
saved: _savedIds, // the reader's collection, by page id
onSavedChanged: (ids) => keepAll(ids),
),
Contents and export
Pages with a title appear in the table of contents (INDEX button), with
live search and direct jumps. Give it an export object and an export button
appears, offering the reader's saved pages, marked passages, or the whole
book as plain data — page numbers, titles, and strings — for you to turn
into a PDF, an email, anything:
contents: FlipBookContents(
export: FlipBookExport(
onExport: (kind, entries) => makePdf(kind, entries),
),
),
The example builds a real shareable PDF from it.
Chrome: footer, header, tap labels, immersive mode
Controls are icons on a bar; a tapped control names itself above the
footer for a moment (tapLabelFor, Duration.zero turns it off). Any
control takes a custom icon or a word instead. With autoHide: true the
book opens as a pure page — a tap (or a mouse hover at the edge) reveals the
chrome, which retires again after revealFor:
header: const FlipBookHeader(autoHide: true),
footer: FlipBookFooter(
autoHide: true,
revealFor: const Duration(seconds: 3),
color: Colors.teal, // the bar; Colors.transparent floats the icons
),
header: null / footer: null removes an element entirely.
Your own buttons, anywhere
final controller = FlipBookController();
FlipBook(
controller: controller,
header: null,
footer: null,
pages: FlipBookPages(items: const [...]),
onClose: () {},
);
FloatingActionButton(onPressed: controller.nextPage);
nextPage(), previousPage(), jumpToPage(i), openIndex(),
closeIndex(), toggleMute(), and the current page.
Styling and localization
There is no theme object and no strings object: each feature carries its own look and its own words. Every field defaults, so you only name what you change — and translating a book means filling in the labels:
FlipBook(
header: const FlipBookHeader(closeLabel: 'إغلاق'),
footer: const FlipBookFooter(
index: FlipBookIndexButton(label: 'الفهرس'),
nav: FlipBookNavButtons(previousLabel: 'السابق', nextLabel: 'التالي'),
),
contents: const FlipBookContents(heading: 'جدول المحتويات'),
...
)
The example's Arabic book is a complete translation of every label.
PageCurlRoute
Navigator.of(context).push(
PageCurlRoute(
coverChild: const CoverPage(), // shown, captured, then peeled away
builder: (_) => const DetailScreen(),
),
);
Mirrors automatically under RTL; mirror: forces a direction. All four
durations (route and curl, forward and reverse) are parameters.
CurlOverlay
Drive the curl yourself — from an AnimationController, a slider, a drag:
CurlOverlay(
progress: controller.value, // 0.0 flat → 0.5 max curl → 1.0 gone
child: MyPageContent(), // captured automatically
)
Or capture the bitmap yourself and pass pageImage for exact control.
Known limitations
- Swipes fling, they don't drag — the page does not follow your finger mid-drag.
- A reader mark cannot cross paragraphs — a
ReaderMarklives inside onebodySegmentsentry. body:widget pages do not scroll — a widget body owns its layout; give it its ownSingleChildScrollView. Text pages already scroll.- Flips show a snapshot — an animating body (GIF, video) freezes during the curl and resumes after it.
- Mute and reading state are not persisted across rebuilds that recreate the book.
Testing note
Golden baselines are macOS-rendered; regenerate them on macOS
(flutter test --update-goldens test/goldens_test.dart). In widget tests,
omit the sound/speech objects or pass no-ops — the callbacks are
fire-and-forget, so an audio failure can never break the animation.
How it works
Each flip captures the page as a bitmap at the device pixel ratio (capped at
2×), then draws it as 28 vertical slices projected onto a cylinder whose
radius shrinks and grows with progress. Slices are depth-sorted, shaded by
their angle to the light, and given a moving specular glint — which is what
makes the paper read as physically curling rather than just rotating.
License
MIT
Libraries
- page_curl_flip
- A realistic cylindrical page-curl animation for Flutter.