【Flutter 组件集录】Card | 8 月更文挑战

2,522 阅读5分钟
前言:

这是我参与8月更文挑战的第 20 天,活动详情查看:8月更文挑战。为应掘金的八月更文挑战,我准备在本月挑选 31 个以前没有介绍过的组件,进行全面分析和属性介绍。这些文章将来会作为 Flutter 组件集录 的重要素材。希望可以坚持下去,你的支持将是我最大的动力~

本系列组件文章列表
1.NotificationListener2.Dismissible3.Switch
4.Scrollbar5.ClipPath6.CupertinoActivityIndicator
7.Opacity8.FadeTransition9. AnimatedOpacity
10. FadeInImage11. Offstage12. TickerMode
13. Visibility14. Padding15. AnimatedContainer
16.CircleAvatar17.PhysicalShape18.Divider
19.Flexible、Expanded 和 Spacer 20.Card [本文]

一、 认识 Card 组件

卡片效果作为 Material Design 中的一员,Flutter 中 Card 组件自然是要有的。源码注释中是这么描述它的:带有轻微圆角和立面阴影的面板。本文将从源码的角度看一下 Card 组件的构成,并讲述一下 Card 在使用中的一些细小的注意点。


1.Card 基本信息

下面是 Card 组件类的定义构造方法,可以看出它继承自 StatelessWidget。没有必须要传入的参数,可以配置颜色、阴影色、形状、边距等属性。


2.Card 的简单使用

如下所示,通过 buildContent 返回 Container 组件作为内容。上层用 Card 组件的包裹后,会有小圆角 + 阴影 的效果,其中 color 属性就是面板的颜色。

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Card(
          color: const Color(0xffB3FE65),
          child: buildContent(),
        ),
      ),
    );
  }
  
  Widget buildContent() {
    return Container(
        width: 200,
        height: 0.618 * 200,
        padding: const EdgeInsets.all(10),
        child: Text("Card : 卡片", style: TextStyle(fontSize: 20)));
  }
}

2. shadowColor 和 elevation 属性

通过 shadowColor 可以设置阴影的颜色,通过 elevation 可以设置阴影的深度。

Card(
  color: Color(0xffB3FE65),
  shadowColor: Colors.blueAccent,
  elevation: 8,
  child: buildContent(),
)

3.margin 属性

单独一个 Card 也许看不清外边距,可以使用两个辅助的 box 看一下。如下,可以看出 Card 默认是有外边距的。调节外边距的属性便是 margin

使用下面的代码,就可以让左外边距为 20,右外边距为 30.

Card(
  margin: EdgeInsets.only(left: 20,right: 30),
  color: Color(0xffB3FE65),
  child: buildContent(),
)

4. clipBehavior 裁剪行为

Clip 是一个枚举类,包含四种形式,如下:

enum Clip {
  none, // 无
  hardEdge, // 硬边缘
  antiAlias, // 抗锯齿
  antiAliasWithSaveLayer, // 抗锯齿保存图层
}

如下左图,在内容的容器中使用图片装饰,你会很疑惑,为什么没有圆角了。因为 Card 的默认裁剪行为为 Clip.none。这时需要通过指定 clipBehavior 完成圆角,这是一个小细节,不知道的话很可能觉得 Card 组件不好用。

Clip.noneClip.antiAlias
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child:
            Card(
              clipBehavior: Clip.antiAlias, //<--- 裁剪行为
              color: const Color(0xffB3FE65),
              child: buildContent(),
            ),
      ),
    );
  }
  Widget buildContent() {
    return Container(
        width: 200,
        height: 0.618 * 200,
        padding: const EdgeInsets.all(10),
        decoration: const BoxDecoration( //<--- 添加装饰图片
            image: DecorationImage(
                fit: BoxFit.cover,
                image: AssetImage('assets/images/anim_draw.webp')
            )
        ),
        child: Text("Card : 卡片", style: TextStyle(fontSize: 20,color: Colors.white)));
  }
}

5. shape 属性

前面只是简单的属性配置,而你 Card 的强大不止于此。也许你会觉得默认的圆角有点小,想要变大点,或不喜欢圆角装饰,先要搞点创造性装饰,那么 shape 属性将为你打开一扇大门。需要的是一个 ShapeBorder 对象,由于其为抽象类,需要找它的子类,框架中提供如下的子类。关于 shape 属性的适应,之前在《Path在手,天下我有》 中详细介绍过,这里不再赘述。

比如想要增加圆角,可以使用 RoundedRectangleBorder 形状。

Card(
  clipBehavior: Clip.antiAlias,
  color: const Color(0xffB3FE65),
  shape: const RoundedRectangleBorder(
      side: BorderSide.none,
      borderRadius: BorderRadius.all(Radius.circular(10))),
  elevation: 3,
  shadowColor: Colors.blueAccent,
  child: buildContent(),
),

除了内置的形状之外,我们还可以自己定义 Shape, 比如下面通过 nStarPath 获取一个多角星的路径,然后在继承自 ShapeBorder 的 StarShapeBorder#getOuterPath 中返回路径,就可以按照该路径进行裁剪。这里为了方便,多角星的数据写死了,外界的容器宽高该为 100

class StarShapeBorder extends ShapeBorder {
  @override
  EdgeInsetsGeometry get dimensions => null;

