Search code examples
flutterflutter-animation

How to create flutter animation like TrueCaller?


I am trying to develop an effect similar to a ripple, but it is very smooth and pleasant. I have tried develop it using Animated and Custom Paint, but it doesn't turn out the same. Has anyone been able to achieve such an effect? Can you help me with any possible approaches?

Thank you so much!

enter image description here

enter image description here

This is my code using mixin animation:

class CircleAnimateWidget extends StatefulWidget {
  final double sizeMin;
  final double sizeMax;
  final double containerSize;
  final int duration;
  final int reverseDuration;

  const CircleAnimateWidget({super.key, required this.sizeMin, required this.duration, required this.sizeMax, required this.containerSize, required this.reverseDuration});

  @override
  State<StatefulWidget> createState() {
    return _State();
  }
}

class _State extends State<CircleAnimateWidget>
    with TickerProviderStateMixin {
  late Animation _sizeAnimation;
  late AnimationController _animationController;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
        vsync: this,
        duration: Duration(milliseconds: widget.duration),
        reverseDuration: Duration(milliseconds: widget.reverseDuration));

    _sizeAnimation = Tween(begin: widget.sizeMin, end: widget.sizeMax).animate(CurvedAnimation(
        curve: Curves.elasticOut,
        reverseCurve: Curves.ease,
        parent: _animationController));

    _animationController.addStatusListener(
          (AnimationStatus status) async {
        if (status == AnimationStatus.completed) {
          await Future.delayed(const Duration(milliseconds: 50));
          _animationController.reverse();
        } else if (status == AnimationStatus.dismissed) {
          _animationController.forward();
        }
      },
    );

    _animationController.forward();
  }

  @override
  void dispose() {
    _animationController?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          return Container(
            width: widget.containerSize,
            height: widget.containerSize,
            alignment: Alignment.center,
            child: SizedBox(
              width: _sizeAnimation.value,
              height: _sizeAnimation.value,
              child: CircleAvatar(
                backgroundColor: Colors.green.withOpacity(0.3),
                child: const SizedBox(),
              ),
            ),
          );
        },
      ),
    );
  }
}

And put it to the stack:

[Positioned(
          child: CircleAnimateWidget(
            sizeMin: maxCircleSize - 20,
            sizeMax: maxCircleSize,
            duration: 1500,
            reverseDuration: 500,
            containerSize: maxCircleSize + 50,
          )
      ),
      Positioned(
          child: CircleAnimateWidget(
            sizeMin: maxCircleSize - 50,
            sizeMax: maxCircleSize - 30,
            duration: 1500,
            reverseDuration: 500,
            containerSize: maxCircleSize + 50,
          )
      ),
      Positioned(
          child: CircleAnimateWidget(
            sizeMin: maxCircleSize - 90,
            sizeMax: maxCircleSize - 60,
            duration: 1500,
            reverseDuration: 500,
            containerSize: maxCircleSize + 50,
          )
      )]

Solution

  • you can use a custom CustomPainter like this:

    class _TrueCallerPainter extends CustomPainter {
      _TrueCallerPainter(this.animation) : super(repaint: animation);
    
      final Animation<double> animation;
    
      final ringData = [
        (_curve(0.0), Colors.green.shade900.withOpacity(0.4)),
        (_curve(0.5), Colors.green.shade900.withOpacity(0.6)),
        (_curve(1.0), Colors.green.shade900),
      ];
    
      static Curve _curve(double t) {
        const delay = 0.25;
        return Interval(lerpDouble(delay, 0, t)!, lerpDouble(1, 1 - delay, t)!);
      }
    
      @override
      void paint(Canvas canvas, Size size) {
        // timeDilation = 10;
        final r = 0.85 * size.shortestSide / 2;
        final paint = Paint();
        double factor = 1;
        for (final (curve, color) in ringData) {
          final radius = r * factor - sin(curve.transform(animation.value) * 2 * pi) * r * 0.05;
          canvas.drawCircle(size.center(Offset.zero), radius, paint..color = color);
          factor -= 0.15;
        }
      }
    
      @override
      bool shouldRepaint(_TrueCallerPainter oldDelegate) => false;
    }
    

    and use it with:

    class TrueCaller extends StatefulWidget {
      @override
      State<TrueCaller> createState() => _TrueCallerState();
    }
    
    class _TrueCallerState extends State<TrueCaller> with TickerProviderStateMixin {
      late final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 750));
    
      @override
      void initState() {
        super.initState();
        _loop();
      }
    
      _loop() async {
        int i = 0;
        while (i++ < 8) {
          await Future.delayed(const Duration(seconds: 1));
          await controller.forward(from: 0);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return DecoratedBox(
          decoration: const BoxDecoration(
            gradient: LinearGradient(colors: [Colors.black, Colors.black87]),
          ),
          child: CustomPaint(
            painter: _TrueCallerPainter(controller),
            child: SizedBox.expand(
              child: Transform.scale(
                scale: 0.5,
                child: const FittedBox(child: Icon(Icons.shield, color: Colors.black45)),
              ),
            ),
          ),
        );
      }
    }
    

    note that it uses dart v3.0 and if you have older version replace ringData's records with some Ring class instances holding just two data fields: Curve and Color

    EDIT

    if you dont want to use a CustomPaint you can always use a workaround with three ScaleTransitions:

    class TrueCaller extends StatefulWidget {
      @override
      State<TrueCaller> createState() => _TrueCallerState();
    }
    
    class _TrueCallerState extends State<TrueCaller> with TickerProviderStateMixin {
      late final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 750));
      final _curves = [0.0, 0.5, 1.0].map(_curve).toList();
    
      @override
      void initState() {
        super.initState();
        _loop();
      }
    
      static Curve _curve(double t) {
        const delay = 0.25;
        return Interval(lerpDouble(delay, 0, t)!, lerpDouble(1, 1 - delay, t)!);
      }
    
      _scale(Curve c, double f, double t) => f - sin(c.transform(t) * 2 * pi) * 0.05;
    
      Widget _ring(Color color) => DecoratedBox(
        decoration: BoxDecoration(
          color: color,
          shape: BoxShape.circle,
        ),
      );
    
      _loop() async {
        int i = 0;
        while (i++ < 8) {
          await Future.delayed(const Duration(seconds: 1));
          await controller.forward(from: 0);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return DecoratedBox(
          decoration: const BoxDecoration(
            gradient: LinearGradient(colors: [Colors.black, Colors.black87]),
          ),
          child: Stack(
            fit: StackFit.expand,
            children: [
              ScaleTransition(
                scale: Animation.fromValueListenable(controller, transformer: (t) => _scale(_curves[0], 0.8, t)),
                child: _ring(Colors.green.withOpacity(0.25)),
              ),
              ScaleTransition(
                scale: Animation.fromValueListenable(controller, transformer: (t) => _scale(_curves[1], 0.7, t)),
                child: _ring(Colors.green.withOpacity(0.25)),
              ),
              ScaleTransition(
                scale: Animation.fromValueListenable(controller, transformer: (t) => _scale(_curves[2], 0.6, t)),
                child: _ring(Colors.green.shade800),
              ),
              Transform.scale(
                scale: 0.5,
                child: const FittedBox(child: Icon(Icons.shield, color: Colors.black45)),
              ),
            ],
          ),
        );
      }
    }