Flutter實(shí)現(xiàn)文字鏤空效果的詳細(xì)步驟
引言
哈哈,2019年初我剛?cè)肼殨r(shí),遇到了一個(gè)特別的需求:學(xué)校的卡片上要有個(gè)分類標(biāo)簽,文字部分還得鏤空。當(dāng)時(shí)我剛開始接觸Flutter,對(duì)很多功能都不熟悉,這個(gè)需求就一直沒能實(shí)現(xiàn),成了我的一個(gè)小執(zhí)念。現(xiàn)在我早已不在那兒工作了,可這兩天閑來無事,突然想起了這個(gè)事。趁著五一假期,我開始琢磨畫筆功能,終于把當(dāng)年實(shí)現(xiàn)不了的功能給實(shí)現(xiàn)了。
Tip: 這時(shí)候可能會(huì)有人說:啊,這道題我會(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)來創(chuàng)建一個(gè)矩形,然后從中"切出"文字形狀。我們將使用Flutter的BlendMode.dstOut
混合模式來實(shí)現(xiàn)這一效果。
開始實(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)建主屏幕
接下來,我們創(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()
來創(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 Shader應(yīng)用開發(fā)之雷達(dá)掃描效果
這篇文章主要為大家詳細(xì)介紹了Android Shader應(yīng)用開發(fā)之雷達(dá)掃描效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-07-07Android實(shí)現(xiàn)TextView兩端對(duì)齊的方法
這篇文章主要介紹了Android實(shí)現(xiàn)TextView兩端對(duì)齊的方法,需要的朋友可以參考下2016-01-01Android編程實(shí)現(xiàn)Gallery中每次滑動(dòng)只顯示一頁的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)Gallery中每次滑動(dòng)只顯示一頁的方法,涉及Android擴(kuò)展Gallery控件實(shí)現(xiàn)翻頁效果控制的功能,涉及Android事件響應(yīng)及屬性控制的相關(guān)技巧,需要的朋友可以參考下2015-11-11Android開發(fā)兩個(gè)activity之間傳值示例詳解
這篇文章主要為大家介紹了Android開發(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++的開發(fā)就難免會(huì)使用到指針的這個(gè)知識(shí) 點(diǎn)。而C++的難點(diǎn)和容易出問題的也在于指針。使用指針出錯(cuò),常常會(huì)引發(fā)帶來對(duì)項(xiàng)目具有毀滅性的錯(cuò)誤,內(nèi)存泄漏、邏輯錯(cuò)誤、系統(tǒng)崩潰2021-10-10Android RecyclerView滾動(dòng)定位
這篇文章主要為大家詳細(xì)介紹了Android RecyclerView滾動(dòng)定位的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-01-01Android自定義View實(shí)現(xiàn)音頻播放圓形進(jìn)度條
這篇文章主要為大家詳細(xì)介紹了Android自定義View實(shí)現(xiàn)音頻播放圓形進(jìn)度條,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-06-06