使用 Flutter 為應用程序製作動畫

已發表: 2022-03-10
快速總結↬ Flutter 為跨平台應用程序提供了出色的動畫支持。 本文探討了向應用程序添加動畫的新的非常規且更簡單的方法。 這些新的“動畫和運動”小部件到底是什麼?我們如何使用它們來添加簡單和復雜的動畫?

適用於任何平台的應用程序在直觀、美觀並為用戶交互提供令人愉快的反饋時都會受到稱讚。 動畫是做到這一點的方法之一。

Flutter 是一個跨平台的框架,在過去的兩年裡已經成熟,包括 Web 和桌面支持。 它贏得了使用它開發的應用程序流暢且美觀的聲譽。 憑藉其豐富的動畫支持、聲明式的 UI 編寫方式、“Hot Reload”等特性,它現在是一個完整的跨平台框架。

如果您剛開始使用 Flutter 並想學習一種非常規的添加動畫的方法,那麼您來對地方了:我們將探索動畫和運動小部件的領域,這是一種添加動畫的隱式方法。

Flutter 基於小部件的概念。 應用程序的每個視覺組件都是一個小部件——將它們視為 Android 中的視圖。 Flutter 使用 Animation 類、用於管理的“AnimationController”對象和用於插入數據范圍的“Tween”來提供動畫支持。 這三個組件協同工作以提供流暢的動畫。 由於這需要手動創建和管理動畫,因此它被稱為一種顯式的動畫製作方式。

現在讓我向您介紹動畫和運動小部件。 Flutter 提供了許多固有地支持動畫的小部件。 無需創建動畫對像或任何控制器,因為所有動畫都由此類小部件處理。 只需為所需的動畫選擇適當的小部件,然後將小部件的屬性值傳遞給動畫即可。 這種技術是一種隱含的動畫方式。

Flutter 中的動畫層次結構。 (大預覽)

上圖大致列出了 Flutter 中的動畫層次結構,以及如何支持顯式和隱式動畫。

本文介紹的一些動畫小部件是:

  • AnimatedOpacity
  • AnimatedCrossFade
  • AnimatedAlign
  • AnimatedPadding
  • AnimatedSize
  • AnimatedPositioned

Flutter 不僅提供了預定義的動畫小部件,還提供了一個名為AnimatedWidget的通用小部件,可用於創建自定義的隱式動畫小部件。 從名稱中可以看出,這些小部件屬於動畫和運動小部件類別,因此它們具有一些共同的屬性,可以讓我們使動畫更流暢和更好看。

現在讓我解釋一下這些常見的屬性,因為它們將在後面的所有示例中使用。

  • duration
    動畫參數的持續時間。
  • reverseDuration
    反向動畫的持續時間。
  • curve
    為參數設置動畫時應用的曲線。 插值可以取自線性分佈,或者,如果指定的話,可以取自曲線。

讓我們通過創建一個我們稱之為“Quoted”的簡單應用程序開始旅程。 每次應用啟動時,它都會顯示一個隨機報價。 有兩點需要注意:首先,所有這些引用都將在應用程序中進行硬編碼; 其次,不會保存任何用戶數據。

注意這些示例的所有文件都可以在 GitHub 上找到。

跳躍後更多! 繼續往下看↓

入門

應該安裝 Flutter,在繼續之前您需要熟悉基本流程。 一個很好的起點是“使用 Google 的 Flutter 進行真正的跨平台移動開發”。

在 Android Studio 中創建一個新的 Flutter 項目。

Android Studio 中新的顫振項目菜單。 (大預覽)

這將打開一個新項目嚮導,您可以在其中配置項目基礎知識。

Flutter 項目類型選擇界面。 (大預覽)

在項目類型選擇屏幕中,有各種類型的 Flutter 項目,每個項目都迎合特定的場景。對於本教程,選擇Flutter Application並按Next

您現在需要輸入一些特定於項目的信息:項目名稱和路徑、公司域等。 看看下面的圖片。

Flutter 應用程序配置屏幕。 (大預覽)

添加項目名稱、Flutter SDK 路徑、項目位置和可選的項目描述。 按下一步

Flutter 應用程序包名稱屏幕。 (大預覽)

每個應用程序(無論是 Android 還是 iOS)都需要一個唯一的包名稱。 通常,您使用網站域的反向; 例如,com.google 或 com.yahoo。 按Finish生成一個正常工作的 Flutter 應用程序。

