Flutter利用Canvas繪制精美表盤效果詳解
前言
趁著周末空閑時間使用 Flutter 的 Canvas制作了一個精美表盤。
最終實現(xiàn)的效果還不錯,如下:
前面說到使用 Canvas 實現(xiàn)該表盤效果,而在 Flutter 中使用 Canvas 更多的則是繼承 CustomPainter
類實現(xiàn) paint
方法,然后在 CustomPaint
中使用自定義實現(xiàn)的 CustomPainter
。 比如這里創(chuàng)建的 DialPainter
使用如下:
@override Widget build(BuildContext context) { double width = MediaQuery.of(context).size.width; return Container( color: const Color.fromARGB(255, 35, 36, 38), /// 設(shè)置背景 child: Center( child: CustomPaint( size: Size(width, width), painter: DialPainter(), ), ), ); } class DialPainter extends CustomPainter{ @override void paint(Canvas canvas, Size size) { } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return true; } }
之后所有繪制的核心代碼都在 DialPainter
中的 paint
中實現(xiàn)的,其中 shouldRepaint
是指父控件重新渲染時是否重新繪制,這里設(shè)置為 true 表示每次都重新繪制。
接下來就看具體實現(xiàn)代碼,我們將整個表盤效果的實現(xiàn)分為三部分:面板、刻度、指針。涉及到的主要知識點包括:Paint、Canvas、Path、TextPainter 等。
初始化
在開始進(jìn)行繪制之前,先進(jìn)行畫筆和長度單位的初始化。
在整個效果的實現(xiàn)上會多次使用到畫筆 Paint ,為了避免創(chuàng)建多個畫筆實例,所以創(chuàng)建一個 Paint 成員變量,后續(xù)通過修改其屬性值來滿足不同效果的繪制。
late final Paint _paint = _initPaint(); Paint _initPaint() { return Paint() ..isAntiAlias = true ..color = Colors.white; }
通過初始化代碼設(shè)置了畫筆的抗鋸齒和默認(rèn)顏色。
為了方便后續(xù)使用長、寬、半徑等長度,創(chuàng)建對應(yīng)的成員變量,同時為了適配不同表盤寬高,保證展示效果一致,在繪制時不直接使用數(shù)值,而使用比例長度:
/// 畫布寬度 late double width; /// 畫布高度 late double height; /// 表盤半徑 late double radius; /// 比例單位長度 late double unit ; @override void paint(Canvas canvas, Size size) { initSize(size); } void initSize(Size size) { width = size.width; height = size.height; radius = min(width, height) / 2; unit = radius / 15; }
半徑取寬度和高度的最小值,然后除以 2 ,單位長度 unit
取值為半徑除以 15。
關(guān)于 Flutter 屏幕適配請參考:Flutter應(yīng)用框架搭建之屏幕適配詳解
面板
首先繪制一個線性漸變的圓:
/// 繪制一個線性漸變的圓 var gradient = ui.Gradient.linear( Offset(width/2, height/2 - radius,), Offset(width/2, height/2 + radius), [const Color(0xFFF9F9F9), const Color(0xFF666666)]); _paint.shader = gradient; _paint.color = Colors.white; canvas.drawCircle(Offset(width/2, height/2), radius, _paint);
通過 Gradient.linear
創(chuàng)建一個線性漸變顏色并設(shè)置給 Paint.shader
,繪制出來效果如下:
然后在其上添加一層徑向漸變,增加表盤的立體感:
/// 繪制一層徑向漸變的圓 var radialGradient = ui.Gradient.radial(Offset(width/2, height/2), radius, [ const Color.fromARGB(216, 246, 248, 249), const Color.fromARGB(216, 229, 235, 238), const Color.fromARGB(216,205, 212, 217), const Color.fromARGB(216,245, 247, 249), ], [0, 0.92, 0.93, 1.0]); _paint.shader = radialGradient; canvas.drawCircle(Offset(width/2, height/2), radius - 0.3 * unit, _paint);
使用 Gradient.radial
創(chuàng)建一個徑向漸變的顏色,效果如下:
最后再在表盤內(nèi)添加一個邊框和陰影增加對比效果:
/// 繪制 border var shadowRadius = radius - 0.8 * unit; _paint ..color = const Color.fromARGB(33, 0, 0, 0) ..shader = null ..style = PaintingStyle.stroke ..strokeWidth = 0.1 * unit; canvas.drawCircle(Offset(width/2, height/2), shadowRadius - 0.2 * unit, _paint); ///繪制陰影 Path path = Path(); path.moveTo(width/2, height/2); var rect = Rect.fromLTRB(width/2 - shadowRadius, height/2 - shadowRadius, width/2+shadowRadius, height /2 +shadowRadius); path.addOval(rect); canvas.drawShadow(path, const Color.fromARGB(51, 0, 0, 0), 1 * unit, true);
最后表盤效果如下:
刻度
面板繪制完成,接下來就是繪制刻度線以及刻度值。
刻度線
代碼如下:
double dialCanvasRadius = radius - 0.8 * unit; canvas.save(); canvas.translate(width/2, height/2); var y = 0.0; var x1 = 0.0; var x2 = 0.0; _paint.shader = null; _paint.color = const Color(0xFF929394); for( int i = 0; i < 60; i++){ x1 = dialCanvasRadius - (i % 5 == 0 ? 0.85 * unit : 1 * unit); x2 = dialCanvasRadius - (i % 5 == 0 ? 2 * unit : 1.67 * unit); _paint.strokeWidth = i % 5 == 0 ? 0.38 * unit : 0.2 * unit; canvas.drawLine(Offset(x1, y), Offset(x2, y), _paint); canvas.rotate(2*pi/60); } canvas.restore();
表盤上有 60 個刻度,其中 12 個為小時刻度其余為分鐘刻度,循環(huán) 60 次,通過 i % 5 == 0
判斷是否為小時刻度,從而使用不同的 x 和 y 坐標(biāo),實現(xiàn)不同的長度和寬度。
這里為了避免去計算圓上的點坐標(biāo),采用的是旋轉(zhuǎn)畫布來實現(xiàn)。畫布默認(rèn)旋轉(zhuǎn)點位左上角,所以需要通過 canvas.translate(width/2, height/2)
將旋轉(zhuǎn)點移動到表盤的中心點,然后每繪制完一個刻度畫布旋轉(zhuǎn) 2*pi/60
的角度,即 6 度。因為畫布進(jìn)行了平移所以繪制的坐標(biāo)都是基于圓中心,即相當(dāng)于圓點移動到了圓中心。
最終實現(xiàn)刻度效果如圖:
刻度值
繪制完刻度后需要給刻度標(biāo)值,這里只顯示 3、6、9、12 四個刻度值,代碼如下:
double dialCanvasRadius = radius - 0.8 * unit; var textPainter = TextPainter( text: const TextSpan( text: "3", style: TextStyle(color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold, height: 1.0)), textDirection: TextDirection.rtl, textWidthBasis: TextWidthBasis.longestLine, maxLines: 1, )..layout(); var offset = 2.25 * unit; var points = [ Offset(width / 2 + dialCanvasRadius - offset - textPainter.width , height / 2 - textPainter.height / 2), Offset(width / 2 - textPainter.width /2, height / 2 + dialCanvasRadius - offset - textPainter.height), Offset(width / 2 - dialCanvasRadius + offset, height / 2 - textPainter.height / 2), Offset(width / 2 - textPainter.width, height / 2 - dialCanvasRadius + offset), ]; for(int i = 0; i< 4; i++){ textPainter = TextPainter( text: TextSpan( text: "${(i + 1) * 3}", style: const TextStyle(color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold, height: 1.0)), textDirection: TextDirection.rtl, textWidthBasis: TextWidthBasis.longestLine, maxLines: 1, )..layout(); textPainter.paint(canvas, points[i]); }
繪制文字使用的是 TextPainter
對象,首先創(chuàng)建一個 TextPainter
對象,用于測量獲取文字的寬高,因為這里只顯示 4 個刻度值,所以這里直接將對應(yīng)需要繪制的坐標(biāo)計算出來,然后循環(huán)繪制顯示的刻度值在對應(yīng)的位置即可。實現(xiàn)后效果如下:
指針
接下來就是指針的繪制,指針分為三部分:時針、分針、秒針。在繪制指針之前還需要繪制中心點:
var radialGradient = ui.Gradient.radial(Offset(width / 2, height / 2), radius, [ const Color.fromARGB(255, 200, 200, 200), const Color.fromARGB(255, 190, 190, 190), const Color.fromARGB(255, 130, 130, 130), ], [0, 0.9, 1.0]); /// 底部背景 _paint ..shader = radialGradient ..style = PaintingStyle.fill; canvas.drawCircle( Offset(width/2, height/2), 2 * unit, _paint); /// 頂部圓點 _paint ..shader = null ..style = PaintingStyle.fill ..color = const Color(0xFF121314); canvas.drawCircle(Offset(width/2, height/2), 0.8 * unit, _paint);
代碼很簡單,在中心繪制兩個圓,一個底部的徑向漸變的大圓,一個頂部深色的小圓,如圖:
時針
時針分為三部分,連接中心的矩形、連接矩形的半圓弧、最后的箭頭,如圖:
代碼實現(xiàn)如下:
double hourHalfHeight = 0.4 * unit; double hourRectRight = 7 * unit; Path hourPath = Path(); /// 添加矩形 時針主體 hourPath.moveTo(0 - hourHalfHeight, 0 - hourHalfHeight); hourPath.lineTo(hourRectRight, 0 - hourHalfHeight); hourPath.lineTo(hourRectRight, 0 + hourHalfHeight); hourPath.lineTo(0 - hourHalfHeight, 0 + hourHalfHeight); /// 時針箭頭尾部弧形 double offsetTop = 0.5 * unit; double arcWidth = 1.5 * unit; double arrowWidth = 2.17 * unit; double offset = 0.42 * unit; var rect = Rect.fromLTWH(hourRectRight - offset, 0 - hourHalfHeight - offsetTop, arcWidth, hourHalfHeight * 2 + offsetTop * 2); hourPath.addArc(rect, pi/2, pi); /// 時針箭頭 hourPath.moveTo(hourRectRight - offset + arcWidth/2, 0 - hourHalfHeight - offsetTop); hourPath.lineTo(hourRectRight - offset + arcWidth/2 + arrowWidth, 0); hourPath.lineTo(hourRectRight - offset + arcWidth/2, 0 + hourHalfHeight + offsetTop); hourPath.close(); canvas.save(); canvas.translate(width/2, height/2); ///繪制 _paint.color = const Color(0xFF232425); canvas.drawPath(hourPath, _paint); canvas.restore();
這里是通過 Path 先添加一個矩形到路徑,然后添加一個圓弧,圓弧向左偏移一定單位,防止對接效果不好,再添加一個三角形也就是箭頭圖形。這里所有的坐標(biāo)計算都是基于圓點在圓盤的中心點計算的,所以需要平移畫布,將圓點移動到圓盤的中心點,即 canvas.translate(width/2, height/2)
跟繪制表盤刻度的思路是一樣的,最后再通過 canvas.drawPath
進(jìn)行繪制。效果如下:
分針
分針的繪制相對比較簡單,因為分針就一個圓角矩形,使用畫布的 drawRRect
方法即可:
double hourHalfHeight = 0.4 * unit; double minutesLeft = -1.33 * unit; double minutesTop = -hourHalfHeight; double minutesRight = 11* unit; double minutesBottom = hourHalfHeight; canvas.save(); canvas.translate(width/2, height/2); /// 繪制分針 var rRect = RRect.fromLTRBR(minutesLeft, minutesTop, minutesRight, minutesBottom, Radius.circular(0.42 * unit)); _paint.color = const Color(0xFF343536); canvas.drawRRect(rRect, _paint); canvas.restore();
實現(xiàn)思路同樣是將畫布移動到圓點,然后計算坐標(biāo)進(jìn)行繪制,這里需要注意的是分針尾部是超過了中心大圓點的,所以這里 left 需要向左偏移一定單位:
這里為了看到分針的效果,將時針隱藏掉了
秒針
秒針分為四部分:尾部弧形、尾部圓角矩形、細(xì)針、中心圓點:
實現(xiàn)代碼:
double hourHalfHeight = 0.4 * unit; double secondsLeft = -4.5 * unit; double secondsTop = -hourHalfHeight; double secondsRight = 12.5 * unit; double secondsBottom = hourHalfHeight; Path secondsPath = Path(); secondsPath.moveTo(secondsLeft, secondsTop); /// 尾部弧形 var rect = Rect.fromLTWH(secondsLeft, secondsTop, 2.5 * unit, hourHalfHeight * 2); secondsPath.addArc(rect, pi/2, pi); /// 尾部圓角矩形 var rRect = RRect.fromLTRBR(secondsLeft + 1 * unit, secondsTop, - 2 * unit, secondsBottom, Radius.circular(0.25 * unit)); secondsPath.addRRect(rRect); /// 指針 secondsPath.moveTo(- 2 * unit, - 0.125 * unit); secondsPath.lineTo(secondsRight, 0); secondsPath.lineTo(-2 * unit, 0.125 * unit); /// 中心圓 var ovalRect = Rect.fromLTWH(- 0.67 * unit, - 0.67 * unit, 1.33 * unit, 1.33 * unit); secondsPath.addOval(ovalRect); canvas.save(); canvas.translate(width/2, height/2); /// 繪制陰影 canvas.drawShadow(secondsPath, const Color(0xFFcc0000), 0.17 * unit, true); /// 繪制秒針 _paint.color = const Color(0xFFcc0000); canvas.drawPath(secondsPath, _paint); canvas.restore();
思路跟時針的實現(xiàn)是一樣的,通過 Path 將圓弧、圓角矩形、三角形、中心圓形組合起來,計算坐標(biāo)同樣的是以圓盤中心為圓點,所有同樣需要使用 translate
移動畫布圓點后繪制。實現(xiàn)效果:
同樣的為了更好的看到秒針的效果,將時針、分針隱藏了
動起來
經(jīng)過上面的繪制,我們將表盤的所有元素都繪制出來了,但是最重要的沒有動起來,動起來的關(guān)鍵就是要讓時針、分針、秒針偏移一定的角度,既然是偏移角度自然就想到了旋轉(zhuǎn)畫布來實現(xiàn),類似于繪制刻度一樣。
分別在時針、分針、秒針的繪制之前對畫布進(jìn)行一定角度的旋轉(zhuǎn):
/// 時針 canvas.save(); canvas.translate(width/2, height/2); canvas.rotate(2*pi/4); _paint.color = const Color(0xFF232425); canvas.drawPath(hourPath, _paint); canvas.restore(); ///分針 canvas.save(); canvas.translate(width/2, height/2); canvas.rotate(2*pi/4*2); var rRect = RRect.fromLTRBR(minutesLeft, minutesTop, minutesRight, minutesBottom, Radius.circular(0.42 * unit)); _paint.color = const Color(0xFF343536); canvas.drawRRect(rRect, _paint); canvas.restore(); ///秒針 canvas.save(); canvas.translate(width/2, height/2); canvas.rotate(2*pi/4*3); canvas.drawShadow(secondsPath, const Color(0xFFcc0000), 0.17 * unit, true); _paint.color = const Color(0xFFcc0000); canvas.drawPath(secondsPath, _paint); canvas.restore();
分別在時針、分針、秒針的繪制前對畫布旋轉(zhuǎn) 90°、180°、270° ,效果如下:
通過畫布旋轉(zhuǎn)實現(xiàn)了我們想要的效果,接下來就是讓指針根據(jù)時間旋轉(zhuǎn)相應(yīng)的角度。可以通過 DateTime.now()
獲取當(dāng)前時間對象,進(jìn)而獲取當(dāng)前的小時、分鐘和秒。然后根據(jù)對應(yīng)的值計算出相應(yīng)的角度:
var date = DateTime.now(); /// 時針 canvas.rotate(2*pi/60*((date.hour - 3 + date.minute / 60 + date.second/60/60) * 5 )); /// 分針 canvas.rotate(2*pi/60 * (date.minute - 15 + date.second / 60)); /// 秒針 canvas.rotate(2*pi/60 * (date.second - 15));
首先將 360 度分為 60 份,時針一小時為 5 份,因為角度的起始是在右側(cè)中心點,所以獲取的小時需要減 3,再加上分鐘、秒鐘占小時的比例;同理分別計算分鐘、秒鐘的角度,最終實現(xiàn)時針、分針、秒針根據(jù)當(dāng)前時間展示。
角度計算對了以后,還需要刷新整個表盤,即每秒鐘刷新一次,刷新時獲取當(dāng)前時間重新繪制時針、分針、秒針的位置,實現(xiàn)動態(tài)效果,這里使用 Timer 每一秒鐘調(diào)用父布局的 setState
實現(xiàn)。
@override void initState() { super.initState(); Timer.periodic(const Duration(seconds: 1), (timer) { setState(() {}); }); }
大功告成,最終實現(xiàn)了開始展示的表盤動態(tài)效果。
以上就是Flutter利用Canvas繪制精美表盤效果詳解的詳細(xì)內(nèi)容,更多關(guān)于Flutter Canvas表盤的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android studio 生成帶Kotlin文檔的實現(xiàn)方式
這篇文章主要介紹了Android studio 生成帶Kotlin文檔的實現(xiàn)方式,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-03-03解決ViewPager和SlidingPaneLayout的滑動事件沖突問題
下面小編就為大家分享一篇解決ViewPager和SlidingPaneLayout的滑動事件沖突問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-01-01