image

22 Dec 2025

9K

35K

Flutter Animation: Crafting Seamless Shape Morphing Widgets

User interfaces thrive on dynamic and engaging interactions. Among the most captivating visual effects is shape morphing — the seamless transformation of one geometric shape into another. This animation technique not only adds a layer of sophistication to your Flutter applications but also improves user experience by providing clear visual cues and a delightful aesthetic. In Flutter, achieving sophisticated shape morphing is highly feasible, leveraging its powerful animation framework and custom painting capabilities.

The Essence of Shape Morphing in Flutter

Shape morphing in Flutter involves animating the properties that define a shape's geometry over a period, creating an illusion of continuous transformation. Unlike simpler animations that merely move or scale a widget, morphing fundamentally alters the visual structure of a drawn object. This is typically accomplished by drawing custom shapes whose parameters are driven by an animation.

The core components often used for this kind of animation include:

  • CustomPainter: This widget provides a canvas to draw custom 2D graphics. It's essential for defining the precise geometry of shapes and their intermediate states during a morph.
  • Path: Used within a CustomPainter, a Path object defines a series of connected points and curves, allowing for complex shape outlines. While direct path interpolation for arbitrary paths can be challenging, animating parameters that influence a path's construction is highly effective.
  • TweenAnimationBuilder: This widget simplifies explicit animations by rebuilding its child whenever the animation value changes. It's ideal for driving a CustomPainter with an animated value that dictates the morphing progress.
  • Tween: Defines the range of an animation, from a beginning value to an ending value. For shape morphing, this typically tweens a numerical value (e.g., from 0.0 to 1.0) that represents the state of transformation.

Building a Morphing Shape Widget: A Practical Example

Let's illustrate how to create a simple yet effective shape morphing animation: transforming a square into a circle and back. This example will utilize a CustomPainter to draw a shape whose corner radius is animated by a TweenAnimationBuilder.

Step 1: The MorphingShapePainter

First, we define a CustomPainter that can draw a rounded rectangle. By animating the borderRadius of this rounded rectangle, we can achieve the square-to-circle morph. A double animationValue will control this radius, where 0.0 represents a perfect square and 1.0 represents a perfect circle (given the bounding box is square).


import 'package:flutter/material.dart';
import 'dart:math' as math;

class MorphingShapePainter extends CustomPainter {
  final double animationValue; // 0.0 for square, 1.0 for circle
  final Color shapeColor;

  MorphingShapePainter(this.animationValue, {this.shapeColor = Colors.blue});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = shapeColor
      ..style = PaintingStyle.fill;

    // Define the bounding rectangle for our shape
    final rect = Rect.fromLTWH(0, 0, size.width, size.height);

    // Calculate the current border radius based on animationValue
    // Max radius is half of the smallest side to form a perfect circle
    final maxRadius = math.min(size.width, size.height) / 2;
    final currentRadius = maxRadius * animationValue;

    // Create a RoundedRectangle (RRect) with the calculated radius
    final rrect = RRect.fromRectAndRadius(
      rect,
      Radius.circular(currentRadius),
    );

    canvas.drawRRect(rrect, paint);
  }

  @override
  bool shouldRepaint(covariant MorphingShapePainter oldDelegate) {
    // Only repaint if the animation value or color has changed
    return oldDelegate.animationValue != animationValue || oldDelegate.shapeColor != shapeColor;
  }
}

Step 2: Integrating with TweenAnimationBuilder

Next, we'll create a StatefulWidget that uses TweenAnimationBuilder to drive the animationValue for our MorphingShapePainter. A simple tap gesture will toggle the target shape between a square and a circle.


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

  @override
  _MorphingShapeWidgetState createState() => _MorphingShapeWidgetState();
}

class _MorphingShapeWidgetState extends State {
  bool _isCircle = false;

  void _toggleShape() {
    setState(() {
      _isCircle = !_isCircle;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _toggleShape,
      child: Center(
        child: SizedBox( // Use SizedBox for fixed dimensions
          width: 200,
          height: 200,
          child: TweenAnimationBuilder(
            tween: Tween(begin: 0.0, end: _isCircle ? 1.0 : 0.0),
            duration: const Duration(milliseconds: 500),
            curve: Curves.easeInOut, // Add a curve for smoother animation
            builder: (context, value, child) {
              return CustomPaint(
                painter: MorphingShapePainter(value, shapeColor: Colors.deepPurple),
              );
            },
          ),
        ),
      ),
    );
  }
}

Complete Code Example

To run this example, you can embed the MorphingShapeWidget within a basic Flutter application structure:


import 'package:flutter/material.dart';
import 'dart:math' as math;

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Shape Morphing',
      theme: ThemeData(
        primarySwatch: Colors.deepPurple,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Shape Morphing Widget'),
        ),
        body: const MorphingShapeWidget(),
      ),
    );
  }
}

