Flutter實(shí)現(xiàn)文字鏤空效果的詳細(xì)步驟
引言
哈哈,2019年初我剛?cè)肼殨r(shí),遇到了一個(gè)特別的需求:學(xué)校的卡片上要有個(gè)分類(lèi)標(biāo)簽,文字部分還得鏤空。當(dāng)時(shí)我剛開(kāi)始接觸Flutter,對(duì)很多功能都不熟悉,這個(gè)需求就一直沒(méi)能實(shí)現(xiàn),成了我的一個(gè)小執(zhí)念?,F(xiàn)在我早已不在那兒工作了,可這兩天閑來(lái)無(wú)事,突然想起了這個(gè)事。趁著五一假期,我開(kāi)始琢磨畫(huà)筆功能,終于把當(dāng)年實(shí)現(xiàn)不了的功能給實(shí)現(xiàn)了。

Tip: 這時(shí)候可能會(huì)有人說(shuō):啊,這道題我會(huì),用
ShaderMask配置blendMode: BlendMode.srcOut就能實(shí)現(xiàn),但實(shí)際上這個(gè)組件不能設(shè)置圓角,內(nèi)邊距等相關(guān)內(nèi)容,如果這時(shí)候添加一個(gè)Container那么鏤空效果也只能看到Container的顏色,而不能看到最底部的圖片
實(shí)現(xiàn)原理
文字鏤空效果的核心是使用Canvas和自定義繪制(CustomPainter)來(lái)創(chuàng)建一個(gè)矩形,然后從中"切出"文字形狀。我們將使用Flutter的BlendMode.dstOut混合模式來(lái)實(shí)現(xiàn)這一效果。
開(kāi)始實(shí)現(xiàn)
步驟1:創(chuàng)建基礎(chǔ)應(yīng)用結(jié)構(gòu)
首先,我們需要設(shè)置基本的應(yīng)用結(jié)構(gòu):
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(),
);
}
}
這里我們創(chuàng)建了一個(gè)基本的MaterialApp,并設(shè)置了主題顏色為teal(青色),啟用了Material 3設(shè)計(jì)。
步驟2:創(chuàng)建主屏幕
接下來(lái),我們創(chuàng)建主屏幕,這是一個(gè)StatefulWidget,因?yàn)槲覀冃枰芾矶鄠€(gè)可變狀態(tài):
class RectangleDrawingScreen extends StatefulWidget {
const RectangleDrawingScreen({super.key});
@override
State<RectangleDrawingScreen> createState() => _RectangleDrawingScreenState();
}
class _RectangleDrawingScreenState extends State<RectangleDrawingScreen> {
// 定義狀態(tài)變量
double _cornerRadius = 20.0;
String _text = "FLUTTER";
double _fontSize = 60.0;
Color _rectangleColor = Colors.teal;
Color _backgroundColor = Colors.white;
// 構(gòu)建UI...
}
我們定義了幾個(gè)關(guān)鍵狀態(tài)變量:
_cornerRadius:矩形的圓角半徑_text:要鏤空的文字_fontSize:文字大小_rectangleColor:矩形的顏色_backgroundColor:背景顏色
步驟3:實(shí)現(xiàn)自定義繪制器
這是實(shí)現(xiàn)鏤空效果的核心部分 - 自定義繪制器:
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) {
// 創(chuàng)建矩形區(qū)域
final Rect rect = Rect.fromLTWH(
20,
20,
size.width - 40,
size.height - 40,
);
// 創(chuàng)建圓角矩形
final RRect roundedRect = RRect.fromRectAndRadius(
rect,
Radius.circular(cornerRadius),
);
// 設(shè)置文字樣式
final textStyle = TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
);
final textSpan = TextSpan(
text: text,
style: textStyle,
);
// 創(chuàng)建文字繪制器
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
// 計(jì)算文字位置
textPainter.layout(
minWidth: 0,
maxWidth: size.width,
);
final double xCenter = (size.width - textPainter.width) / 2;
final double yCenter = (size.height - textPainter.height) / 2;
// 使用圖層和混合模式實(shí)現(xiàn)鏤空效果
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;
}
}
這個(gè)自定義繪制器的工作原理是:
- 創(chuàng)建一個(gè)圓角矩形
- 使用
saveLayer和BlendMode.dstOut創(chuàng)建一個(gè)混合圖層 - 在矩形上"切出"文字形狀
- 使用
shouldRepaint方法優(yōu)化重繪性能
步驟4:構(gòu)建UI界面
現(xiàn)在,讓我們實(shí)現(xiàn)主界面,包括預(yù)覽區(qū)域和控制面板:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Rectangle Text Cutout'),
backgroundColor: Colors.teal.shade100,
),
body: Column(
children: [
// 預(yù)覽區(qū)域
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:實(shí)現(xiàn)顏色選擇按鈕
最后,我們實(shí)現(xiàn)顏色選擇按鈕的構(gòu)建方法:
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,
),
),
),
);
}
關(guān)鍵技術(shù)點(diǎn)解析
1. 混合模式(BlendMode)的應(yīng)用
在這個(gè)效果中,最關(guān)鍵的技術(shù)是使用BlendMode.dstOut混合模式。這個(gè)混合模式會(huì)從目標(biāo)圖像(矩形)中"減去"源圖像(文字),從而創(chuàng)建出文字形狀的"洞"。
final Paint cutoutPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill ..blendMode = BlendMode.dstOut;
2. Canvas圖層(Layer)的使用
我們使用canvas.saveLayer()和canvas.restore()來(lái)創(chuàng)建和管理圖層,這是實(shí)現(xiàn)復(fù)雜繪制效果的關(guān)鍵:
canvas.saveLayer(rect.inflate(20), Paint()); // 繪制矩形 canvas.saveLayer(rect.inflate(20), cutoutPaint); // 繪制文字 canvas.restore(); canvas.restore();
3. 文字居中處理
為了讓文字在矩形中居中顯示,我們需要計(jì)算正確的位置:
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;
}
}
以上就是Flutter實(shí)現(xiàn)文字鏤空效果的詳細(xì)步驟的詳細(xì)內(nèi)容,更多關(guān)于Flutter文字鏤空效果的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android音樂(lè)播放器簡(jiǎn)單實(shí)現(xiàn)案例
我們平時(shí)長(zhǎng)時(shí)間打代碼的時(shí)候肯定會(huì)感到疲憊和乏味,這個(gè)時(shí)候一邊播放自己喜歡的音樂(lè),一邊繼續(xù)打代碼,心情自然也愉快很多。音樂(lè)帶給人的聽(tīng)覺(jué)享受是無(wú)可比擬的,動(dòng)聽(tīng)的音樂(lè)可以愉悅?cè)说纳硇?,讓人更加積極地去熱愛(ài)生活,這篇文章主要介紹了Android音樂(lè)播放器簡(jiǎn)單實(shí)現(xiàn)案例2022-12-12
Android Shader應(yīng)用開(kāi)發(fā)之雷達(dá)掃描效果
這篇文章主要為大家詳細(xì)介紹了Android Shader應(yīng)用開(kāi)發(fā)之雷達(dá)掃描效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-07-07
Android實(shí)現(xiàn)TextView兩端對(duì)齊的方法
這篇文章主要介紹了Android實(shí)現(xiàn)TextView兩端對(duì)齊的方法,需要的朋友可以參考下2016-01-01
Android編程實(shí)現(xiàn)Gallery中每次滑動(dòng)只顯示一頁(yè)的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)Gallery中每次滑動(dòng)只顯示一頁(yè)的方法,涉及Android擴(kuò)展Gallery控件實(shí)現(xiàn)翻頁(yè)效果控制的功能,涉及Android事件響應(yīng)及屬性控制的相關(guān)技巧,需要的朋友可以參考下2015-11-11
Android開(kāi)發(fā)兩個(gè)activity之間傳值示例詳解
這篇文章主要為大家介紹了Android開(kāi)發(fā)兩個(gè)activity之間傳值示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07
理解關(guān)于Android系統(tǒng)中輕量級(jí)指針的實(shí)現(xiàn)
由于android系統(tǒng)底層的很大的一部分是用C++實(shí)現(xiàn)的,C++的開(kāi)發(fā)就難免會(huì)使用到指針的這個(gè)知識(shí) 點(diǎn)。而C++的難點(diǎn)和容易出問(wèn)題的也在于指針。使用指針出錯(cuò),常常會(huì)引發(fā)帶來(lái)對(duì)項(xiàng)目具有毀滅性的錯(cuò)誤,內(nèi)存泄漏、邏輯錯(cuò)誤、系統(tǒng)崩潰2021-10-10
Android RecyclerView滾動(dòng)定位
這篇文章主要為大家詳細(xì)介紹了Android RecyclerView滾動(dòng)定位的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-01-01
android嵌套滾動(dòng)入門(mén)實(shí)踐
嵌套滾動(dòng)是 Android OS 5.0之后,google 為我們提供的新特性,本篇文章主要介紹了android嵌套滾動(dòng)入門(mén)實(shí)踐,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-05-05
Android自定義View實(shí)現(xiàn)音頻播放圓形進(jìn)度條
這篇文章主要為大家詳細(xì)介紹了Android自定義View實(shí)現(xiàn)音頻播放圓形進(jìn)度條,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-06-06

