page_turn_animation 0.1.4 copy "page_turn_animation: ^0.1.4" to clipboard
page_turn_animation: ^0.1.4 copied to clipboard

A Flutter package that provides a realistic page turn/curl animation effect for transitioning between content using captured images.

example/lib/main.dart

import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:page_turn_animation/page_turn_animation.dart';

void main() {
  runApp(const PageTurnExampleApp());
}

// =============================================================================
// App
// =============================================================================

/// Example app demonstrating the page_turn_animation package.
///
/// Simulates a simple book whose pages can be turned forward and backward
/// with realistic curl animations. Use the edge selector to switch between
/// top, bottom, left, and right curl directions.
class PageTurnExampleApp extends StatelessWidget {
  const PageTurnExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Page Turn Animation Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.brown,
        useMaterial3: true,
        scaffoldBackgroundColor: const Color(0xFF3E2723),
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF4E342E),
          foregroundColor: Color(0xFFEFEBE9),
        ),
      ),
      home: const BookViewer(),
    );
  }
}

// =============================================================================
// Book page data
// =============================================================================

class BookPage {
  const BookPage({
    required this.title,
    required this.body,
    required this.color,
    required this.icon,
  });

  final String title;
  final String body;
  final Color color;
  final IconData icon;
}

const List<BookPage> _pages = [
  BookPage(
    title: 'Page Turn Animation',
    body:
        'A Flutter package for realistic 3D page curl transitions. '
        'Works with any widget content — just capture it as a ui.Image '
        'using a RepaintBoundary and hand it to PageTurnAnimation.',
    color: Color(0xFFFFF3E0),
    icon: Icons.auto_stories,
  ),
  BookPage(
    title: 'Choose Your Edge',
    body:
        'Use the PageTurnEdge enum to control which side the page '
        'curls over: top (notepad), bottom (wall calendar), '
        'right (manga), or left (Western book). '
        'Try the selector above to see each one in action!',
    color: Color(0xFFE8F5E9),
    icon: Icons.swap_horiz,
  ),
  BookPage(
    title: 'Customize the Style',
    body:
        'PageTurnStyle lets you tweak backgroundColor, shadowColor, '
        'shadowOpacity, shadowBlurRadius, segments (quality vs. '
        'performance), and curlIntensity. Use copyWith on '
        'PageTurnStyle.defaults for quick adjustments.',
    color: Color(0xFFE1F5FE),
    icon: Icons.tune,
  ),
  BookPage(
    title: 'Tips & Best Practices',
    body:
        'Capture images at the device\'s pixel ratio for crisp results. '
        'Wrap your animation controller in a CurvedAnimation for natural '
        'motion. Lower the segment count (50–80) on budget devices. '
        'Always dispose captured images when done.',
    color: Color(0xFFFCE4EC),
    icon: Icons.lightbulb_outline,
  ),
];

// =============================================================================
// Animation phases
// =============================================================================

/// [idle]      — show the current page normally.
/// [capturing] — both current and target pages render in RepaintBoundary
///               widgets so their pixels can be captured.
/// [animating] — captured images drive the PageTurnAnimation widget.
enum _Phase { idle, capturing, animating }

// =============================================================================
// BookViewer
// =============================================================================

class BookViewer extends StatefulWidget {
  const BookViewer({super.key});

  @override
  State<BookViewer> createState() => _BookViewerState();
}

