How to slide a widget after tapping it in flutter?

Issue

I want to achieve the following animation in Flutter. I’ve created the containers with GestureDetector for that but don’t know how to achieve the animation.

Steps will be like:

  1. Click any of the item
  2. The item will displaced
  3. Corresponding page will be opened
  4. On coming back to previous page, the item will remained displaced until another item is clicked. Clicking on same item won’t perform anything.

Currently, if any item is clicked, it’s landed to new page without animation

enter image description here

Following is the code I’m using right now. I need the exact output like the attached gif.

GIF: See in Google Drive (Please open in new tab, otherwise this page will be navigated)

Code:

double width = MediaQuery.of(context).size.width;
Container(
       height: width * 0.6,
       width: width * 0.6,
       alignment: Alignment.center,
       child: Row(
             children: [
                    Column(
                       children: [
                            GestureDetector(
                                 onTap: () {
                                      Methods.navigationToDetailsPage(context, 'Category');
                                 },
                                 child: Container(
                                      height: width * 0.6 * 0.5,
                                      width: width * 0.6 * 0.5,
                                      alignment: Alignment.center,
                                      padding: EdgeInsets.fromLTRB(width * 0.06, width * 0.06, 2, 2),
                                      child: Image.asset(
                                            'assets/images/category.png',
                                      ),
                                  ),
                            ),
                            GestureDetector(
                                  onTap: () {
                                     Methods.navigationToDetailsPage(context, 'Segment');
                                  },
                                  child: Container(
                                      height: width * 0.6 * 0.5,
                                      width: width * 0.6 * 0.5,
                                      alignment: Alignment.center,
                                      padding: EdgeInsets.fromLTRB(width * 0.06, 2, 2, width * 0.06),
                                      child: Image.asset(
                                        'assets/images/segment.png',
                                      ),
                                  ),
                              ),
                       ],
                    ),
                    Column(
                        children: [
                            GestureDetector(
                                  onTap: () {
                                      Methods.navigationToDetailsPage(context, 'Division');
                                  },
                                  child: Container(
                                       height: width * 0.6 * 0.5,
                                       width: width * 0.6 * 0.5,
                                       alignment: Alignment.center,
                                       padding: EdgeInsets.fromLTRB(2, width * 0.06, width * 0.06, 2),
                                       child: Image.asset(
                                          'assets/images/division.png',
                                       ),
                                   ),
                            ),
                            GestureDetector(
                                   onTap: () {
                                       Methods.navigationToDetailsPage(context, 'Brand');
                                   },
                                   child: Container(
                                       height: width * 0.6 * 0.5,
                                       width: width * 0.6 * 0.5,
                                       alignment: Alignment.center,
                                       padding: EdgeInsets.fromLTRB(2, 2, width * 0.06, width * 0.06),
                                       child: Image.asset(
                                         'assets/images/brand.png',
                                       ),
                                    ),
                             ),
                        ],
                    ),
             ],
       ),
)

Solution

The code example at the bottom covers:

  • How to setup an AnimationController, see the AnimatedSectorButton widget created.
  • Usage of a Tween animation using Curves to smooth the animation flow, see the SectorTile widget created.
  • How to create a widget similar in shape to the images you used, this was done with ClipPath and a CustomClipper.
Note:

Generally using images with text for layout should be avoided, given they are less customizable, and they make it far harder to f.ex. translate an app given per asset you would need an image per language. Better to potentially import a font and create a nice widget yourself.

Illustration of what the code does:

Code in action, showing circle sectors animate out to their diagonal

Code example


import 'package:flutter/material.dart';
import 'package:flutter/animation.dart';
import 'package:flutter/rendering.dart';

main() {
  runApp(StackOverflowExampleApp());
}

class StackOverflowExampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                AnimatedSectorButton(radius: 150, sectorQuadrant: SectorQuadrant.TopLeft),
                AnimatedSectorButton(radius: 150, sectorQuadrant: SectorQuadrant.TopRight),
              ],
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                AnimatedSectorButton(radius: 150, sectorQuadrant: SectorQuadrant.BottomLeft),
                AnimatedSectorButton(radius: 150, sectorQuadrant: SectorQuadrant.BottomRight),
              ],
            )
          ]),
        ),
      ),
    );
  }
}

class AnimatedSectorButton extends StatefulWidget {
  final double radius;
  final SectorQuadrant sectorQuadrant;

  const AnimatedSectorButton({required this.radius, required this.sectorQuadrant});

  _AnimatedSectorButtonState createState() => _AnimatedSectorButtonState();
}

class _AnimatedSectorButtonState extends State<AnimatedSectorButton> with TickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(milliseconds: 1000), vsync: this)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          Future.delayed(Duration(milliseconds: 1500)).then((value) async {
            _controller.reverse();
          });
        }
      });
  }

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

  Widget build(BuildContext context) {
    return SectorTile(
      radius: widget.radius,
      quadrant: widget.sectorQuadrant,
      controller: _controller.view,
      onTap: () {
        _controller.forward();
      },
    );
  }
}

enum SectorQuadrant {
  TopRight,
  TopLeft,
  BottomLeft,
  BottomRight
}

class SectorTile extends StatelessWidget {
  final double radius;
  final SectorQuadrant quadrant;
  final Animation<double> controller;
  final Function() onTap;
  late final Animation<double> offsetValue;
  late final double xSign;
  late final double ySign;

  SectorTile({
    Key? key,
    required this.radius,
    required this.quadrant,
    required this.controller,
    required this.onTap,
  }) : super(key: key) {
    // Here we define the specific of the animation.
    offsetValue = Tween(begin: 0.0, end: 1.0)
        .chain(CurveTween(curve: Curves.fastOutSlowIn))
        .animate(controller);
    
    // Primarily used for the direction of the animation
    xSign = quadrant == SectorQuadrant.TopLeft || quadrant == SectorQuadrant.BottomLeft ? -1 : 1;
    ySign = quadrant == SectorQuadrant.TopLeft || quadrant == SectorQuadrant.TopRight ? -1 : 1;
  }

  Widget _buildAnimation(BuildContext context, Widget? widget) {
    double value = offsetValue.value * (radius / 3);

    return Transform.translate(
      offset: Offset(value * xSign, value * ySign), 
      child: ClipPath(
        clipper: SectorClipper(quadrant),
        child: Material(
          color: Colors.red,
          child: InkWell(
            splashColor: Colors.black87,
            onTap: onTap,
            child: Container(
              width: radius,
              height: radius,
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Align(
                  alignment: Alignment(-xSign, -ySign),
                  child: Text(
                    "StackOverflow",
                    style: TextStyle(color: Colors.white),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(animation: controller, builder: _buildAnimation);
  }
}

// Needed for constraining the material splash effect.
class SectorClipper extends CustomClipper<Path> {
  final SectorQuadrant sectorQuadrant;
  
  SectorClipper(this.sectorQuadrant);

  @override
  Path getClip(Size size) {
    switch (sectorQuadrant) {
      case SectorQuadrant.TopRight:
        return Path()..addOval(Rect.fromCircle(center: Offset(0, size.height), radius: size.height));
      case SectorQuadrant.TopLeft:
        return Path()..addOval(Rect.fromCircle(center: Offset(size.width, size.height), radius: size.height));
      case SectorQuadrant.BottomLeft:
        return Path()..addOval(Rect.fromCircle(center: Offset(size.width, 0), radius: size.height));
      case SectorQuadrant.BottomRight:
        return Path()..addOval(Rect.fromCircle(center: Offset(0, 0), radius: size.height));
    }
  }

  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}

Answered By – Tor-Martin Holen

Answer Checked By – Timothy Miller (FlutterFixes Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *