How to expand a card on tap in flutter?

19,000

Solution 1

A while ago I tried replicating that exact page/transition and while I didn't get it to look perfectly like it, I did get fairly close. Keep in mind that this was put together quickly and doesn't really follow best practices or anything.

The important part is the Hero widgets, and especially the tags that go along with them - if they don't match, it won't do it.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.deepPurple,
        ),
        body: ListView.builder(
          itemBuilder: (context, index) {
            return TileItem(num: index);
          },
        ),
      ),
    );
  }
}

class TileItem extends StatelessWidget {
  final int num;

  const TileItem({Key key, this.num}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: "card$num",
      child: Card(
        shape: RoundedRectangleBorder(
          borderRadius: const BorderRadius.all(
            Radius.circular(8.0),
          ),
        ),
        clipBehavior: Clip.antiAliasWithSaveLayer,
        child: Stack(
          children: <Widget>[
            Column(
              children: <Widget>[
                AspectRatio(
                  aspectRatio: 485.0 / 384.0,
                  child: Image.network("https://picsum.photos/485/384?image=$num"),
                ),
                Material(
                  child: ListTile(
                    title: Text("Item $num"),
                    subtitle: Text("This is item #$num"),
                  ),
                )
              ],
            ),
            Positioned(
              left: 0.0,
              top: 0.0,
              bottom: 0.0,
              right: 0.0,
              child: Material(
                type: MaterialType.transparency,
                child: InkWell(
                  onTap: () async {
                    await Future.delayed(Duration(milliseconds: 200));
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (context) {
                          return new PageItem(num: num);
                        },
                        fullscreenDialog: true,
                      ),
                    );
                  },
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class PageItem extends StatelessWidget {
  final int num;

  const PageItem({Key key, this.num}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    AppBar appBar = new AppBar(
      primary: false,
      leading: IconTheme(data: IconThemeData(color: Colors.white), child: CloseButton()),
      flexibleSpace: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.black.withOpacity(0.4),
              Colors.black.withOpacity(0.1),
            ],
          ),
        ),
      ),
      backgroundColor: Colors.transparent,
    );
    final MediaQueryData mediaQuery = MediaQuery.of(context);

    return Stack(children: <Widget>[
      Hero(
        tag: "card$num",
        child: Material(
          child: Column(
            children: <Widget>[
              AspectRatio(
                aspectRatio: 485.0 / 384.0,
                child: Image.network("https://picsum.photos/485/384?image=$num"),
              ),
              Material(
                child: ListTile(
                  title: Text("Item $num"),
                  subtitle: Text("This is item #$num"),
                ),
              ),
              Expanded(
                child: Center(child: Text("Some more content goes here!")),
              )
            ],
          ),
        ),
      ),
      Column(
        children: <Widget>[
          Container(
            height: mediaQuery.padding.top,
          ),
          ConstrainedBox(
            constraints: BoxConstraints(maxHeight: appBar.preferredSize.height),
            child: appBar,
          )
        ],
      ),
    ]);
  }
}

EDIT: in response to a comment, I'm going to write an explanation of how Hero works (or at least how I think it works =D).

Basically, when a transition between pages is started, the underlying mechanism that performs the transition (part of the Navigator more or less) looks for any 'hero' widgets in the current page and the new page. If a hero is found, its size and position is calculated for each of the pages.

As the transition between the pages is performed, the hero from the new page is moved to an overlay in the same place as the old hero, and then its size and position is animated towards its final size and position in the new page. (Note that you can change if you want with a bit of work - see this blog for more information about that).

This is what the OP was trying to achieve:

When you tap on a Card, its background color expands and becomes a background color of a Scaffold with an Appbar.

The easiest way to do this is to simply put the scaffold itself in the hero. Anything else will obscure the AppBar during the transition, as while it's doing the hero transition it is in an overlay. See the code below. Note that I've added in a class to make the transition happen slower so you can see what's going on, so to see it at normal speed change the part where it pushes a SlowMaterialPageRoute back to a MaterialPageRoute.

That looks something like this:

import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.deepPurple,
        ),
        body: ListView.builder(
          itemBuilder: (context, index) {
            return TileItem(num: index);
          },
        ),
      ),
    );
  }
}

Color colorFromNum(int num) {
  var random = Random(num);
  var r = random.nextInt(256);
  var g = random.nextInt(256);
  var b = random.nextInt(256);
  return Color.fromARGB(255, r, g, b);
}

class TileItem extends StatelessWidget {
  final int num;

