flutteruser-interfaceanimation

Flutter Hero Animation path override


I'm building an app with a Hero animation that transitions from a home screen to a second screen. Here's a simplified version of my code:

// A single tile, placed in a grid
class BeverageTile extends StatelessWidget {
  final Beverage beverage;

  const BeverageTile({
    super.key,
    required this.beverage,
  });

  @override
  Widget build(BuildContext context) {
    final String price = beverage.price == null ? 'Free' : '\$${beverage.price}';

    return GestureDetector(
      onTap: () => Navigator.of(context).push(
        FadePageRoute(
          page: BeverageSelectionScreen(beverage: beverage),
        ),
      ),
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white.withOpacity(0.1),
          borderRadius: BorderRadius.circular(16.0),
        ),
        child: Column(
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                Container(
                  decoration: BoxDecoration(
                    color: Colors.white.withOpacity(0.2),
                    borderRadius: const BorderRadius.only(
                      bottomLeft: Radius.circular(16.0),
                      topRight: Radius.circular(16.0),
                    ),
                  ),
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 12.0,
                      vertical: 6.0,
                    ),
                    child: Text(
                      price,
                      style: const TextStyle(
                        fontSize: 14.0,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                ),
              ],
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.only(
                  top: 16.0,
                  left: 16.0,
                  right: 16.0,
                ),
                // SETUP OF THE HERO
                child: Hero(
                  createRectTween: (begin, end) {
                    return RectTween(begin: begin, end: end);
                  },
                  tag: 'beverage_${beverage.name}',
                  child: SvgPicture.asset(
                    beverage.imagePath,
                    fit: BoxFit.contain,
                  ),
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(
                vertical: 16.0,
                horizontal: 8.0,
              ),
              child: Text(
                beverage.name,
                textAlign: TextAlign.center,
                style: const TextStyle(fontSize: 16.0),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// Second screen
class BeverageSelectionScreen extends StatelessWidget {
  final Beverage beverage;

  const BeverageSelectionScreen({super.key, required this.beverage});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Color(0xFF1a1718),
      ),
      child: Padding(
        padding: const EdgeInsets.all(72.0),
        child: Hero(
          tag: 'beverage_${beverage.name}',
          child: SvgPicture.asset(
            beverage.imagePath,
            fit: BoxFit.contain,
          ),
        ),
      ),
    );
  }
}

I've also implemented a custom PageRouteBuilder to handle the transition animation between pages. The Hero animation works as expected, but the image currently follows a curved path, which is the default behavior according to the Material Design motion spec.

Question: How can I customize the path of the Hero animation so that the image moves along a linear path instead of the default curved trajectory?


Solution

  • By default, Hero uses MaterialRectArcTween, which creates a curved animation path. To make the hero follow a linear path, use a RectTween in the createRectTween property:

    createRectTween: (begin, end) => RectTween(begin: begin, end: end),
    

    Important:
    Make sure to override createRectTween in both Hero widgets (on both pages) to ensure that the reverse animation is linear as well.

    Example GIF

    Full example:

    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart';
    
    void main() {
      // Slow down the animation to better see the Hero flight.
      timeDilation = 15.0;
    
      runApp(
        const MaterialApp(
          home: HeroLinearPathExample(),
        ),
      );
    }
    
    class HeroLinearPathExample extends StatelessWidget {
      const HeroLinearPathExample({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Linear Hero Animation Example'),
          ),
          body: Align(
            alignment: Alignment.bottomLeft,
            child: GestureDetector(
              onTap: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (_) => const SecondPage(),
                  ),
                );
              },
              child: Hero(
                tag: 'hero-tag',
                createRectTween: (begin, end) {
                  // Enforce a linear animation
                  return RectTween(begin: begin, end: end);
                },
                child: const FlutterLogo(size: 100),
              ),
            ),
          ),
        );
      }
    }
    
    class SecondPage extends StatelessWidget {
      const SecondPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Second Page'),
          ),
          body: Align(
            alignment: Alignment.topRight,
            child: Hero(
              tag: 'hero-tag',
              createRectTween: (begin, end) {
                // Enforce a linear animation for the reverse transition
                return RectTween(begin: begin, end: end);
              },
              child: const FlutterLogo(size: 100),
            ),
          ),
        );
      }
    }