如何使用Flutter實(shí)現(xiàn)58同城中的加載動(dòng)畫詳解
前言
在應(yīng)用中執(zhí)行耗時(shí)操作時(shí),為了避免界面長(zhǎng)時(shí)間等待造成假死的現(xiàn)象,往往會(huì)添加一個(gè)加載中的動(dòng)畫來(lái)提醒用戶,在58同城中也不例外,而且我們并沒(méi)有使用系統(tǒng)默認(rèn)的加載動(dòng)畫,而是制作了一個(gè)具有58特色的加載動(dòng)畫。
在本篇文章中,給大家分享下筆者使用Flutter實(shí)現(xiàn)58同城中加載動(dòng)畫的過(guò)程。先看一下加載動(dòng)畫的效果:
動(dòng)畫效果乍看比較復(fù)雜,難以看出端倪,其實(shí)我們可以先調(diào)慢動(dòng)畫的速度,這樣能夠比較清晰地分析出動(dòng)畫的流程。
動(dòng)畫的流程
動(dòng)畫由兩個(gè)圓弧的動(dòng)效組成,兩個(gè)圓弧的起始點(diǎn)角度和掃過(guò)的弧度隨著時(shí)間規(guī)律變化。仔細(xì)觀察會(huì)發(fā)現(xiàn),兩個(gè)圓弧的動(dòng)效其實(shí)是一樣的,只不過(guò)起始位置是不一樣的。我們先看下外部大圓弧的運(yùn)動(dòng)規(guī)律。
大圓弧從x軸正方向開(kāi)始運(yùn)動(dòng),按照動(dòng)畫的運(yùn)動(dòng)規(guī)律,可以將動(dòng)畫分為三個(gè)階段:
第一階段:圓弧起點(diǎn)的在x軸正方向,終點(diǎn)的角度x軸正方向開(kāi)始向下逐漸增大,直到終點(diǎn)到達(dá)y軸負(fù)方向位置,最終圓弧掃過(guò)的角度為180度。
第二階段:圓弧掃過(guò)的角度保持在180度,起點(diǎn)和終點(diǎn)一起順時(shí)針旋轉(zhuǎn),直到旋轉(zhuǎn)180度后終點(diǎn)到達(dá)x軸正方向。
第三階段:圓弧的終點(diǎn)保持在x軸正方向,起點(diǎn)順時(shí)針旋轉(zhuǎn),直到起點(diǎn)也到達(dá)x軸正方向,此時(shí)完成一個(gè)完整的動(dòng)畫。接下來(lái)繼續(xù)重復(fù)動(dòng)畫的第一階段,組成一個(gè)連貫的動(dòng)畫。
分析完動(dòng)畫的流程,思路就很清晰了,我們按照動(dòng)畫流程把動(dòng)畫拆分成三部分,通過(guò)對(duì)圓弧的起點(diǎn)、終點(diǎn)和掃過(guò)角度的變換,組合成一個(gè)完整的動(dòng)畫,然后不斷地重復(fù),最后就變成了一個(gè)加載中的動(dòng)畫效果。
接下來(lái)開(kāi)始寫代碼實(shí)現(xiàn)。
由于動(dòng)畫是由一個(gè)圓弧不斷變化組成的,如果使用Android,我們很自然的想到可以使用Canvas來(lái)進(jìn)行圓弧的繪制,然后根據(jù)時(shí)間的變化不停地重新繪制圓弧,從而實(shí)現(xiàn)動(dòng)畫效果。那么在Flutter中是否也存在Canvas呢,答案是肯定的,F(xiàn)lutter和Android一樣,也存在Canvas。
Flutter中的Canvas
Flutter中使用 CustomPainter 類在Canvas上進(jìn)行繪制,該類包含一個(gè) paint() 方法,該方法提供了一個(gè)Canvas對(duì)象,可以用來(lái)繪制各種圖形。
abstract class CustomPainter extends Listenable { void paint(Canvas canvas, Size size); }
不過(guò)在Flutter中一切皆是Widget,而承載Canvas功能的Widget是 CustomPaint 類。 CustomPaint 包含一個(gè)painter屬性,用來(lái)指定進(jìn)行繪制的 CustomPainter,源碼如下:
class CustomPaint extends SingleChildRenderObjectWidget { const CustomPaint({ Key key, this.painter, }); final CustomPainter painter; }
Flutter中的Canvas和Android類似,提供了一系列的API用來(lái)繪制點(diǎn)、線、圓形、正方形等,而且API很類似,對(duì)比一下Flutter與Android中Canvas的常見(jiàn)API(具體的參數(shù)列表請(qǐng)參考文檔和源碼,篇幅有限不再一一列出):
Android | Flutter | |
---|---|---|
點(diǎn) |
drawPoint() drawPoints() |
drawPoints() |
線 |
drawLine() drawLines() |
drawLine() |
圓 | drawCircle() | drawCircle() |
橢圓 | drawOval() | drawOval() |
圓弧 | drawArc() | drawArc() |
矩形 | drawRect() | drawRect() |
Path | drawPath() | drawPath() |
圖片 | drawBitmap() | drawImage() |
文字 | drawText() | drawParagraph() |
變換 |
save() restore() |
save() restore() |
要繪制動(dòng)畫中的圓弧,應(yīng)該使用 drawArc() 方法來(lái)實(shí)現(xiàn),這里需要注意的是drawArc()方法的參數(shù):startAngle和sweepAngle的單位是弧度(180度等于π弧度)。
具體來(lái)看一下 Canvas.drawArc() 方法的參數(shù)列表:
/// rect: 圓弧四周范圍所形成的矩形,在本篇中圓弧為圓形,可以使用Rect.fromCircle()確定圓弧的范圍 /// startAngle: 圓弧起始點(diǎn)的角度,x軸正方向?yàn)?度,按順時(shí)針遞增,y軸負(fù)方向?yàn)?0度,以此類推 /// sweepAngle: 圓弧掃過(guò)的角度,即圓弧終點(diǎn)所在的角度為startAngle + sweepAngle /// useCenter: 如果為true,圓弧兩端會(huì)與圓心相連,形成一個(gè)扇形,本篇中應(yīng)為false /// paint: 畫筆,下文中會(huì)進(jìn)行簡(jiǎn)單介紹 void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
在Canvas的一系列方法中會(huì)發(fā)現(xiàn)一個(gè)熟悉的名稱:Paint,與Android類似,F(xiàn)lutter中的Paint類也是用來(lái)描述畫筆的。
Paint類
Paint類位于 dart.ui 庫(kù)中,Paint類保存了畫筆的顏色、粗細(xì)、是否抗鋸齒、著色器等屬性。
下面簡(jiǎn)單的介紹下幾個(gè)常用的屬性:
Paint paint = Paint() ..color = Color(0xFFFF552E) ..strokeWidth = 2.0 ..style = PaintingStyle.stroke ..isAntiAlias = true ..shader = LinearGradient(colors: []).createShader(rect) ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.bevel;
屬性說(shuō)明:
- color:Color類型,設(shè)置畫筆的顏色。
- strokeWidth:double類型,設(shè)置畫筆的粗細(xì)。
- style:PaintingStyle枚舉類型,設(shè)置畫筆的樣式, PaintingStyle.stroke 為描邊, PaintingStyle.fill 為填充。
- isAntiAlias:bool類型,設(shè)置是否抗鋸齒,true為開(kāi)啟抗鋸齒。
- shader:Shader類型,著色器,一般用來(lái)繪制漸變效果,可以使用 LinearGradient、 RadialGradient、 SweepGradient 等。
- strokeCap:StrokeCap枚舉類型,設(shè)置線條兩端點(diǎn)的樣式, StrokeCap.butt 為無(wú)(默認(rèn)值), StrokeCap.round 為圓形, StrokeCap.square 為方形。
- strokeJoin:StrokeJoin枚舉類型,設(shè)置線條交匯處的樣式, StrokeJoin.miter 為銳角, StrokeJoin.round 為圓弧, StrokeJoin.bevel 為斜角,可以參考下圖方便理解:
熟悉了Canvas和Paint的使用之后,就能夠繪制出加載動(dòng)畫的圓弧了。當(dāng)然,只是繪制出圓弧并沒(méi)有什么用,主要是怎么讓圓弧動(dòng)起來(lái)。
Flutter中的動(dòng)畫 想要讓圓弧動(dòng)起來(lái),我們需要使用到Flutter的動(dòng)畫。下面先來(lái)介紹下Flutter中動(dòng)畫的實(shí)現(xiàn)。 Flutter中的動(dòng)畫相關(guān)的類主要有以下幾個(gè): Animation:動(dòng)畫的核心類,是一個(gè)抽象類。用來(lái)生成動(dòng)畫執(zhí)行過(guò)程中的插值,輸出的結(jié)果可以是線性或曲線的,Animation對(duì)象與UI渲染沒(méi)有任何關(guān)系。 abstract class Animation<T> extends Listenable implements ValueListenable<T> { /// 添加動(dòng)畫狀態(tài)的監(jiān)聽(tīng) void addStatusListener(AnimationStatusListener listener); /// 移除動(dòng)畫狀態(tài)的監(jiān)聽(tīng) void removeStatusListener(AnimationStatusListener listener); /// 獲取當(dāng)前動(dòng)畫的狀態(tài) AnimationStatus get status; /// 獲取當(dāng)前動(dòng)畫的插值,執(zhí)行動(dòng)畫時(shí)需要根據(jù)該值進(jìn)行UI繪制等 T get value; }
AnimationController:動(dòng)畫的管理類,繼承自 Animation<double>。默認(rèn)情況下在給定的時(shí)間范圍內(nèi)線性生成從0.0到1.0的值。
AnimationController對(duì)象需要傳遞一個(gè)vsync參數(shù),它接收一個(gè)TickerProvider類型的對(duì)象,主要職責(zé)是創(chuàng)建Ticker。Flutter應(yīng)用在啟動(dòng)時(shí)會(huì)綁定一個(gè)SchedulerBinding,可以給每一次屏幕刷新添加回調(diào),Ticker就是通過(guò)SchedulerBinding來(lái)添加屏幕刷新的回調(diào),當(dāng)屏幕刷新時(shí),會(huì)通知到綁定的Ticker回調(diào)。假如動(dòng)畫的UI不在當(dāng)前屏幕,比如鎖屏?xí)r,鎖屏后屏幕停止刷新,不會(huì)通知SchedulerBinding,Ticker也就不會(huì)觸發(fā),這樣就能夠防止屏幕外的動(dòng)畫消耗不必要的資源。
class AnimationController extends Animation<double> with AnimationEagerListenerMixin, AnimationLocalListenersMixin, AnimationLocalStatusListenersMixin { /// value:動(dòng)畫的初始值,默認(rèn)是lowerBound /// duration:動(dòng)畫執(zhí)行的時(shí)長(zhǎng) /// lowerBound:動(dòng)畫的最小值,默認(rèn)值為0.0 /// upperBound:動(dòng)畫的最大值,默認(rèn)值為1.0 /// vsync:可以通過(guò) `with SingleTickerProviderStateMixin` 傳入StatefulWidget對(duì)象 AnimationController({ double value, this.duration, this.lowerBound = 0.0, this.upperBound = 1.0, @required TickerProvider vsync, }) { _ticker = vsync.createTicker(_tick); } Ticker _ticker; /// Ticker的回調(diào),每次屏幕刷新都會(huì)回調(diào) void _tick(Duration elapsed) { notifyListeners(); } /// 開(kāi)始播放動(dòng)畫 TickerFuture forward({ double from }) /// 反向播放動(dòng)畫 TickerFuture reverse({ double from }) /// 設(shè)置動(dòng)畫重復(fù)執(zhí)行 TickerFuture repeat({ double min, double max, bool reverse = false, Duration period }) /// 釋放動(dòng)畫資源 void dispose() }
CurvedAnimation:非線性動(dòng)畫類,繼承自 Animation<double>。CurvedAnimation可以使用curve屬性指定曲線函數(shù)Curve,類似Android動(dòng)畫的插值器,F(xiàn)lutter中已經(jīng)實(shí)現(xiàn)了許多常用的曲線,在Curves類中可以找到,比如Curves.linear、Curves.decelerate、Curves.ease。也可以繼承Curve類重寫 transform() 方法來(lái)實(shí)現(xiàn)自定義的曲線函數(shù)。
class CurvedAnimation extends Animation<double> with AnimationWithParentMixin<double> { /// parent:指定AnimationController對(duì)象 /// curve:指定動(dòng)畫的曲線函數(shù) CurvedAnimation({ @required this.parent, @required this.curve, }) } abstract class Curve { /// 計(jì)算動(dòng)畫執(zhí)行中`t`點(diǎn)的插值,可以自定義曲線函數(shù) double transform(double t) }
Tween:補(bǔ)間值的生成類,繼承自 Animatable<T>。
由于AnimationController的值范圍默認(rèn)為0.0到1.0,如果需要不同的范圍或數(shù)據(jù)類型,可以使用Tween指定動(dòng)畫值的范圍。Tween不僅能返回double類型的值,還有IntTween、ColorTween、SizeTween等各種返回不同數(shù)據(jù)類型的子類。
使用Tween對(duì)象需要調(diào)用 animate() 方法,傳入AnimationController對(duì)象,該方法會(huì)返回一個(gè)Animation,這樣就可以獲取到動(dòng)畫的插值了。
class Tween<T extends dynamic> extends Animatable<T> { /// begin:動(dòng)畫的起始值 /// end:動(dòng)畫的結(jié)束值 Tween({ this.begin, this.end }); /// 可以把double類型的動(dòng)畫插值轉(zhuǎn)換成任何類型的值 T transform(double t) /// parent:傳入AnimationController對(duì)象 /// 返回Animation對(duì)象,使用Animation.value獲取動(dòng)畫當(dāng)前的插值 Animation<T> animate(Animation<double> parent) }
AnimatedBuilder:用于構(gòu)建動(dòng)畫的Widget,將動(dòng)畫和要執(zhí)行動(dòng)畫的Widget關(guān)聯(lián)起來(lái),繼承關(guān)系為AnimatedBuilder → AnimatedWidget → StatefulWidget。
class AnimatedBuilder extends AnimatedWidget { const AnimatedBuilder({ @required Listenable animation, @required this.builder, }); /// typedef TransitionBuilder = Widget Function(BuildContext context, Widget child); /// builder是一個(gè)函數(shù),返回Widget對(duì)象 final TransitionBuilder builder; @override Widget build(BuildContext context) { return builder(context, child); } } abstract class AnimatedWidget extends StatefulWidget { const AnimatedWidget({ @required this.listenable, }); @protected Widget build(BuildContext context); @override _AnimatedState createState() => _AnimatedState(); } class _AnimatedState extends State<AnimatedWidget> { @override void initState() { super.initState(); widget.listenable.addListener(_handleChange); } @override void dispose() { widget.listenable.removeListener(_handleChange); super.dispose(); } void _handleChange() { setState(() { }); } @override Widget build(BuildContext context) => widget.build(context); }
分析上面列出的源碼,AnimatedWidget是一個(gè)StatefulWidget。當(dāng)AnimatedWidget關(guān)聯(lián)的_AnimatedState初始化時(shí),會(huì)注冊(cè)動(dòng)畫的監(jiān)聽(tīng)函數(shù)_handleChange,_handleChange監(jiān)聽(tīng)函數(shù)中又調(diào)用了setState()方法,即動(dòng)畫插值每次改變時(shí)都會(huì)調(diào)用build()方法。_AnimatedState.build()方法中又調(diào)用了AnimatedWidget.build()方法,在AnimatedBuilder中實(shí)現(xiàn)了AnimatedWidget.build()方法:調(diào)用屬性builder生成Widget,最終實(shí)現(xiàn)了動(dòng)畫與Widget的綁定。
加載動(dòng)畫的實(shí)現(xiàn)
了解了Flutter的動(dòng)畫后,再結(jié)合之前對(duì)加載動(dòng)畫流程的分析,加載動(dòng)畫可分成三個(gè)階段,我們可以依賴Tween類,指定值的范圍從0.0到3.0變化,當(dāng)然也可以只使用AnimationController,指定lowerBound和upperBound的值分別為0.0和3.0。這里之所以不使用CurvedAnimation,是因?yàn)榧虞d動(dòng)畫的圓弧是線性變化的,不存在加速減速,沒(méi)有必要使用。
大圓弧能夠?qū)崿F(xiàn)了,我們?cè)賮?lái)看內(nèi)部的小圓弧,仔細(xì)觀察會(huì)發(fā)現(xiàn)小圓弧的變化規(guī)律與大圓弧完全一致,只不過(guò)小圓弧的起始位置在x軸負(fù)方向,與大圓弧正好相差180度,也就是π弧度。在繪制大圓弧的同時(shí),可以很輕松的計(jì)算出小圓弧的起點(diǎn)的角度(即大圓弧起點(diǎn)的角度+π弧度)。
至此整個(gè)動(dòng)畫的實(shí)現(xiàn)思路就清晰了:
- 自定義加載動(dòng)畫的Widget,繼承自CustomPaint類。
- 使用AnimationController、Tween創(chuàng)建動(dòng)畫,動(dòng)畫的值范圍從0.0到3.0線性變化,并且設(shè)置動(dòng)畫重復(fù)執(zhí)行。動(dòng)畫插值每遞增1.0代表動(dòng)畫執(zhí)行的一個(gè)階段。
- 繼承CustomPainter類,實(shí)現(xiàn)paint()方法繪制圓弧。根據(jù)動(dòng)畫的插值判斷當(dāng)前屬于動(dòng)畫的哪個(gè)階段,再計(jì)算出圓弧的起點(diǎn)、掃過(guò)的角度,繪制出兩個(gè)圓弧。
下面是實(shí)現(xiàn)加載動(dòng)畫的關(guān)鍵代碼:
import 'dart:math'; import 'package:flutter/material.dart'; class WubaLoadingWidget extends StatefulWidget { @override _WubaLoadingWidgetState createState() => _WubaLoadingWidgetState(); } class _WubaLoadingWidgetState extends State<WubaLoadingWidget> with SingleTickerProviderStateMixin { AnimationController _animationController; Animation<double> _animation; @override void initState() { super.initState(); _animationController = new AnimationController( // 可以指定lowerBound、upperBound,使用AnimationController對(duì)象 // lowerBound: 0.0, // upperBound: 3.0, vsync: this, duration: const Duration(milliseconds: 1500), ); _animation = Tween(begin: 0.0, end: 3.0) .animate(_animationController); _animationController.forward(); // 執(zhí)行動(dòng)畫 _animationController.repeat(); // 設(shè)置動(dòng)畫循環(huán)執(zhí)行 } @override void dispose() { // 調(diào)用dispose()方法釋放動(dòng)畫資源 _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animationController, builder: (BuildContext context, Widget child) { return Container( child: CustomPaint( painter: _LoadingPaint( value: _animation.value, ), ), ); }, ); } } class _LoadingPaint extends CustomPainter { final double value; final Paint _outerPaint; // 大圓弧的Paint final Paint _innerPaint; // 小圓弧的Paint _LoadingPaint({ this.value, }); @override void paint(Canvas canvas, Size size) { double startAngle = 0; double sweepAngle = 0; // 動(dòng)畫的第一階段:圓弧起點(diǎn)為0度,終點(diǎn)的角度遞增 if (value <= 1.0) { startAngle = 0; sweepAngle = value * pi; } // 動(dòng)畫的第二階段:圓弧掃過(guò)的弧度為π弧度(180度),起點(diǎn)、終點(diǎn)一起順時(shí)針旋轉(zhuǎn),一共旋轉(zhuǎn)π弧度 else if (value <= 2.0) { startAngle = (value - 1) * pi; sweepAngle = pi; } // 動(dòng)畫的第三階段:圓弧的終點(diǎn)不變,起點(diǎn)從x軸負(fù)方向開(kāi)始順時(shí)針旋轉(zhuǎn),直到起點(diǎn)也到達(dá)x軸正方向 else { startAngle = pi + (value - 2) * pi; sweepAngle = (3 - value) * pi; } // 繪制外圈的大圓弧 canvas.drawArc(outerRect, startAngle, sweepAngle, false, _outerPaint); // 繪制內(nèi)圈的小圓弧 canvas.drawArc(innerRect, startAngle + pi, sweepAngle, false, _innerPaint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
總結(jié)
Flutter的Canvas、Paint與Android的API非常類似,基本的思路也一致,對(duì)于Android同學(xué)比較容易掌握。
Flutter中動(dòng)畫的實(shí)現(xiàn)相較于Android邏輯更加清晰簡(jiǎn)單,方便易用。AnimatedBuilder類巧妙的將UI與動(dòng)畫整合在一起,把UI和動(dòng)畫職責(zé)分離,這種思路值得學(xué)習(xí)。Flutter中的動(dòng)畫還有路由過(guò)渡動(dòng)畫、Hero動(dòng)畫、切換動(dòng)畫組件AnimatedSwitcher等,有需要的同學(xué)可以查找相關(guān)資料。
如果大家需要定制一些個(gè)性化的加載動(dòng)畫,推薦一個(gè)GitHub的開(kāi)源項(xiàng)目:flutter_spinkit,這個(gè)插件提供了很多種常用的加載動(dòng)畫效果。
好了,以上就是這篇文章的全部?jī)?nèi)容了,希望本文的內(nèi)容對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,謝謝大家對(duì)腳本之家的支持。
相關(guān)文章
Android自定義View繪制隨機(jī)生成圖片驗(yàn)證碼
這篇文章主要為大家詳細(xì)介紹了Android自定義View繪制隨機(jī)生成圖片驗(yàn)證碼,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-09-09AFURLSessionManager 上傳下載使用代碼說(shuō)明
本文通過(guò)代碼給大家介紹了AFURLSessionManager 上傳下載使用說(shuō)明,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友參考下吧2017-09-09Android之ImageSwitcher的實(shí)例詳解
這篇文章主要介紹了Android之ImageSwitcher的實(shí)例詳解的相關(guān)資料,這里提供實(shí)例幫助大家理解這個(gè)控件的功能,希望能幫助到大家,需要的朋友可以參考下2017-08-08Android ListView用EditText實(shí)現(xiàn)搜索功能效果
本篇文章主要介紹了Android ListView用EditText實(shí)現(xiàn)搜索功能效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下。2017-03-03Android編程之創(chuàng)建自己的內(nèi)容提供器實(shí)現(xiàn)方法
這篇文章主要介紹了Android編程之創(chuàng)建自己的內(nèi)容提供器實(shí)現(xiàn)方法,結(jié)合具體實(shí)例形式分析了Android創(chuàng)建內(nèi)容提供器的原理、步驟與相關(guān)操作技巧,需要的朋友可以參考下2017-08-08AndroidStudio實(shí)現(xiàn)能在圖片上涂鴉程序
這篇文章主要為大家詳細(xì)介紹了AndroidStudio實(shí)現(xiàn)能在圖片上涂鴉程序,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-05-05