Flutter 實現底部擴散模糊動畫(一)跳轉頁面

AlexV525發表於2019-08-21

相關文章

背景

  一直以來,專案組的小夥伴對於某安的設計和互動十分喜愛,從首頁佈局到使用者頁樣式到加號擴散動畫,都想用到專案裡來。鑑於他們強烈的熱愛,已經實現了部分佈局看齊。最近,終於輪到了要實現點選底部加號後出現擴散動畫,並出現幾項操作項的動畫的時候了。

簡介

  閱讀這篇文章前,你需要對Flutter有一定的瞭解,包括生命週期、高斯模糊、動畫、MediaQuery等相關知識,當然,所有內容都可以通過搜尋找到~

  效果圖:   

Flutter 實現底部擴散模糊動畫(一)跳轉頁面

  互動過程主要分為以下三步:

  • 點選加號,從加號位置以圓形擴散高斯模糊效果;
  • 操作項依次出現,並附帶一定的動畫效果;
  • 點選"X"或空白處或系統返回鍵,背景以圓形收縮至加號位置。


  完整demo及元件已上傳至專案,走過路過留個star~

前置條件

  想要實現效果,首先有幾點前置條件需要明確:

  • 路由需要做成透明路由,否則高斯模糊無法作用在上一個路由之上;
  • 根據生命週期,動畫的執行必須要在第一次build後立即執行,而不能在initStatedidChangeDependencies裡執行,否則會存在context為空或觸發時機錯誤的問題;
  • 關閉動畫必須要在pop()前執行,否則widget已經被取消掛載(this.mounted == false)

實現過程

  下面是具體的實現過程,將配合上述條件進行說明。

透明跳轉路由

  網上有非常多的透明路由例項,包括法法路由裡也包含了透明路由,此處不再贅述,直接貼上程式碼。

class TransparentRoute extends PageRoute<void> {
    TransparentRoute({
        @required this.builder,
        RouteSettings settings,
    })  : assert(builder != null),
                super(settings: settings, fullscreenDialog: false);

    final WidgetBuilder builder;

    @override
    bool get opaque => false;
    @override
    Color get barrierColor => null;
    @override
    String get barrierLabel => null;
    @override
    bool get maintainState => true;
    @override
    /// 這裡時長設定為0,是因為我們的佈局一開始
    /// 並不包含任何內容,所以直接砍掉跳轉時間。
    Duration get transitionDuration => Duration.zero;

    @override
    Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
        final result = builder(context);
        return Semantics(
            scopesRoute: true,
            explicitChildNodes: true,
            child: result,
        );
    }
}
複製程式碼

  構建完成後,直接push就OK。

Navigator.of(context).push(TransparentRoute(
    builder: (context) => AddingButtonPage(),
));
複製程式碼

擴散動畫

  在widget中實現執行動畫,首先需要加入TickerProviderStateMixin,並且宣告一個controller和動畫(Animation)本身。

class _DemoPageState extends State<DemoPage>
    with TickerProviderStateMixin {
/.../
    Animation<double> _backDropFilterAnimation;
    AnimationController _backDropFilterController;
複製程式碼

  在隨後的功能中,我們首先對controller進行初始化,設定一個動畫時長。

_backDropFilterController = AnimationController(
    duration: Duration(milliseconds: 300),
    vsync: this,
);
複製程式碼

  這時我們開始思考擴散大小的問題:以底部為中心,半徑逐漸放大的圓,當半徑達到多少時能完全覆蓋可視範圍呢?

答案:√ (width² + (height * 2 + padding.top)²) / 2
根號(二倍高的平方加寬的平方)的一半

  是不是一個非常熟悉的公式?沒錯,它就是“勾股定理”~

Flutter 實現底部擴散模糊動畫(一)跳轉頁面
  貼上以dart:math簡單實現的勾股定理:

import 'dart:math' as math;

double pythagoreanTheorem(double short, double long) {
    return math.sqrt(math.pow(short, 2) + math.pow(long, 2));
}
複製程式碼

  這裡利用一張圖片說明半徑的問題。

Flutter 實現底部擴散模糊動畫(一)跳轉頁面
  為了讓模糊控制元件能完整的覆蓋檢視區域,擴散的圓的半徑必須大於以檢視長的兩倍和寬及其頂點連線而成的斜邊的長度,而不能只是檢視的高度。padding.top是狀態列的高度,也要加入到高度中。

  所以,我們就確定了圓形的終止半徑,且起始半徑為0。這個時候可以寫出第一個Tween了,用於確定圓形半徑的變化範圍。MediaQuery用於獲取檢視長短邊。順便定義一個曲線,實現曲線過渡效果。Flutter的Curves裡內建了許多曲線,在這我選用了Curves.easeInOut

/// 視野區域的大小(Size)
final MediaQueryData m = MediaQuery.of(context);
final Size s = m.size;
final double r = pythagoreanTheorem(s.width, s.height * 2 + m.padding.top) / 2;

/// 動畫曲線
Animation _backDropFilterCurve = CurvedAnimation(
    parent: _backDropFilterController,
    curve: Curves.easeInOut,
);

/// 放大動畫的設定檔
Animation<double> _backDropFilterAnimation = Tween(
    begin: 0.0, end: r * 2
).animate(_backDropFilterCurve);
複製程式碼

  此處終止值是兩倍半徑的原因是圓形的繪製是以圓形的外正方形大小來進行的繪製的,所以此處大小需要設定為兩倍半徑,以達到真正的半徑效果。

  一個動畫的設定檔完成了,要想讓動畫動起來,需要把動畫執行的值和一個變數繫結,並且執行動畫。所以我們給這個動畫加上監聽後執行setState以更新大小,並且執行動畫。

/// 儲存半徑的變數
double _backdropFilterSize = 0.0;

/// 監聽動畫執行
_backDropFilterAnimation.addListener(() {
    setState(() {
        _backdropFilterSize = _backDropFilterAnimation.value;
    });
});

/// 正向執行動畫
_backDropFilterController.forward();
複製程式碼

  至此,放大動畫已經完成了設定,接下來我們建立佈局與該動畫進行繫結。

高斯模糊佈局

  剛剛在設定動畫時我們已經知道,圓形的最終大小是遠遠超過檢視可視大小的,在Flutter中想要實現這樣的相對佈局或絕對佈局,我們需要用到Stack。這時需要注意,Stack的溢位屬性(overflow)需要設定為顯示,否則圓形只能擴大到檢視最大寬度。

Stack(
    overflow: Overflow.visible,
    children: <Widget>[],
);
複製程式碼

  我們開始來考慮高斯模糊的區域大小。已知圓形的半徑為對角線長度,那麼以此設定的區域應該是多大呢?

  再次拿出一張圖來看看我們的擴散圓形相對於檢視應該處於什麼位置:

Flutter 實現底部擴散模糊動畫(一)跳轉頁面
  Positioned使用的是絕對佈局,在此處,它的參考系是檢視區域。那麼我們可以很輕易的判斷頂部和橫向的溢位,用於計算大小。

final MediaQueryData m = MediaQuery.of(context);
final Size s = m.size;
final double r = pythagoreanTheorem(s.width, s.height * 2 + m.padding.top) / 2;

/// 頂部溢位大小
final double topOverflow = r - s.height;
/// 橫向溢位大小
final double horizontalOverflow = r - s.width;

return Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(
            left: - horizontalOverflow,
            right: - horizontalOverflow,
            top: - topOverflow,
            bottom: - r,
/.../
複製程式碼

  以此設定範圍,就是圓形擴大到最大半徑時外正方形的大小。

  在Flutter中實現高斯模糊非常簡單,只需要使用BackdropFilter即可,通常來說需要在外包裹ClipRect用來解決模糊區域的問題,而我們的需求是圓形,所以在這裡應該使用ClipRRect

import 'dart:ui' as ui;

Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(
            left: - horizontalOverflow,
            right: - horizontalOverflow,
            top: - topOverflow,
            bottom: - r,
            child: SizedBox(
                /// 高寬與變數繫結
                width: _backdropFilterSize,
                height: _backdropFilterSize,
                /// 使用圓角ClipRRect達到圓形效果
                child: ClipRRect(
                    /// 圓角的大小,使用最大值則所有時候都為圓形
                    borderRadius: BorderRadius.circular(r * 2),
                    child: BackdropFilter(
                        /// XY用於設定模糊程度
                        filter: ui.ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0),
                        /// 使用空格佔位,否則模糊背景不顯示
                        child: Text(" "),
                    ),
                ),
            ),
        ),
    ],
);
複製程式碼

  將高斯模糊控制元件放入佈局中,我們便完成了圓形的定位。

設定可放置內容的區域

  實現了背景模糊,接下來就是將內容放置在佈局中合理的大小區域。

  我們的圓形上半部分位於可視區域,所以我們在背景中,使用Align,利用溢位大小和已知的可視區域大小,便可以確定內容放置的位置。

Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(...),
        Align(
            /// 區域相對頂部居中對齊,在可視區域附近
            alignment: Alignment.topCenter,
            child: Container(
                /// 推出頂部溢位部分,使得區域頂部對齊檢視頂部
                margin: EdgeInsets.only(top: topOverflow),
                /// 將可視區域大小設定為控制元件大小
                width: s.width,
                height: s.height,
                /// 設定constraint,防止子控制元件發生意料之外的溢位
                constraints: BoxConstraints(
                    maxWidth: s.width,
                    maxHeight: s.height,
                ),
                child: child ?? SizedBox(),
            ),
        );
    ],
);
複製程式碼

  至此,我們可以很方便地在模糊區域內放置內容了,不需要使用時再去設定佈局。

整體執行

  動畫部分完成,我們將動畫部分封裝起來,加入到首次完成build後執行。

import 'package:flutter/scheduler.dart';

class _AddingButtonPageState extends State<AddingButtonPage> with TickerProviderStateMixin {
    @override
    void initState() {
        /// 使用scheduler,將動畫加入到build後進行
        SchedulerBinding.instance.addPostFrameCallback((_) => backDropFilterAnimate(context));
        super.initState();
    }
    
    
    void backDropFilterAnimate(BuildContext context) async {
        final Size s = MediaQuery.of(context).size;

        _backDropFilterController = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        Animation _backDropFilterCurve = CurvedAnimation(
            parent: _backDropFilterController,
            curve: Curves.easeInOut,
        );
        _backDropFilterAnimation = Tween(
            begin: 0.0,
            end: pythagoreanTheorem(s.width, s.height) * 2,
        ).animate(_backDropFilterCurve)
            ..addListener(() {
                setState(() {
                    _backdropFilterSize = _backDropFilterAnimation.value;
                });
            });
        _backDropFilterController.forward();
    }
    
/.../
複製程式碼

  至此,一個底部擴散模糊動畫跳轉頁面的動畫就這樣輕鬆如意的完成啦~

結語

  根據幾個月的潛水經驗,大多數人覺得Flutter製作動畫困難是因為看不懂Animation的各種屬性和操作,甚至文件都生澀難懂,可其實真正寫出來後,動畫部分也只有少量程式碼,很容易就可以理解其中的含義。

  最後歡迎加入Flutter Candies,一起生產可愛的Flutter小糖果 (QQ群:181398081)

Flutter 實現底部擴散模糊動畫(一)跳轉頁面

相關文章