📱 引言
哈哈,2019年初我刚入职时,遇到了一个特别的需求:学校的卡片上要有个分类标签,文字部分还得镂空。当时我刚开始接触Flutter,对很多功能都不熟悉,这个需求就一直没能实现,成了我的一个小执念。现在我早已不在那儿工作了,可这两天闲来无事,突然想起了这个事。趁着五一假期,我开始琢磨画笔功能,终于把当年实现不了的功能给实现了😎!
Tip: 这时候可能会有人说:啊,这道题我会,用
ShaderMask
配置blendMode: BlendMode.srcOut
就能实现,但实际上这个组件不能设置圆角,内边距等相关内容,如果这时候添加一个Container
那么镂空效果也只能看到Container
的颜色,而不能看到最底部的图片
🛠️ 实现原理
文字镂空效果的核心是使用Canvas和自定义绘制(CustomPainter)来创建一个矩形,然后从中"切出"文字形状。我们将使用Flutter的BlendMode.dstOut
混合模式来实现这一效果。
🚀 开始实现
步骤1:创建基础应用结构 🏗️
首先,我们需要设置基本的应用结构:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Rectangle Text Cutout',
theme: ThemeData(
primarySwatch: Colors.teal,
useMaterial3: true,
),
home: const RectangleDrawingScreen(),
);
}
}
这里我们创建了一个基本的MaterialApp,并设置了主题颜色为teal(青色),启用了Material 3设计。
步骤2:创建主屏幕 📱
接下来,我们创建主屏幕,这是一个StatefulWidget,因为我们需要管理多个可变状态:
class RectangleDrawingScreen extends StatefulWidget {
const RectangleDrawingScreen({super.key});
@override
State<RectangleDrawingScreen> createState() => _RectangleDrawingScreenState();
}
class _RectangleDrawingScreenState extends State<RectangleDrawingScreen> {
// 定义状态变量
double _cornerRadius = 20.0;
String _text = "FLUTTER";
double _fontSize = 60.0;
Color _rectangleColor = Colors.teal;
Color _backgroundColor = Colors.white;
// 构建UI...
}
我们定义了几个关键状态变量:
_cornerRadius
:矩形的圆角半径_text
:要镂空的文字_fontSize
:文字大小_rectangleColor
:矩形的颜色_backgroundColor
:背景颜色
步骤3:实现自定义绘制器 🎭
这是实现镂空效果的核心部分 - 自定义绘制器:
class RectangleTextCutoutPainter extends CustomPainter {
final double cornerRadius;
final String text;
final double fontSize;
final Color rectangleColor;
RectangleTextCutoutPainter({
required this.cornerRadius,
required this.text,
required this.fontSize,
required this.rectangleColor,
});
@override
void paint(Canvas canvas, Size size) {
// 创建矩形区域
final Rect rect = Rect.fromLTWH(
20,
20,
size.width - 40,
size.height - 40,
);
// 创建圆角矩形
final RRect roundedRect = RRect.fromRectAndRadius(
rect,
Radius.circular(cornerRadius),
);
// 设置文字样式
final textStyle = TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
);
final textSpan = TextSpan(
text: text,
style: textStyle,
);
// 创建文字绘制器
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
// 计算文字位置
textPainter.layout(
minWidth: 0,
maxWidth: size.width,
);
final double xCenter = (size.width - textPainter.width) / 2;
final double yCenter = (size.height - textPainter.height) / 2;
// 使用图层和混合模式实现镂空效果
canvas.saveLayer(rect.inflate(20), Paint());
final Paint rectanglePaint = Paint()
..color = rectangleColor
..style = PaintingStyle.fill;
canvas.drawRRect(roundedRect, rectanglePaint);
final Paint cutoutPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
canvas.saveLayer(rect.inflate(20), cutoutPaint);
textPainter.paint(canvas, Offset(xCenter, yCenter));
canvas.restore();
canvas.restore();
}
@override
bool shouldRepaint(covariant RectangleTextCutoutPainter oldDelegate) {
return oldDelegate.cornerRadius != cornerRadius ||
oldDelegate.text != text ||
oldDelegate.fontSize != fontSize ||
oldDelegate.rectangleColor != rectangleColor;
}
}
这个自定义绘制器的工作原理是:
- 创建一个圆角矩形
- 使用
saveLayer
和BlendMode.dstOut
创建一个混合图层 - 在矩形上"切出"文字形状
- 使用
shouldRepaint
方法优化重绘性能
步骤4:构建UI界面 💻
现在,让我们实现主界面,包括预览区域和控制面板:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Rectangle Text Cutout'),
backgroundColor: Colors.teal.shade100,
),
body: Column(
children: [
// 预览区域
Expanded(
child: Container(
color: Colors.grey[200],
child: Center(
child: Stack(
children: [
// 背景图片
Positioned.fill(
child: Image.network(
"https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/d11fee3a97464bca82c9291435cc2a89~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5reh5YaZ5oiQ54Gw:q75.awebp?rk3s=f64ab15b&x-expires=1746213056&x-signature=ylstmk1m2eVeu8bI%2BhDJkVUHe7U%3D",
fit: BoxFit.cover,
),
),
// 自定义绘制
CustomPaint(
size: const Size(double.infinity, double.infinity),
painter: RectangleTextCutoutPainter(
cornerRadius: _cornerRadius,
text: _text,
fontSize: _fontSize,
rectangleColor: _rectangleColor,
),
),
// 额外的ShaderMask效果
ShaderMask(
blendMode: BlendMode.srcOut,
child: Text(
_text,
),
shaderCallback: (bounds) =>
LinearGradient(colors: [Colors.black], stops: [0.0])
.createShader(bounds),
),
],
),
),
),
),
// 控制面板
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[200],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 圆角控制
const Text('Corner Radius:', style: TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _cornerRadius,
min: 0,
max: 100,
divisions: 100,
label: _cornerRadius.round().toString(),
activeColor: Colors.teal,
onChanged: (value) {
setState(() {
_cornerRadius = value;
});
},
),
// 字体大小控制
const SizedBox(height: 10),
const Text('Font Size:', style: TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _fontSize,
min: 20,
max: 120,
divisions: 100,
label: _fontSize.round().toString(),
activeColor: Colors.teal,
onChanged: (value) {
setState(() {
_fontSize = value;
});
},
),
// 文字输入
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
labelText: 'Text to Cut Out',
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.teal),
),
),
onChanged: (value) {
setState(() {
_text = value;
});
},
controller: TextEditingController(text: _text),
),
// 矩形颜色选择
const SizedBox(height: 16),
Row(
children: [
const Text('Rectangle Color: ', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 10),
_buildColorButton(Colors.teal),
_buildColorButton(Colors.blue),
_buildColorButton(Colors.red),
_buildColorButton(Colors.purple),
_buildColorButton(Colors.orange),
],
),
// 背景颜色选择
const SizedBox(height: 16),
Row(
children: [
const Text('Background Color: ', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 10),
_buildBackgroundColorButton(Colors.white),
_buildBackgroundColorButton(Colors.grey.shade300),
_buildBackgroundColorButton(Colors.yellow.shade100),
_buildBackgroundColorButton(Colors.blue.shade100),
_buildBackgroundColorButton(Colors.pink.shade100),
],
),
],
),
),
],
),
);
}
步骤5:实现颜色选择按钮 🎨
最后,我们实现颜色选择按钮的构建方法:
Widget _buildColorButton(Color color) {
return GestureDetector(
onTap: () {
setState(() {
_rectangleColor = color;
});
},
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 30,
height: 30,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: _rectangleColor == color ? Colors.black : Colors.transparent,
width: 2,
),
),
),
);
}
Widget _buildBackgroundColorButton(Color color) {
return GestureDetector(
onTap: () {
setState(() {
_backgroundColor = color;
});
},
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 30,
height: 30,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: _backgroundColor == color ? Colors.black : Colors.transparent,
width: 2,
),
),
),
);
}
🔍 关键技术点解析
1. 混合模式(BlendMode)的应用 🧩
在这个效果中,最关键的技术是使用BlendMode.dstOut
混合模式。这个混合模式会从目标图像(矩形)中"减去"源图像(文字),从而创建出文字形状的"洞"。
final Paint cutoutPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
2. Canvas图层(Layer)的使用 📚
我们使用canvas.saveLayer()
和canvas.restore()
来创建和管理图层,这是实现复杂绘制效果的关键:
canvas.saveLayer(rect.inflate(20), Paint());
// 绘制矩形
canvas.saveLayer(rect.inflate(20), cutoutPaint);
// 绘制文字
canvas.restore();
canvas.restore();
3. 文字居中处理 📏
为了让文字在矩形中居中显示,我们需要计算正确的位置:
final double xCenter = (size.width - textPainter.width) / 2;
final double yCenter = (size.height - textPainter.height) / 2;
🎉 code
为了方便大家查阅,下面贴出完整代码
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Rectangle Text Cutout',
theme: ThemeData(
primarySwatch: Colors.teal,
useMaterial3: true,
),
home: const RectangleDrawingScreen(),
);
}
}
class RectangleDrawingScreen extends StatefulWidget {
const RectangleDrawingScreen({super.key});
@override
State<RectangleDrawingScreen> createState() => _RectangleDrawingScreenState();
}
class _RectangleDrawingScreenState extends State<RectangleDrawingScreen> {
double _cornerRadius = 20.0;
String _text = "FLUTTER";
double _fontSize = 60.0;
Color _rectangleColor = Colors.teal;
Color _backgroundColor = Colors.white;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Rectangle Text Cutout'),
backgroundColor: Colors.teal.shade100,
),
body: Column(
children: [
Expanded(
child: Container(
color: Colors.grey[200],
child: Center(
child: Stack(
children: [
Positioned.fill(
child: Image.network(
"https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/d11fee3a97464bca82c9291435cc2a89~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5reh5YaZ5oiQ54Gw:q75.awebp?rk3s=f64ab15b&x-expires=1746213056&x-signature=ylstmk1m2eVeu8bI%2BhDJkVUHe7U%3D",
fit: BoxFit.cover,
),
),
CustomPaint(
size: const Size(double.infinity, double.infinity),
painter: RectangleTextCutoutPainter(
cornerRadius: _cornerRadius,
text: _text,
fontSize: _fontSize,
rectangleColor: _rectangleColor,
),
),
ShaderMask(
blendMode: BlendMode.srcOut,
child: Text(
_text,
),
shaderCallback: (bounds) =>
LinearGradient(colors: [Colors.black], stops: [0.0])
.createShader(bounds),
),
],
),
),
),
),
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[200],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Corner Radius:', style: TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _cornerRadius,
min: 0,
max: 100,
divisions: 100,
label: _cornerRadius.round().toString(),
activeColor: Colors.teal,
onChanged: (value) {
setState(() {
_cornerRadius = value;
});
},
),
const SizedBox(height: 10),
const Text('Font Size:', style: TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _fontSize,
min: 20,
max: 120,
divisions: 100,
label: _fontSize.round().toString(),
activeColor: Colors.teal,
onChanged: (value) {
setState(() {
_fontSize = value;
});
},
),
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
labelText: 'Text to Cut Out',
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.teal),
),
),
onChanged: (value) {
setState(() {
_text = value;
});
},
controller: TextEditingController(text: _text),
),
const SizedBox(height: 16),
Row(
children: [
const Text('Rectangle Color: ', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 10),
_buildColorButton(Colors.teal),
_buildColorButton(Colors.blue),
_buildColorButton(Colors.red),
_buildColorButton(Colors.purple),
_buildColorButton(Colors.orange),
],
),
const SizedBox(height: 16),
Row(
children: [
const Text('Background Color: ', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 10),
_buildBackgroundColorButton(Colors.white),
_buildBackgroundColorButton(Colors.grey.shade300),
_buildBackgroundColorButton(Colors.yellow.shade100),
_buildBackgroundColorButton(Colors.blue.shade100),
_buildBackgroundColorButton(Colors.pink.shade100),
],
),
],
),
),
],
),
);
}
Widget _buildColorButton(Color color) {
return GestureDetector(
onTap: () {
setState(() {
_rectangleColor = color;
});
},
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 30,
height: 30,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: _rectangleColor == color ? Colors.black : Colors.transparent,
width: 2,
),
),
),
);
}
Widget _buildBackgroundColorButton(Color color) {
return GestureDetector(
onTap: () {
setState(() {
_backgroundColor = color;
});
},
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 30,
height: 30,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: _backgroundColor == color ? Colors.black : Colors.transparent,
width: 2,
),
),
),
);
}
}
class RectangleTextCutoutPainter extends CustomPainter {
final double cornerRadius;
final String text;
final double fontSize;
final Color rectangleColor;
RectangleTextCutoutPainter({
required this.cornerRadius,
required this.text,
required this.fontSize,
required this.rectangleColor,
});
@override
void paint(Canvas canvas, Size size) {
final Rect rect = Rect.fromLTWH(
20,
20,
size.width - 40,
size.height - 40,
);
final RRect roundedRect = RRect.fromRectAndRadius(
rect,
Radius.circular(cornerRadius),
);
final textStyle = TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
);
final textSpan = TextSpan(
text: text,
style: textStyle,
);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: 0,
maxWidth: size.width,
);
final double xCenter = (size.width - textPainter.width) / 2;
final double yCenter = (size.height - textPainter.height) / 2;
canvas.saveLayer(rect.inflate(20), Paint());
final Paint rectanglePaint = Paint()
..color = rectangleColor
..style = PaintingStyle.fill;
canvas.drawRRect(roundedRect, rectanglePaint);
final Paint cutoutPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
canvas.saveLayer(rect.inflate(20), cutoutPaint);
textPainter.paint(canvas, Offset(xCenter, yCenter));
canvas.restore();
canvas.restore();
}
@override
bool shouldRepaint(covariant RectangleTextCutoutPainter oldDelegate) {
return oldDelegate.cornerRadius != cornerRadius ||
oldDelegate.text != text ||
oldDelegate.fontSize != fontSize ||
oldDelegate.rectangleColor != rectangleColor;
}
}
你可以进一步扩展这个效果,例如添加动画、使用自定义字体、或者结合其他绘制效果来创造更加独特的视觉体验。
希望这篇教程对你有所帮助!如果你有任何问题或想法,欢迎在评论区留言。祝你编码愉快!🚀