  const TileItem({Key key, this.num}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: "card$num",
      child: Card(
        color: colorFromNum(num),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.all(
            Radius.circular(8.0),
          ),
        ),
        clipBehavior: Clip.antiAliasWithSaveLayer,
        child: Stack(
          children: <Widget>[
            Column(
              children: <Widget>[
                AspectRatio(
                  aspectRatio: 485.0 / 384.0,
                  child: Image.network("https://picsum.photos/485/384?image=$num"),
                ),
                Material(
                  type: MaterialType.transparency,
                  child: ListTile(
                    title: Text("Item $num"),
                    subtitle: Text("This is item #$num"),
                  ),
                )
              ],
            ),
            Positioned(
              left: 0.0,
              top: 0.0,
              bottom: 0.0,
              right: 0.0,
              child: Material(
                type: MaterialType.transparency,
                child: InkWell(
                  onTap: () async {
                    await Future.delayed(Duration(milliseconds: 200));
                    Navigator.push(
                      context,
                      SlowMaterialPageRoute(
                        builder: (context) {
                          return new PageItem(num: num);
                        },
                        fullscreenDialog: true,
                      ),
                    );
                  },
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class PageItem extends StatelessWidget {
  final int num;

  const PageItem({Key key, this.num}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Hero(
      tag: "card$num",
      child: Scaffold(
        backgroundColor: colorFromNum(num),
        appBar: AppBar(
          backgroundColor: Colors.white.withOpacity(0.2),
        ),
      ),
    );
  }
}

class SlowMaterialPageRoute<T> extends MaterialPageRoute<T> {
  SlowMaterialPageRoute({
    WidgetBuilder builder,
    RouteSettings settings,
    bool maintainState = true,
    bool fullscreenDialog = false,
  }) : super(builder: builder, settings: settings, fullscreenDialog: fullscreenDialog);

  @override
  Duration get transitionDuration => const Duration(seconds: 3);
}

However, there are situations in which it might not be optimal to have the entire scaffold doing the transition - maybe it has a lot of data, or is designed to fit in a specific amount of space. In that case, an option to make a version of whatever you want to do the hero transition that is essentially a 'fake' - i.e. have a stack with two layers, one which is the hero and has a background colour, scaffold, and whatever else you want to show up during the transition, and another layer on top which completely obscures the bottom layer (i.e. has a background with 100% opacity) that also has an app bar and whatever else you want.

There are probably better ways of doing it than that - for example, you could specify the hero separately using the method mentioned in the blog I linked to.

Solution 2

I achieved this by using the Flutter Hero Animation Widget. In order to do that you will need:

  1. A source page where you start from and that contains the card you want to expand to full screen. Let's call it 'Home'
  2. A destination page that will represent how your card will look like once expanded. Let's call it 'Details'.
  3. (Optional) A data model to store data

Now let's take a look at this example below (You can find the full project code here):

First, let's make an Item class (i will put it in models/item.dart) to store our data. Each item will have its own id, title, subtitle, details and image url :

import 'package:flutter/material.dart';

class Item {
  String title, subTitle, details, img;
  int id;

  Item({this.id, this.title, this.subTitle, this.details, this.img});
}

Now, let's initialize our material app in the main.dart file :

import 'package:flutter/material.dart';

import 'package:expanding_card_animation/home.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: Home(),
    );
  }
}

Next, we will make our home page. It'll be a simple stateless widget, and will contain a list of Items that will be displayed in a ListView of Cards. A gesture detector is used to expand the card when tapping it. The expansion is just a navigation to the details page, but with the Hero animation, it looks like it just expanded the Card.

import 'package:flutter/material.dart';

import 'package:expanding_card_animation/details.dart';
import 'package:expanding_card_animation/models/item.dart';

class Home extends StatelessWidget {
  List<Item> listItems = [
    Item(
        id: 1,
        title: 'Title 1',
        subTitle: 'SubTitle 1',
        details: 'Details 1',
        img:
            'https://d1fmx1rbmqrxrr.cloudfront.net/cnet/i/edit/2019/04/eso1644bsmall.jpg'),
    Item(
        id: 2,
        title: 'Title 2',
        subTitle: 'SubTitle 2',
        details: 'Details 2',
        img:
            'https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__340.jpg'),
    Item(
        id: 3,
        title: 'Title 3',
        subTitle: 'SubTitle 3',
        details: 'Details 3',
        img: 'https://miro.medium.com/max/1200/1*mk1-6aYaf_Bes1E3Imhc0A.jpeg'),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home screen'),
      ),
      body: Container(
        margin: EdgeInsets.fromLTRB(40, 10, 40, 0),
        child: ListView.builder(
            itemCount: listItems.length,
            itemBuilder: (BuildContext c, int index) {
              return GestureDetector(
                onTap: () {
                  Navigator.push(
                    context,
                    MaterialPageRoute(
                        builder: (context) => Details(listItems[index])),
                  );
                },
                child: Card(
                  elevation: 7,
                  shape: RoundedRectangleBorder(
                    side: BorderSide(color: Colors.grey[400], width: 1.0),
                    borderRadius: BorderRadius.circular(10.0),
                  ),
                  margin: EdgeInsets.fromLTRB(0, 0, 0, 20),
                  child: Column(
                    children: [
                      //Wrap the image widget inside a Hero widget
                      Hero(
                        //The tag must be unique for each element, so we used an id attribute
                        //in the item object for that
                        tag: '${listItems[index].id}',
                        child: Image.network(
                          "${listItems[index].img}",
                          scale: 1.0,
                          repeat: ImageRepeat.noRepeat,
                          fit: BoxFit.fill,
                          height: 250,
                        ),
                      ),
                      Divider(
                        height: 10,
                      ),
                      Text(
                        listItems[index].title,
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      SizedBox(
                        height: 20,
                      ),
                    ],
                  ),
                ),
              );
            }),
      ),
    );
  }
}

Finally, let's make the details page. It's also a simple stateless widget that will take the item's info as an input, and display them on full screen. Note that we wrapped the image widget inside another Hero widget, and make sure that you use the same tags used in the source page(here, we used the id in the passed item for that) :

import 'package:flutter/material.dart';

import 'package:expanding_card_animation/models/item.dart';

class Details extends StatelessWidget {
  final Item item;

  Details(this.item);

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.transparent,
          elevation: 0,
        ),
        extendBodyBehindAppBar: true,
        body: Container(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Hero(
                //Make sure you have the same id associated to each element in the
                //source page's list
                tag: '${item.id}',
                child: Image.network(
                  "${item.img}",
                  scale: 1.0,
                  repeat: ImageRepeat.noRepeat,
                  fit: BoxFit.fitWidth,
                  height: MediaQuery.of(context).size.height / 3,
                ),
              ),
              SizedBox(
                height: 30,
              ),
              ListTile(
                title: Text(
                  item.title,
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                    fontSize: 20,
                  ),
                ),
                subtitle: Text(item.subTitle),
              ),
              Divider(
                height: 20,
                thickness: 1,
              ),
              Padding(
                padding: EdgeInsets.only(left: 20),
                child: Text(
                  item.details,
                  style: TextStyle(
                    fontSize: 25,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

And that's it, now you can customize it as you wish. Hope i helped.

Share:
19,000
Matt
Author by

Matt

Updated on July 19, 2022

Comments

  • Matt
    Matt almost 2 years

    I would like to achieve the material design card behavior on tap. When I tap it, it should expand fullscreen and reveal additional content/new page. How do I achieve it?

    https://material.io/design/components/cards.html#behavior

    I tried with Navigator.of(context).push() to reveal new page and play with Hero animations to move the card background to new Scaffold, however it seems it is not the way to go since new page is not revealing from the card itself, or I cannot make it to. I am trying to achieve the same behavior as in the material.io that I presented above. Would you please guide me somehow?

    Thank you

  • Matt
    Matt over 5 years
    Thank you, that's close enough :) One more question, can I somehow achieve the same effect when navigating from a Card to a new page with a Scaffold? What I also want to achieve is that when you tap on a Card, it's background color expands and becomes a background color of a Scaffold with an Appbar. I tried that with a stack with two children: container with a color and a scaffold with color: transparent so the previous container maintains the color of the scaffold. However, when using hero animation of the background, the scaffold's body is covered by the hero widget when entering the screen.
  • rmtmckenzie
    rmtmckenzie over 5 years
    @Matt I think you need a bit more context on how hero animations work, so I've added a bit to my answer. Hope it helps =)
  • Matt
    Matt over 5 years
    Thanks for your time and explanations, that cleared up the Hero animations for me :) I will now apply that knowledge to the view I'm working on.
  • Arda Çebi
    Arda Çebi about 4 years
    Hey @rmtmckenzie, if possible, can you add a simple example without the ordered numbers and the images from the internet? (like just one card and a simple expanded page with content) Thanks!