class _BookViewerState extends State<BookViewer>
    with SingleTickerProviderStateMixin {
  // State
  int _currentIndex = 0;
  int _targetIndex = 0;
  bool _isForward = true;
  _Phase _phase = _Phase.idle;
  PageTurnEdge _edge = PageTurnEdge.left;

  // Captured images
  ui.Image? _currentImage;
  ui.Image? _targetImage;

  // Keys for dual RepaintBoundary capture
  final GlobalKey _currentKey = GlobalKey();
  final GlobalKey _targetKey = GlobalKey();

  // Animation
  late final AnimationController _controller = AnimationController(
    duration: const Duration(milliseconds: 1000),
    vsync: this,
  );

  late final CurvedAnimation _curve = CurvedAnimation(
    parent: _controller,
    curve: Curves.decelerate,
  );

  /// Style configuration for the page turn effect. Customize these values
  /// to change how the curl looks during animation.
  static const _pageTurnStyle = PageTurnStyle(
    backgroundColor: Color(0xFFFAF3E8),
    shadowColor: Colors.black,
    shadowOpacity: 0.8,
    shadowBlurRadius: 20.0,
    segments: 150,
    curlIntensity: 1.0,
  );

  @override
  void dispose() {
    _curve.dispose();
    _controller.dispose();
    _disposeImages();
    super.dispose();
  }

  // ---------------------------------------------------------------------------
  // Image capture
  // ---------------------------------------------------------------------------

  Future<bool> _captureImages() async {
    if (!mounted) return false;
    final pixelRatio = MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1.0;

    final currentBoundary =
        _currentKey.currentContext?.findRenderObject()
            as RenderRepaintBoundary?;
    if (currentBoundary != null) {
      _currentImage = await currentBoundary.toImage(pixelRatio: pixelRatio);
    }

    final targetBoundary =
        _targetKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
    if (targetBoundary != null) {
      _targetImage = await targetBoundary.toImage(pixelRatio: pixelRatio);
    }

    return _currentImage != null && _targetImage != null;
  }

  void _disposeImages() {
    _currentImage?.dispose();
    _targetImage?.dispose();
    _currentImage = null;
    _targetImage = null;
  }

  // ---------------------------------------------------------------------------
  // Navigation
  // ---------------------------------------------------------------------------

  Future<void> _turnForward() async {
    if (_phase != _Phase.idle || _currentIndex >= _pages.length - 1) return;
    _isForward = true;
    _targetIndex = _currentIndex + 1;
    await _runPageTurn();
  }

  Future<void> _turnBackward() async {
    if (_phase != _Phase.idle || _currentIndex <= 0) return;
    _isForward = false;
    _targetIndex = _currentIndex - 1;
    await _runPageTurn();
  }

  /// Full capture → animate → commit lifecycle.
  Future<void> _runPageTurn() async {
    // 1. Render both pages for image capture.
    setState(() => _phase = _Phase.capturing);
    await SchedulerBinding.instance.endOfFrame;

    // 2. Capture both RepaintBoundary images.
    final success = await _captureImages();
    if (!success || !mounted) {
      _cleanup();
      return;
    }

    // 3. Animate.
    setState(() => _phase = _Phase.animating);
    try {
      await _controller.forward();
    } catch (_) {}

    if (!mounted) return;

    // 4. Commit page change.
    setState(() => _currentIndex = _targetIndex);
    _cleanup();
  }

  void _cleanup() {
    if (!mounted) return;
    setState(() => _phase = _Phase.idle);
    _controller.reset();
    _disposeImages();
  }

  // ---------------------------------------------------------------------------
  // Build
  // ---------------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page Turn Animation')),
      body: Column(
        children: [
          // Edge selector
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Row(
              children: [
                Text(
                  'Edge over which to turn page',
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: const Color(0xFFBCAAA4),
                  ),
                ),
                const SizedBox(width: 12),
                DropdownButton<PageTurnEdge>(
                  value: _edge,
                  dropdownColor: const Color(0xFF4E342E),
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: const Color(0xFFEFEBE9),
                  ),
                  underline: Container(
                    height: 1,
                    color: const Color(0xFFBCAAA4),
                  ),
                  iconEnabledColor: const Color(0xFFBCAAA4),
                  items: const [
                    DropdownMenuItem(
                      value: PageTurnEdge.top,
                      child: Text('Top'),
                    ),
                    DropdownMenuItem(
                      value: PageTurnEdge.bottom,
                      child: Text('Bottom'),
                    ),
                    DropdownMenuItem(
                      value: PageTurnEdge.left,
                      child: Text('Left'),
                    ),
                    DropdownMenuItem(
                      value: PageTurnEdge.right,
                      child: Text('Right'),
                    ),
                  ],
                  onChanged: (edge) {
                    if (edge != null) setState(() => _edge = edge);
                  },
                ),
              ],
            ),
          ),
          const SizedBox(height: 8),

          // Book area
          Expanded(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildForPhase(),
            ),
          ),
          const SizedBox(height: 8),

          // Page indicator
          Text(
            'Page ${(_phase == _Phase.idle ? _currentIndex : _targetIndex) + 1} '
            'of ${_pages.length}',
            style: Theme.of(
              context,
            ).textTheme.bodySmall?.copyWith(color: const Color(0xFFBCAAA4)),
          ),

          // Navigation buttons
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FilledButton.tonalIcon(
                  onPressed: _currentIndex > 0 && _phase == _Phase.idle
                      ? _turnBackward
                      : null,
                  icon: const Icon(Icons.arrow_back),
                  label: const Text('Previous'),
                ),
                const SizedBox(width: 16),
                FilledButton.icon(
                  onPressed:
                      _currentIndex < _pages.length - 1 && _phase == _Phase.idle
                      ? _turnForward
                      : null,
                  icon: const Icon(Icons.arrow_forward),
                  label: const Text('Next'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildForPhase() {
    switch (_phase) {
      // Both pages rendered with RepaintBoundary for image capture.
      // Current page on top so the screen doesn't visually flash.
      case _Phase.capturing:
        return Stack(
          children: [
            Positioned.fill(
              child: RepaintBoundary(
                key: _targetKey,
                child: _buildPage(_targetIndex),
              ),
            ),
            Positioned.fill(
              child: RepaintBoundary(
                key: _currentKey,
                child: _buildPage(_currentIndex),
              ),
            ),
          ],
        );

      // Hand captured images to PageTurnAnimation.
      case _Phase.animating:
        return Stack(
          children: [
            // Bottom layer: the destination page.
            Positioned.fill(child: _buildPage(_targetIndex)),

            // Forward: current page curls away, revealing destination.
            if (_isForward && _currentImage != null)
              PageTurnAnimation(
                image: _currentImage!,
                animation: _curve,
                direction: PageTurnDirection.forward,
                edge: _edge,
                style: _pageTurnStyle,
              ),

            // Backward: static current-page image hides destination,
            // then target-page image curls into view on top.
            if (!_isForward) ...[
              if (_currentImage != null)
                Positioned.fill(
                  child: RawImage(image: _currentImage, fit: BoxFit.fill),
                ),
              if (_targetImage != null)
                PageTurnAnimation(
                  image: _targetImage!,
                  animation: _curve,
                  direction: PageTurnDirection.backward,
                  edge: _edge,
                  style: _pageTurnStyle,
                ),
            ],
          ],
        );

      case _Phase.idle:
        return _buildPage(_currentIndex);
    }
  }

  Widget _buildPage(int index) {
    final page = _pages[index];
    return ColoredBox(
      color: page.color,
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(page.icon, size: 64, color: Colors.brown.shade400),
            const SizedBox(height: 24),
            Text(
              page.title,
              style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                fontWeight: FontWeight.bold,
                color: Colors.brown.shade800,
              ),
            ),
            const SizedBox(height: 16),
            Text(
              page.body,
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                color: Colors.brown.shade700,
                height: 1.6,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
198
downloads

Publisher

verified publisherresengi.io

Weekly Downloads

A Flutter package that provides a realistic page turn/curl animation effect for transitioning between content using captured images.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on page_turn_animation