【问题标题】:How to expand a card on tap in flutter?如何在颤动中扩展卡片?
【发布时间】:2019-04-17 20:22:05
【问题描述】:

我想实现材料设计卡片的点击行为。当我点击它时,它应该会展开全屏并显示其他内容/新页面。如何实现?

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

我尝试使用 Navigator.of(context).push() 来显示新页面并使用 Hero 动画将卡片背景移动到新的 Scaffold,但是这似乎不是可行的方法,因为新页面没有显示从卡本身,否则我无法做到。我正在尝试实现与我在上面介绍的 material.io 中相同的行为。请您以某种方式指导我吗?

谢谢

【问题讨论】:

  • 您应该显示一些代码,因为据我所知,英雄动画实际上看起来像是从卡片本身显示出来的。
  • @Ringil 感谢您的回复。看来你是对的,因为 rmtmckenzie 达到了效果:) 我只是把英雄搞砸了。
  • 在 AppBar 中添加 elevation:0 以获得完美效果!
  • 你可以看这个视频-youtube.com/watch?v=2aJZzRMziJc

标签: android ios dart flutter material-design


【解决方案1】:

不久前,我尝试复制那个确切的页面/过渡,虽然我没有让它看起来完全像它,但我确实非常接近。请记住,这是快速组合起来的,并没有真正遵循最佳实践或其他任何东西。

重要的部分是英雄小部件,尤其是伴随它们的标签 - 如果它们不匹配,它就不会这样做。

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,
          )
        ],
      ),
    ]);
  }
}

编辑:作为对评论的回应,我将解释 Hero 的工作原理(或者至少我认为它是如何工作的 =D)。

基本上,当页面之间的转换开始时,执行转换的底层机制(或多或少是导航器的一部分)会在当前页面和新页面中查找任何“英雄”小部件。如果找到英雄,则会为每个页面计算其大小和位置。

随着页面之间的过渡,新页面中的英雄被移动到与旧英雄相同的位置的叠加层,然后其大小和位置被动画化为它在新页面中的最终大小和位置. (请注意,如果您愿意,可以通过一些工作进行更改 - 请参阅 this blog 了解更多信息)。

这是 OP 试图实现的目标:

当你点击一张卡片时,它的背景颜色会扩展并变成带有 Appbar 的 Scaffold 的背景颜色。

最简单的方法是简单地将脚手架本身放入英雄中。任何其他的东西都会在过渡期间遮挡 AppBar,因为它在进行 hero 过渡时它是在一个叠加层中。请参阅下面的代码。请注意,我在一个类中添加了一个使转换发生得更慢的类,以便您可以看到发生了什么,因此要以正常速度查看它,请更改将 SlowMaterialPageRoute 推回 MaterialPageRoute 的部分。

看起来像这样:

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);
}

但是,在某些情况下,让整个脚手架进行过渡可能不是最佳选择 - 可能它有很多数据,或者设计为适合特定数量的空间。在这种情况下,可以选择制作一个基本上是“假”的英雄转换版本 - 即有一个包含两层的堆栈,一层是英雄,具有背景颜色、脚手架等否则你想在过渡期间显示,顶部的另一层完全遮住了底层(即具有 100% 不透明度的背景),它还有一个应用栏和任何你想要的东西。

可能有比这更好的方法 - 例如,您可以使用the blog I linked to中提到的方法单独指定英雄。

【讨论】:

  • 谢谢,已经够接近了 :) 还有一个问题,当从卡片导航到带有脚手架的新页面时,我能以某种方式达到相同的效果吗?我还想要实现的是,当您点击卡片时,它的背景颜色会扩展并成为带有 Appbar 的 Scaffold 的背景颜色。我尝试了一个带有两个孩子的堆栈:具有颜色的容器和具有颜色的脚手架:透明,因此前一个容器保持脚手架的颜色。但是,当使用背景的英雄动画时,脚手架的主体在进入屏幕时被英雄小部件覆盖。
  • @Matt 我认为您需要更多关于英雄动画如何工作的背景信息,所以我在答案中添加了一些内容。希望对你有帮助 =)
  • 感谢您的时间和解释,它们为我清理了英雄动画 :) 我现在将把这些知识应用到我正在处理的视图中。
  • 嘿@rmtmckenzie,如果可能的话,你能添加一个没有订购数字和互联网图像的简单例子吗? (就像一张卡片和一个包含内容的简单扩展页面)谢谢!
【解决方案2】:

我通过使用Flutter Hero Animation Widget 实现了这一点。为此,您需要:

  1. 您从其中开始的源页面,其中包含要展开为全屏的卡片。让我们称之为“家”
  2. 一个目标页面,它将代表您的卡片展开后的外观。我们称之为“详细信息”。
  3. (可选)用于存储数据的数据模型

现在让我们看看下面这个例子(你可以找到完整的项目代码here):

首先,让我们创建一个 Item 类(我将把它放在 models/item.dart 中)来存储我们的数据。每个项目都有自己的 id、title、subtitle、details 和 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});
}

现在,让我们在 main.dart 文件中初始化我们的材质应用程序:

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(),
    );
  }
}

接下来,我们将制作我们的主页。这将是一个简单的无状态小部件,并将包含将显示在卡片 ListView 中的项目列表。手势检测器用于在点击卡片时展开卡片。展开只是详细信息页面的导航,但在英雄动画中,它看起来就像只是展开了卡片。

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,
                      ),
                    ],
                  ),
                ),
              );
            }),
      ),
    );
  }
}

最后,让我们制作详细信息页面。它也是一个简单的无状态小部件,它将项目的信息作为输入,并全屏显示。请注意,我们将图像小部件包装在另一个英雄小部件中,并确保您使用与源页面中相同的标签(这里,我们使用了传递项目中的 id):

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,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

就是这样,现在您可以根据需要自定义它。希望我有所帮助。

【讨论】:

    猜你喜欢
    • 2020-08-11
    • 2021-03-11
    • 2021-09-29
    • 2020-01-14
    • 1970-01-01
    • 1970-01-01
    • 2020-01-26
    • 1970-01-01
    • 2023-03-22
    相关资源
    最近更新 更多