class MorphingShapePainter extends CustomPainter {
  final double animationValue; // 0.0 for square, 1.0 for circle
  final Color shapeColor;

  MorphingShapePainter(this.animationValue, {this.shapeColor = Colors.blue});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = shapeColor
      ..style = PaintingStyle.fill;

    final rect = Rect.fromLTWH(0, 0, size.width, size.height);

    final maxRadius = math.min(size.width, size.height) / 2;
    final currentRadius = maxRadius * animationValue;

    final rrect = RRect.fromRectAndRadius(
      rect,
      Radius.circular(currentRadius),
    );

    canvas.drawRRect(rrect, paint);
  }

  @override
  bool shouldRepaint(covariant MorphingShapePainter oldDelegate) {
    return oldDelegate.animationValue != animationValue || oldDelegate.shapeColor != shapeColor;
  }
}

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

  @override
  _MorphingShapeWidgetState createState() => _MorphingShapeWidgetState();
}

class _MorphingShapeWidgetState extends State {
  bool _isCircle = false;

  void _toggleShape() {
    setState(() {
      _isCircle = !_isCircle;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _toggleShape,
      child: Center(
        child: SizedBox(
          width: 200,
          height: 200,
          child: TweenAnimationBuilder(
            tween: Tween(begin: 0.0, end: _isCircle ? 1.0 : 0.0),
            duration: const Duration(milliseconds: 500),
            curve: Curves.easeInOut,
            builder: (context, value, child) {
              return CustomPaint(
                painter: MorphingShapePainter(value, shapeColor: Colors.deepPurple),
              );
            },
          ),
        ),
      ),
    );
  }
}

Explanation of the Code

  • MorphingShapePainter:
    • Takes an animationValue (double from 0.0 to 1.0) and a shapeColor.
    • In the paint method, it defines a Rect that serves as the bounding box for our shape.
    • It calculates currentRadius by multiplying maxRadius (half of the shorter side, for a perfect circle) with the animationValue.
    • RRect.fromRectAndRadius creates a rounded rectangle. When currentRadius is 0, it's a perfect rectangle. When currentRadius equals maxRadius, it's a perfect circle (if the Rect is square).
    • shouldRepaint is optimized to only repaint when necessary, preventing unnecessary UI redraws and improving performance.
  • MorphingShapeWidget:
    • A StatefulWidget manages the _isCircle boolean state, which determines the target shape.
    • GestureDetector wraps the animation, allowing users to tap anywhere on the shape to trigger the morph.
    • SizedBox ensures the CustomPaint has fixed dimensions, making the morph predictable.
    • TweenAnimationBuilder:
      • The tween is set to go from 0.0 to 1.0 (for square to circle) or 1.0 to 0.0 (for circle to square) based on the _isCircle state.
      • duration controls how long the animation takes.
      • curve: Curves.easeInOut provides a smooth start and end to the animation, making it feel more natural.
      • The builder callback receives the interpolated value from the tween at each animation step. This value is then passed directly to the MorphingShapePainter.

Advanced Considerations & Best Practices

While the square-to-circle morph is fundamental, the principles extend to more complex scenarios:

  • Complex Path Morphing: For morphing between two entirely different custom paths (e.g., a star to a heart), direct interpolation of Path objects can be complex, especially if they have different numbers of points or segment types. Libraries like flutter_path_morph can assist in such advanced scenarios by normalizing and interpolating SVG path data.
  • Performance: For intricate shapes or frequent animations, ensure your shouldRepaint logic in CustomPainter is efficient. Avoid complex calculations within the paint method that could be pre-calculated.
  • Interaction: Shape morphing can be combined with other interactive elements, such as drag gestures, scroll progress, or button presses, to create highly responsive and engaging UIs.
  • Chaining Animations: Use AnimationController and a series of Tweens if you need to coordinate multiple animation effects or have more fine-grained control over the animation lifecycle (e.g., repeating, reversing, or pausing).
  • ShaderMasks & ClipPaths: For very artistic or non-geometric morphs, ShaderMask or dynamic ClipPath widgets can be used to reveal or transform content in unique ways.

Conclusion

Shape morphing animations in Flutter offer a powerful avenue for creating rich, intuitive, and visually stunning user interfaces. By mastering CustomPainter and leveraging the explicit animation capabilities of TweenAnimationBuilder, developers can transform simple geometric shapes into fluid, dynamic elements that elevate the overall user experience. Start experimenting with these techniques, and you'll unlock a new dimension of creativity in your Flutter applications.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is