iOS- Flutter 容器类组件-Clip&FittedBox

335 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 23 天,点击查看活动详情

Clip 裁剪类组件

Flutter中提供了一些裁剪组件,用于对组件进行裁剪,iOS中的裁剪功能比较简单,通常就是圆角的一些简单操作,而Flutter中的裁剪功能比较丰富。

裁剪Widget默认行为
ClipOval子组件为正方形时剪裁成内贴圆形;矩形时剪裁成内贴椭圆
ClipRRect将子组件剪裁为圆角矩形
ClipRect默认剪裁掉子组件布局空间之外的绘制内容(溢出部分裁剪)
ClipPath按照自定义的路径剪裁

实例:

class ClipTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 头像  
    Widget avatar = Image.asset("imgs/avatar.png", width: 60.0);
    return Center(
      child: Column(
        children: <Widget>[
          avatar, //不剪裁
          ClipOval(child: avatar), //剪裁为圆形
          ClipRRect( //剪裁为圆角矩形
            borderRadius: BorderRadius.circular(5.0),
            child: avatar,
          ), 
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Align(
                alignment: Alignment.topLeft,
                widthFactor: .5,//宽度设为原来宽度一半,另一半会溢出
                child: avatar,
              ),
              Text("你好世界", style: TextStyle(color: Colors.green),)
            ],
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              ClipRect(//将溢出部分剪裁
                child: Align(
                  alignment: Alignment.topLeft,
                  widthFactor: .5,//宽度设为原来宽度一半
                  child: avatar,
                ),
              ),
              Text("你好世界",style: TextStyle(color: Colors.green))
            ],
          ),
        ],
      ),
    );
  }
}

自定义裁剪(CustomClipper)

//自定义裁剪
class MyClipper extends CustomClipper<Rect> {
//用于获取剪裁区域的接口
  @override
  Rect getClip(Size size) => Rect.fromLTWH(10.0, 15.0, 40.0, 30.0);
//接口决定是否重新剪裁。如果应用中,剪裁区域始终不会发生变化时应该返回false,这样就不会触发重新剪裁,避免不必要的性能开销。如果剪裁区域发生变化(比如对剪裁区域执行一个动画),那么变化后应该返回true来重新绘制
  @override
  bool shouldReclip(CustomClipper<Rect> oldClipper) => false;
}

使用实例:

DecoratedBox(
  decoration: BoxDecoration(
    color: Colors.red
  ),
  child: ClipRect(
    clipper: MyClipper(), //使用自定义的clipper
    child: avatar
  ),
)

ClipPath可以按照自定义的路径实现剪裁,它需要自定义一个CustomCliper 类型的Clipper,定义方式和MyClipper类似,只不过getClip需要返回一个Path。

FittedBox

子组件超出了父组件大小时,如果不经过处理的话Flutter中就会显示一个溢出警告,并打印错误日志。

//比如
Padding(
  padding: const EdgeInsets.symmetric(vertical: 30.0),
  child: Row(children: [Text('xx'*30)]), //文本长度超出 Row 的最大宽度会溢出
)

根据Flutter的布局协议,父组件会将自身的最大显示空间作为约束传递给子组件,子组件应该遵守父组件的约束,如果子组件原始大小超过了父组件的约束区域,则需要进行一些缩小,剪裁或其他处理,而不同的组件的处理方式是特定的,比如Text组件,如果它的父组件宽度固定,高度不限的话,则默认情况下Text会在文本达到父组件宽度的时候进行换行。如果我们想让Text文本在超过父组件宽度时不换行而是字体缩小,或者父组件宽高固定,而Text文本较少,想让文本放大以填充整个父组件的空间。这种情况的本质就是:子组件如何适配父组件空间。根据Flutter布局协议适配算法应该在容器或布局组件的layout中实现,为了方便开发者自定义适配规则,Flutter提供了FittedBox组件。

const FittedBox({
  Key? key,
  this.fit = BoxFit.contain, // 适配方式
  this.alignment = Alignment.center, //对齐方式
  this.clipBehavior = Clip.none, //是否剪裁
  Widget? child,
})

原理:

  • FittedBox在布局子组件时会忽略其父组件传递的约束,可以允许子组件无限大,即FittedBox传递给子组件的约束为(0<=width<=double.infinity,0<=height<=double.infinity)。
  • FittedBox对子组件布局结束后就可以获得子组件真实的大小。
  • FittedBox知道子组件的真实大小也知道它父组件的约束,那么FittedBox就可以通过指定的适配方式(BoxFit枚举中指定),让其子组件在FittedBox父组件的约束范围内按照指定的方式显示。

举例:

//父Container要比子Container小,所以当没有指定任何适配方式时,子组件会按照其真实大小进行绘制,所以蓝色区域会产出父组件的空间,因而看不到红色区域。
Widget build(BuildContext context) {
  return Center(
    child: Column(
      children: [
        wContainer(BoxFit.none),
        Text('test sssss 1111'),
        wContainer(BoxFit.contain),
        Text('Flutter中国'),
      ],
    ),
  );
}
//指定适配方式BoxFit.contain,含义是按照子组件的比例缩放,尽可能多的占据父组件空间,因为子组件的长宽并不相同,所以按照比例缩放适配父组件后,父组件能显示一部分。
Widget wContainer(BoxFit boxFit) {
  return Container(
    width: 50,
    height: 50,
    color: Colors.red,
    child: FittedBox(
      fit: boxFit,
      // 子容器超过父容器大小
      child: Container(width: 60, height: 70, color: Colors.blue),
    ),
  );
}

