在Flutter中使用贝塞尔曲线裁剪小部件

英文地址:Clipping widgets with bezier curves in Flutter

在本篇博文中,我将探究如何使用贝塞尔曲线剪切图片。

源码

源码在这里

实现

首先,创建一个Flutter项目,称作为wavy_image_mask,假定你已经安装了Flutter,那么你在你控制台运行flutter create wavy_image_mask,使用IntelliJ IDEA打开你的项目。

来让我们创建一个名为WavyHeaderImageStatelessWidget 控件。先在lib目录下创建wavy_header_image.dart文件,现在,我们从构建方法中返回一个占位的文本控件。

wavy_header_image.dart

1
2
3
4
5
6
7
8
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is where our image will be.');
}
}

返回main文件,移除flutter为你自动生成样本代码,使用刚刚创建的WavyHeaderImage来替换Scaffold主体,如下:

main.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import 'package:flutter/material.dart';
import 'package:wavy_image_mask/wavy_header_image.dart';

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wavy image mask',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}

class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: WavyHeaderImage(),
);
}
}

创建标题图片

现在,我需要一个好看的标题图片,我们将使用Chevanon Photography的这张图片

在flutter添加assets

为了引入包含一般的图片或文件,我们使用Flutter的assets系统。在我们的应用的根目录创建images文件夹。添加你刚才下载的coffee_header.jpeg到图片文件里面,你的目录结构看起来如下:

为了能够使得图片在我们的代码中可用访问到,我也需要在pubspec.yaml文件中的flutter部分中的引入assets,看起来如下所示:

pubspec.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: wavy_image_mask
description: A new Flutter project.

dependencies:
flutter:
sdk: flutter

dev_dependencies:
flutter_test:
sdk: flutter

flutter:
uses-material-design: true
assets:
- images/coffee_header.jpeg

现在我们就可以使用这张图片了。

制作一些波浪纹

比较笨的方法就是寻求设计师来在Photoshop中编辑我们的原始图片,创建波浪纹遮蔽。我也可以找一张使用白色绘制波浪纹的透明图片来贴在我们图像的顶部,把它固定到底部,结束一天的工作。

更好的方式是我们自己裁剪图片的波浪部分。该解决方案在不同屏幕尺寸上看起来更好。我们不需要引入额外的波浪纹遮蔽图片到我们的资产中。多亏了Flutter中一组与图像相关的API,我们可以轻松的实现。

使用ClipPaths

该类是我们用于在底部创建的波浪纹形状的组件是ClipPath组件。ClipPath需要两个参数:clipperchild。这个clipper是一个CustomClipper<Path>对象,决定了一个Path路径。ClipPath使用该路径来防止child组将在闭合的路径外绘制。

重新在打开我们在最开始的时候创建的wavy_header_image.dart文件,移除占位的文本组件,用于ClipPath取代。

child参数一个new Image.asset('image/coffee_header.jpeg')。这个仅仅是从图片文件夹载入图片并显示它。对于clipper,我们仅仅是调用了new BottomWaveClipper()。我们也许在同一文件中,创建一个新类BottomWaveClipper

下面这个是现在应该有的:

wavy_header_image.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipPath(
child: Image.asset('images/coffee_header.jpeg'),
clipper: BottomWaveClipper(),
);
}
}

class BottomWaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
// This is where we decide what part of our image is going to be
// visible. If you try to run the app now, nothing will be shown.
return Path();
}

@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
绘制路径

所有的CustomClippers在它们的getClip方法中获得一个Size参数,这个size表示传给ClipPath对象的child组件的宽度和高度。在这里,是我们想要剪切的图片的coffee图片。因为我们的CustomClipper使用路径来确定剪切的区域,所以我们使用Path类中的方法来绘制这个剪切区域。

例如,如果我们想斜着裁剪图像的右下角,最终代码会得到下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
Path getClip(Size size) {
var path = Path();

// Draw a straight line from current point to the bottom left corner.
path.lineTo(0.0, size.height);

// Draw a straight line from current point to the top right corner.
path.lineTo(size.width, 0.0);

// Draws a straight line from current point to the first point of the path.
// In this case (0, 0), since that's where the paths start by default.
path.close();
return path;
}

因为除了底部的波浪纹,我们不想剪切任何地方。所以,我们需要的在绘制一条线到右上角前,添加一个path.lineTo(size.width,size.height)。这个修改将会返回图片本身,没有任何剪切,但是设置了实际要剪切波浪纹的部分。