  @override
  Path getInnerPath(Rect rect, {TextDirection textDirection}) {
    return null;
  }

  @override
  Path getOuterPath(Rect rect, {TextDirection textDirection}) =>
      nStarPath(9, 50, 40, dx: 50, dy: 50);

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
    
  }

  @override
  ShapeBorder scale(double t) {
    return null;
  }

  Path nStarPath(int num, double R, double r, {dx = 0, dy = 0}) {
    Path _path = Path();
    _path.reset(); //重置路径
    double perRad = 2 * pi / num; //每份的角度
    double radA = perRad / 2 / 2; //a角
    double radB = 2 * pi / (num - 1) / 2 - radA / 2 + radA; //起始b角
    _path.moveTo(cos(radA) * R + dx, -sin(radA) * R + dy); //移动到起点
    for (int i = 0; i < num; i++) { //循环生成点,路径连至
      _path.lineTo(
          cos(radA + perRad * i) * R + dx, -sin(radA + perRad * i) * R + dy);
      _path.lineTo(
          cos(radB + perRad * i) * r + dx, -sin(radB + perRad * i) * r + dy);
    }
    _path.close();
    return _path;
  }
}

5.borderOnForeground 属性

这个属性估计没人在意它,它可以决定 ShapeBorder 的绘制是否显示在前景之中。通过上面可以看到 StarShapeBorder 中有个 paint 方法可以提供绘制操作,这里简单在区域左上角画个小圈。默认 borderOnForegroundtrue,绘制的装饰会显在前景中,如下图。

@override
void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
  canvas.drawCircle(Offset.zero, 50, Paint()..color=Colors.blueAccent);
}

如果 borderOnForeground 设置为 false,就说明绘制的内容不出现在前景中。


二、Card 的水波纹

1.错误的使用

如果你将 InkWell 放在了 Center 之上,那么它水波纹会被前景所覆盖。如下图所示,之上 margin 的那点区域显示出来水波纹。

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child:  InkWell(
        onTap: (){
        },
        child:Card(
        clipBehavior: Clip.antiAlias,
        color: const Color(0xffB3FE65),
        elevation: 3,
        shadowColor: Colors.blueAccent,
        child: buildContent()),
      ),
    ),
  );
}

2. 正确的使用

正确的使用方式是在 child 组件上嵌套 InkWell

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child:  Card(
        clipBehavior: Clip.antiAlias,
        color: const Color(0xffB3FE65),
        elevation: 3,
        shadowColor: Colors.blueAccent,
        child: InkWell(
            splashColor: Colors.blue.withAlpha(30),
            onTap: (){
            },
            child:buildContent()),
      ),
    ),
  );
}

3.无法触发水波纹的解决方案

有些时候,比如使用 Image、或为 Container 设置颜色、装之后,水波纹就无法触发。

这是可以通过 Ink 组件来替代 ContainerImage 源码中是怎么说的:

Widget buildContent() {
  return Ink(
      width: 200,
      height: 0.618 * 200,
      decoration: const BoxDecoration(
          image: DecorationImage(
              fit: BoxFit.cover,
              image: AssetImage('assets/images/anim_draw.webp'))),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Text("Card: 卡片",
            style: TextStyle(fontSize: 20,color: Colors.white)),
      ));
}

三、Card 的源码分析

好啦,又到最后看源码的时间了。Card 组件作为一个 StatelessWidget,肯定是 “白嫖” 了别的组件功能。核心代码如下:可以看出它就是一个 Container + Material 组件的组合体。

那为什么一个件简单的的对象,要单独抽离一个 Card 组件呢?很明显,语义明确,简单易用,简单 就是王道。另外一点就是可以统一设置 CardTheme 来决定 Card 的默认表现。如果没有 Card 组件,想达到效果可以用 Material 组件,但每次用都要设置很多对象,而且无法设置主题,使用封装是为了更好地使用。

@override
Widget build(BuildContext context) {
  final ThemeData theme = Theme.of(context);
  final CardTheme cardTheme = CardTheme.of(context);
  return Semantics(
    container: semanticContainer,
    child: Container(
      margin: margin ?? cardTheme.margin ?? const EdgeInsets.all(4.0),
      child: Material(
        type: MaterialType.card,
        shadowColor: shadowColor ?? cardTheme.shadowColor ?? theme.shadowColor,
        color: color ?? cardTheme.color ?? theme.cardColor,
        elevation: elevation ?? cardTheme.elevation ?? _defaultElevation,
        shape: shape ?? cardTheme.shape ?? const RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(4.0)),
        ),
        borderOnForeground: borderOnForeground,
        clipBehavior: clipBehavior ?? cardTheme.clipBehavior ?? Clip.none,
        child: Semantics(
          explicitChildNodes: !semanticContainer,
          child: child,
        ),
      ),
    ),
  );
}

通过之前看的几个 StatelessWidget 的组件可以发现,这种类型的组件主要的目的就是方便用户使用,其内部都是依赖于别的组件实现的,使用在看 StatelessWidget 时多看看内部的实现方式,就可以将很多组件联系到一块,很多曾经的疑惑点,也就能迎刃而解。了解了内部的实现,在使用时,也会多几分底气。那本文到这里就结束了,谢谢观看,明天见~