需要注意的是:在未指定适配方式时,虽然FittedBox子组件的大小超过了FittedBox父Container的空间,但FittedBox自身还是需要遵守其父组件传递的约束,所以最终FittedBox的本身的大小是5050,这也是为什么蓝色会和下面文本重叠的原因,因为在布局空间内,父Container只占5050的大小,接下来文本会紧挨着Container进行布局,而此时Container中有子组件的大小超过了自己,所以最终的效果就是绘制范围超出了Container,布局位置是正常的,所以重叠了,如果不想让蓝色超出父组件的布局范围,那么可以使用ClipRect对超出部分进行裁剪。

ClipRect( // 将超出子组件布局范围的绘制内容剪裁掉
  child: Container(
    width: 50,
    height: 50,
    color: Colors.red,
    child: FittedBox(
      fit: boxFit,
      child: Container(width: 60, height: 70, color: Colors.blue),
    ),
  ),
);

实例

@override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children:  [
          wRow(' 90000000000000000 '),
          FittedBox(child: wRow(' 90000000000000000 ')),
          wRow(' 800 '),
          FittedBox(child: wRow(' 800 ')),
    		]
        .map((e) => Padding(
              padding: EdgeInsets.symmetric(vertical: 20),
              child: e,
            ))
        .toList();,
      ),
    );
  }

 // 直接使用Row
  Widget wRow(String text) {
    Widget child = Text(text);
    child = Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,//将水平方向剩余显示空间均分成多份穿插在每一个child之间。
      children: [child, child, child],
    );
    return child;
  }

效果

可以看到,当数字为' 90000000000000000 '时,三个数字的长度加起来已经超出了测试设备的屏幕宽度,所以直接使用 Row 会溢出,当给 Row 添加上如果加上 FittedBox时,就可以按比例缩放至一行显示,实现了我们预期的效果。但是当数字没有那么大时,比如下面的 ' 800 ',直接使用 Row 是可以的,但加上 FittedBox 后三个数字虽然也能正常显示,但是它们却挤在了一起,这不符合我们的期望。之所以会这样,原因其实很简单:在指定主轴对齐方式为 spaceEvenly 的情况下,Row 在进行布局时会拿到父组件的约束,如果约束的 maxWidth 不是无限大,则 Row 会根据子组件的数量和它们的大小在主轴方向来根据 spaceEvenly 填充算法来分割水平方向的长度,最终Row 的宽度为 maxWidth;但如果 maxWidth 为无限大时,就无法在进行分割了,所以此时 Row 就会将子组件的宽度之和作为自己的宽度。

回示例中,当 Row 没有被 FittedBox 包裹时,此时父组件传给 Row 的约束的 maxWidth 为屏幕宽度,此时,Row 的宽度也就是屏幕宽度,而当被FittedBox 包裹时,FittedBox 传给 Row 的约束的 maxWidth 为无限大(double.infinity),因此Row 的最终宽度就是子组件的宽度之和。 父组件传递给子组件的约束可以用我们上一章中封装的 LayoutLogPrint 来打印出来:

LayoutLogPrint(tag: 1, child: wRow(' 800 ')),
FittedBox(child: LayoutLogPrint(tag: 2, child: wRow(' 800 '))),
//输出
flutter: 1: BoxConstraints(0.0<=w<=396.0, 0.0<=h<=Infinity)
flutter: 2: BoxConstraints(unconstrained)

问题原因找到了,那解决的思路就很简单了,我们只需要让FittedBox 子元素接收到的约束的 maxWidth 为屏幕宽度即可,为此我们封装了一个 SingleLineFittedBox 来替换 FittedBox 以达到我们预期的效果,实现如下:

class SingleLineFittedBox extends StatelessWidget {
 const SingleLineFittedBox({Key? key,this.child}) : super(key: key);
 final Widget? child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (_, constraints) {
        return FittedBox(
          child: ConstrainedBox(
            constraints: constraints.copyWith(
              //让 maxWidth 使用屏幕宽度
              maxWidth: constraints.maxWidth
            ),
            child: child,
          ),
        );
      },
    );
  }
}

//将上面代码替换为
wRow(' 90000000000000000 '),
SingleLineFittedBox(child: wRow(' 90000000000000000 ')),
wRow(' 800 '),
SingleLineFittedBox(child: wRow(' 800 ')),

效果图

返现 800 正常显示了,但用SingleLineFittedBox包裹的 ' 90000000000000000 ' 的那个 Row 却溢出了!溢出的原因其实也很简单,因为我们在 SingleLineFittedBox 中将传给 Row 的 maxWidth 置为屏幕宽度后,效果和不加SingleLineFittedBox 的效果是一样的,Row 收到父组件约束的 maxWidth 都是屏幕的宽度,所以搞了半天实现了个寂寞。但是,不要放弃,其实离胜利只有一步,只要我们稍加修改,就能实现我们的预期,话不多说,直接上代码:

class SingleLineFittedBox extends StatelessWidget {
  const SingleLineFittedBox({Key? key,this.child}) : super(key: key);
 final Widget? child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (_, constraints) {
        return FittedBox(
          child: ConstrainedBox(
            constraints: constraints.copyWith(
              minWidth: constraints.maxWidth,
              maxWidth: double.infinity,
              //maxWidth: constraints.maxWidth
            ),
            child: child,
          ),
        );
      },
    );
  }
}

代码很简单,将最小宽度(minWidth)约束指定为屏幕宽度,因为Row必须得遵守父组件的约束,所以 Row 的宽度至少等于屏幕宽度,所以就不会出现缩在一起的情况;同时将 maxWidth 指定为无限大,则就可以处理数字总长度超出屏幕宽度的情况。