现在我们学习了如何用路径画直线来剪辑,让我们来画波浪纹。因为波的底部垂直方向上低于于左下角的第一个点,所以我们要把这个点向上移动一点。同样的,右下角也比左下角高一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
var path = Path();

// Since the wave goes vertically lower than bottom left starting point,
// we'll have to make this point a little higher.
path.lineTo(0.0, size.height - 20);

// TODO: The wavy clipping magic happens here, between the bottom left and bottom right points.

// The bottom right point also isn't at the same level as its left counterpart,
// so we'll adjust that one too.
path.lineTo(size.width, size.height - 40);
path.lineTo(size.width, 0.0);
path.close();
...

这将在图像的底部创建一个轻微的夹角,如下所示:

什么是二次贝塞尔曲线?

现在,我实现最终的波浪纹。为此,我们使用quadraticBezierTo方法,这允许我们很容易地在我们的路径上创建我们的曲线。

该API极其类似于lineTo方法,但是相反不是给定一个坐标,我们要给两个:一个用于我们的端点,另一个用于所谓的控制点。

根本上来说,我们仅是绘制一条直线到我们的端点,我们的控制点就像是磁铁一样,将路径的中间部分拉向它的方向,使我们的直线扭曲成一个曲线。

端点与控制

在我们这个例子中,我们需要绘制两个二次贝塞尔曲线。第一条线的控制点应该将该条线的中点向下拉。类似地,第二条线应该将该条线的中点向上拉。

如果我们看下这个模型,第一条线并不完全在水平方向上的中间位置结束。同样地,第二条线的宽度比该图片的宽度的一半更宽一些。

基于目前了解的这些,我们想要向下面这样排列这些端点以及控制点:

你可能会说:“这些曲线太大了!”你说得对。然而,这些红色控制点并不是曲线接触的地方,它们只会像磁铁一样吸引直线。最终的结果会非常接近模型。

第一条曲线

第一条曲线有下面几点可了解:

  • 端点在水平方向稍稍距离中点一段距离(未达到)。
  • 端点在垂直方向上稍稍高于起始点。
  • 控制点在水平方向上位于图片宽度的1/4处。
  • 控制点在垂直方向上位于图片的底部。

基于以上这些,第一条贝塞尔曲线看起来是这样的:

1
2
3
var fisrtControlPoint = new Offset(size.width /4,size.heigh);
var firstEndPoint = new Offset(size.width /2.25,size.height-30.0);
path.quadraticBezierTo(firstControlPoint.dx,firstControlPoint.dy,firstEndPoint.dx,firstEndPoint.dy);

我们可以跳过创建Offset对象,可直接使用线x & y 坐标,但是这里仅仅是为了可读性。这样,第一眼就能看出quadraticBezierTo方法的参数是什么。

第二条曲线

第二条曲线有下面几点可了解:

  • 端点在水平方向上位于图片的右边。
  • 端点在垂直方向上比第一条曲线的端点略高一点。
  • 控制点在水平方向上位于图片宽度的3/4处。
  • 控制点在垂直方向上在端点上方大约20个像素。

伴随着一些错误和尝试,我们得到第二条曲线:

1
2
3
4
5
var secondControlPoint =
Offset(size.width - (size.width / 3.25), size.height - 65);
var secondEndPoint = Offset(size.width, size.height - 40);
path.quadraticBezierTo(secondControlPoint.dx, secondControlPoint.dy,
secondEndPoint.dx, secondEndPoint.dy);

最后的效果如下:

下面这个是WavyHeaderImage组件的完整代码:

wavy_header_image.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipPath(
child: Image.asset('images/coffee_header.jpeg'),
clipper: BottomWaveClipper(),
);
}
}

class BottomWaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = new Path();
path.lineTo(0.0, size.height - 20);

var firstControlPoint = Offset(size.width / 4, size.height);
var firstEndPoint = Offset(size.width / 2.25, size.height - 30.0);
path.quadraticBezierTo(firstControlPoint.dx, firstControlPoint.dy,
firstEndPoint.dx, firstEndPoint.dy);

var secondControlPoint =
Offset(size.width - (size.width / 3.25), size.height - 65);
var secondEndPoint = Offset(size.width, size.height - 40);
path.quadraticBezierTo(secondControlPoint.dx, secondControlPoint.dy,
secondEndPoint.dx, secondEndPoint.dy);

path.lineTo(size.width, size.height - 40);
path.lineTo(size.width, 0.0);
path.close();

return path;
}

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