生成的示例項目。 (大預覽)

項目生成後,您應該會看到如上所示的屏幕。 打開main.dart文件(在屏幕截圖中突出顯示)。 這是主要的應用程序文件。 示例工程本身是完整的,可以直接在模擬器或物理設備上運行,無需任何修改。

main.dart文件的內容替換為以下代碼片段:

 import 'package:animated_widgets/FirstPage.dart'; import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Animated Widgets', debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.blue, accentColor: Colors.redAccent, ), home: FirstPage(), ); } }

此代碼通過添加與創建新應用程序相關的簡單信息來清理main.dart文件。 MyApp 類返回一個對象: MaterialApp小部件,它提供了創建符合 Material Design 的應用程序的基本結構。 為了使代碼更有條理,在lib文件夾中創建兩個新的 dart 文件: FirstPage.dartQuotes.dart

FirstPage.dart 文件。 (大預覽)

FirstPage.dart將包含負責我們引用的應用程序所需的所有視覺元素(小部件)的所有代碼。 所有的動畫都在這個文件中處理。

注意在本文後面,每個動畫小部件的所有代碼片段都作為 Scaffold 小部件的子級添加到此文件中。 有關更多信息,GitHub 上的此示例可能很有用。

首先將以下代碼添加到FirstPage.dart 。 這是稍後將添加其他內容的部分代碼。

 import 'dart:math'; import 'package:animated_widgets/Quotes.dart'; import 'package:flutter/material.dart'; class FirstPage extends StatefulWidget { @override State createState() { return FirstPageState(); } } class FirstPageState extends State with TickerProviderStateMixin { bool showNextButton = false; bool showNameLabel = false; bool alignTop = false; bool increaseLeftPadding = false; bool showGreetings = false; bool showQuoteCard = false; String name = ''; double screenWidth; double screenHeight; String quote; @override void initState() { super.initState(); Random random = new Random(); int quoteIndex = random.nextInt(Quotes.quotesArray.length); quote = Quotes.quotesArray[quoteIndex]; } @override Widget build(BuildContext context) { screenWidth = MediaQuery.of(context).size.width; screenHeight = MediaQuery.of(context).size.height; return Scaffold( appBar: _getAppBar(), body: Stack( children: [ // All other children will be added here. // In this article, all the children widgets are contained // in their own separate methods. // Just method calls should be added here for the respective child. ], ), ); } } import 'dart:math'; import 'package:animated_widgets/Quotes.dart'; import 'package:flutter/material.dart'; class FirstPage extends StatefulWidget { @override State createState() { return FirstPageState(); } } class FirstPageState extends State with TickerProviderStateMixin { bool showNextButton = false; bool showNameLabel = false; bool alignTop = false; bool increaseLeftPadding = false; bool showGreetings = false; bool showQuoteCard = false; String name = ''; double screenWidth; double screenHeight; String quote; @override void initState() { super.initState(); Random random = new Random(); int quoteIndex = random.nextInt(Quotes.quotesArray.length); quote = Quotes.quotesArray[quoteIndex]; } @override Widget build(BuildContext context) { screenWidth = MediaQuery.of(context).size.width; screenHeight = MediaQuery.of(context).size.height; return Scaffold( appBar: _getAppBar(), body: Stack( children: [ // All other children will be added here. // In this article, all the children widgets are contained // in their own separate methods. // Just method calls should be added here for the respective child. ], ), ); } } import 'dart:math'; import 'package:animated_widgets/Quotes.dart'; import 'package:flutter/material.dart'; class FirstPage extends StatefulWidget { @override State createState() { return FirstPageState(); } } class FirstPageState extends State with TickerProviderStateMixin { bool showNextButton = false; bool showNameLabel = false; bool alignTop = false; bool increaseLeftPadding = false; bool showGreetings = false; bool showQuoteCard = false; String name = ''; double screenWidth; double screenHeight; String quote; @override void initState() { super.initState(); Random random = new Random(); int quoteIndex = random.nextInt(Quotes.quotesArray.length); quote = Quotes.quotesArray[quoteIndex]; } @override Widget build(BuildContext context) { screenWidth = MediaQuery.of(context).size.width; screenHeight = MediaQuery.of(context).size.height; return Scaffold( appBar: _getAppBar(), body: Stack( children: [ // All other children will be added here. // In this article, all the children widgets are contained // in their own separate methods. // Just method calls should be added here for the respective child. ], ), ); } } import 'dart:math'; import 'package:animated_widgets/Quotes.dart'; import 'package:flutter/material.dart'; class FirstPage extends StatefulWidget { @override State createState() { return FirstPageState(); } } class FirstPageState extends State with TickerProviderStateMixin { bool showNextButton = false; bool showNameLabel = false; bool alignTop = false; bool increaseLeftPadding = false; bool showGreetings = false; bool showQuoteCard = false; String name = ''; double screenWidth; double screenHeight; String quote; @override void initState() { super.initState(); Random random = new Random(); int quoteIndex = random.nextInt(Quotes.quotesArray.length); quote = Quotes.quotesArray[quoteIndex]; } @override Widget build(BuildContext context) { screenWidth = MediaQuery.of(context).size.width; screenHeight = MediaQuery.of(context).size.height; return Scaffold( appBar: _getAppBar(), body: Stack( children: [ // All other children will be added here. // In this article, all the children widgets are contained // in their own separate methods. // Just method calls should be added here for the respective child. ], ), ); } }
Quotes.dart 文件。 (大預覽)

Quotes.dart文件包含所有硬編碼引用的列表。 這裡需要注意的一點是列表是一個靜態對象。 這意味著它可以在其他地方使用,而無需創建 Quotes 類的新對象。 這是通過設計選擇的,因為上面的列表只是作為一個實用程序。

將以下代碼添加到該文件中:

 class Quotes { static const quotesArray = [ "Good, better, best. Never let it rest. 'Til your good is better and your better is best", "It does not matter how slowly you go as long as you do not stop.", "Only I can change my life. No one can do it for me." ]; }

項目骨架現在已經準備好了,讓我們再充實一下 Quoted。

AnimatedOpacity

為了給應用程序帶來個人風格,知道用戶的名字會很好,所以讓我們詢問它並顯示一個下一步按鈕。 在用戶輸入他們的名字之前,這個按鈕是隱藏的,當給出名字時它會優雅地顯示出來。 我們需要某種按鈕的可見性動畫,但是有沒有一個小部件呢? 就在這裡。

輸入AnimatedOpacity 。 此小部件通過添加隱式動畫支持構建在 Opacity 小部件上。 我們如何使用它? 記住我們的場景:我們需要顯示一個具有動畫可見性的下一步按鈕。 我們將按鈕小部件包裝在AnimatedOpacity小部件中,輸入一些適當的值並添加觸發動畫的條件——其餘的由 Flutter 處理。

 _getAnimatedOpacityButton() { return AnimatedOpacity( duration: Duration(seconds: 1), reverseDuration: Duration(seconds: 1), curve: Curves.easeInOut, opacity: showNextButton ? 1 : 0, child: _getButton(), ); }
下一個按鈕的不透明度動畫。 (大預覽)

AnimatedOpacity小部件有兩個強制性屬性:

  • opacity
    值為 1 表示完全可見; 0(零)表示隱藏。 在製作動畫時,Flutter 在這兩個極端之間插入值。 您可以看到如何放置條件以更改可見性,從而觸發動畫。
  • child
    將具有動畫可見性的子小部件。

您現在應該了解使用隱式小部件添加可見性動畫是多麼簡單。 所有這些小部件都遵循相同的準則並且易於使用。 讓我們繼續下一個。

AnimatedCrossFade

我們有用戶名,但小部件仍在等待輸入。 在上一步中,當用戶輸入他們的姓名時,我們會顯示下一個按鈕。 現在,當用戶按下按鈕時,我想停止接受輸入並顯示輸入的名稱。 當然,有很多方法可以做到這一點,但也許我們可以隱藏輸入小部件並顯示不可編輯的文本小部件。 讓我們使用AnimatedCrossFade小部件嘗試一下。

這個小部件需要兩個孩子,因為小部件根據某些條件在它們之間交叉淡入淡出。 使用此小部件時要記住的一件重要事情是兩個孩子的寬度應該相同。 如果高度不同,則從底部剪掉較高的小部件。 在這種情況下,兩個小部件將用作子部件:輸入和標籤。

 _getAnimatedCrossfade() { return AnimatedCrossFade( duration: Duration(seconds: 1), alignment: Alignment.center, reverseDuration: Duration(seconds: 1), firstChild: _getNameInputWidget(), firstCurve: Curves.easeInOut, secondChild: _getNameLabelWidget(), secondCurve: Curves.easeInOut, crossFadeState: showNameLabel ? CrossFadeState.showSecond : CrossFadeState.showFirst, ); }
輸入小部件和名稱小部件之間的交叉淡入淡出。 (大預覽)

此小部件需要一組不同的強制參數:

  • crossFadeState
    這種狀態決定了要顯示哪個孩子。
  • firstChild
    指定此小部件的第一個孩子。
  • secondChild
    指定第二個孩子。

AnimatedAlign

此時,名稱標籤位於屏幕中央。 它在頂部看起來會更好,因為我們需要屏幕中心來顯示引號。 簡單地說,名稱標籤小部件的對齊方式應該從中心更改為頂部。 將這種對齊更改與之前的交叉淡入淡出動畫一起製作動畫不是很好嗎? 我們開始做吧。

與往常一樣,可以使用多種技術來實現這一點。 由於名稱標籤小部件已經居中對齊,因此對其對齊進行動畫處理將比操作小部件的頂部和左側值簡單得多。 AnimatedAlign小部件非常適合這項工作。

要啟動此動畫,需要觸發器。 這個小部件的唯一目的是動畫對齊變化,所以它只有幾個屬性:添加一個孩子,設置它的對齊,觸發對齊變化,就是這樣。

 _getAnimatedAlignWidget() { return AnimatedAlign( duration: Duration(seconds: 1), curve: Curves.easeInOut, alignment: alignTop ? Alignment.topLeft : Alignment.center, child: _getAnimatedCrossfade(), ); }
名稱小部件的對齊動畫。 (大預覽)

它只有兩個強制性屬性:

  • 孩子:
    將修改其對齊方式的孩子。
  • 結盟:
    所需的對齊值。

這個小部件非常簡單,但結果很優雅。 此外,我們看到了使用兩個不同的動畫小部件來創建更複雜的動畫是多麼容易。 這就是動畫小部件的美妙之處。

AnimatedPadding

現在我們在頂部有用戶名,使用不同類型的動畫小部件,毫不費力地流暢地製作動畫。 讓我們在名字前添加一個問候語“Hi”。 在頂部添加一個值為“Hi”的文本小部件將使其與問候文本小部件重疊,如下圖所示。

問候語和姓名小部件重疊。 (大預覽)

如果名稱文本小部件的左側有一些填充怎麼辦? 增加左邊的內邊距肯定會起作用,但是等等:我們可以用一些動畫來增加內邊距嗎? 是的,這就是AnimatedPadding所做的。 為了讓這一切看起來更好,讓我們讓問候文本小部件淡入,同時增加名稱文本小部件的填充。

 _getAnimatedPaddingWidget() { return AnimatedPadding( duration: Duration(seconds: 1), curve: Curves.fastOutSlowIn, padding: increaseLeftPadding ? EdgeInsets.only(left: 28.0) : EdgeInsets.only(left: 0), child: _getAnimatedCrossfade(), ); }

由於上面的動畫應該是在之前的動畫對齊完成之後才會出現,所以我們需要延遲觸發這個動畫。 簡單地偏離主題,現在是討論增加延遲的流行機制的好時機。 Flutter 提供了幾種這樣的技術,但 Future.delayed 構造函數是一種更簡單、更清晰、更易讀的方法。 例如,要在 1 秒後執行一段代碼:

 Future.delayed(Duration(seconds: 1), (){ sum = a + b; // This sum will be calculated after 1 second. print(sum); });

由於延遲持續時間是已知的(根據之前的動畫持續時間計算),因此可以在此間隔之後觸發動畫。

 // Showing “Hi” after 1 second - greetings visibility trigger. _showGreetings() { Future.delayed(Duration(seconds: 1), () { setState(() { showGreetings = true; }); }); } // Increasing the padding for name label widget after 1 second - increase padding trigger. _increaseLeftPadding() { Future.delayed(Duration(seconds: 1), () { setState(() { increaseLeftPadding = true; }); }); }
名稱小部件的填充動畫。 (大預覽)

這個小部件只有兩個強制屬性:

  • child
    此小部件內的子級,將應用填充。
  • padding
    要添加的空間量。

AnimatedSize

今天,任何具有某種動畫的應用程序都將包括放大或縮小視覺組件以吸引用戶注意力(通常稱為縮放動畫)。 為什麼不在這裡使用相同的技術? 我們可以向用戶展示從屏幕中心放大的勵志名言。 讓我向您介紹AnimatedSize小部件,它啟用放大和縮小效果,通過更改其子項的大小來控制。

當涉及到所需的參數時,這個小部件與其他小部件有點不同。 我們需要 Flutter 所說的“Ticker”。 Flutter 有一種方法可以讓對象知道何時觸發了新的幀事件。 它可以被認為是發出一個信號,說:“現在就做! … 現在做! … 現在做! ……”

AnimatedSize小部件需要一個屬性——vsync——它接受一個ticker provider。 獲取代碼提供者的最簡單方法是在類中添加一個Mixin 。 有兩個基本的代碼提供者實現: SingleTickerProviderStateMixin ,它提供單個代碼; 和TickerProviderStateMixin ,它提供了幾個。

Ticker 的默認實現用於標記動畫的幀。 在這種情況下,採用後者。 更多關於混合的信息。

 // Helper method to create quotes card widget. _getQuoteCardWidget() { return Card( color: Colors.green, elevation: 8.0, child: _getAnimatedSizeWidget(), ); } // Helper method to create animated size widget and set its properties. _getAnimatedSizeWidget() { return AnimatedSize( duration: Duration(seconds: 1), curve: Curves.easeInOut, vsync: this, child: _getQuoteContainer(), ); } // Helper method to create the quotes container widget with different sizes. _getQuoteContainer() { return Container( height: showQuoteCard ? 100 : 0, width: showQuoteCard ? screenWidth - 32 : 0, child: Center( child: Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Text(quote, style: TextStyle(color: Colors.white, fontWeight: FontWeight.w400, fontSize: 14),), ), ), ); } // Trigger used to show the quote card widget. _showQuote() { Future.delayed(Duration(seconds: 2), () { setState(() { showQuoteCard = true; }); }); }
報價小部件的縮放動畫。 (大預覽)

此小部件的強制屬性:

  • vsync
    協調動畫和幀更改所需的代碼提供程序。<
  • child
    大小發生變化的孩子將被動畫化。

放大和縮小動畫現在很容易馴服。

AnimatedPositioned

偉大的! 引號從中心放大以吸引用戶的注意力。 如果它在放大時從底部滑上去怎麼辦? 讓我們試試看。 此動作涉及使用報價小部件的位置和動畫位置屬性的變化。 AnimatedPositioned是完美的候選。

只要指定的位置發生變化,這個小部件就會在給定的持續時間內自動轉換孩子的位置。 需要注意的一點:它僅在其父小部件是“堆棧”時才有效。 這個小部件非常簡單易用。 讓我們來看看。

 // Helper method to create the animated positioned widget. // With position changes based on “showQuoteCard” flag. _getAnimatedPositionWidget() { return AnimatedPositioned( duration: Duration(seconds: 1), curve: Curves.easeInOut, child: _getQuoteCardWidget(), top: showQuoteCard ? screenHeight/2 - 100 : screenHeight, left: !showQuoteCard ? screenWidth/2 : 12, ); }
具有引號縮放動畫的位置。 (大預覽)

這個小部件只有一個強制屬性:

  • child
    位置將被更改的小部件。

如果不希望孩子的大小隨著它的位置而改變,則此小部件的更具性能的替代方案是SlideTransition

這是我們的完整動畫:

所有動畫小部件在一起。 (大預覽)

結論

動畫是用戶體驗不可或缺的一部分。 靜態應用程序或帶有 janky 動畫的應用程序不僅會降低用戶留存率,還會降低開發人員在交付成果方面的聲譽。

今天,大多數流行的應用程序都有某種微妙的動畫來取悅用戶。 對用戶請求的動畫反饋也可以吸引他們進行更多探索。 Flutter 為跨平台開發提供了很多功能,包括對流暢和響應式動畫的豐富支持。

Flutter 有很好的插件支持,允許我們使用來自其他開發者的動畫。 現在已經成熟到 1.9 版本,在社區的厚愛下,Flutter 未來一定會變得更好。 我想說現在是學習 Flutter 的好時機!

更多資源

  • “動畫和運動小部件”,Flutter Docs
  • “動畫簡介”,Flutter Docs
  • Flutter 代碼實驗室

編者註非常感謝 Ahmad Awais 在審閱本文時提供的幫助。