Flutter 實現底部擴散模糊動畫(二)頁面互動

AlexV525發表於2019-08-23

相關文章

前言

  在上一期,我們已經完成了點開動畫的編寫和執行,如果有仔細看完的小夥伴會發現,其中的動畫效果不止擴散這麼簡單,本篇就來繼續研究其餘的動畫互動。

簡介

  作為一個炫(pin)酷(ru)的頁面,頁面中的互動也非常的重要。在本篇,我將進一步說明頁面內各個位置的互動細節,從而帶著各位做一個不將就的強迫症~

  效果圖:

Flutter 實現底部擴散模糊動畫(二)頁面互動

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

互動要素

  頁面中的互動主要包含三個觸發位置:

  • 點選空白的模糊處,頁面會執行退出和退出動畫;
  • 點選頁面上的返回或關閉按鈕,頁面會執行退出和退出動畫;
  • 元素漸顯並帶有其他效果。

  接下來將逐點說明如何實現。

實現過程

攔截返回操作

  我們知道在Flutter中,頁面要返回時,會執行Navigator.maybePop的方法,使頁面返回。為了攔截路由pop,Flutter提供了WillPopScope來攔截返回行為,我們只需要註冊onWillPop方法,就可以在pop前執行程式碼。

bool _popping = false;

Future<bool> willPop() async {
    /// 等待返回動畫的執行
    await backDropFilterAnimate(context, false);
    /// 判斷_popping從而避免重複觸發pop
    if (!_popping) {
        _popping = true;
        await Future.delayed(Duration(milliseconds: _animateDuration), () {
            Navigator.of(context).pop();
        });
    }
    return null;
}

@override
Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.transparent,
        body: WillPopScope(
            /// 繫結willPop方法
            onWIllPop: willPop,
            child: wrapper(
                context,
                child: widget.child,
            ),
        ),
    );
}
複製程式碼

  如此我們就輕鬆愉快地攔截了路由~

退出動畫

  思考退出動畫和跳轉動畫的關係,我們立馬就可以想到,跳轉和退出的動畫是相反的,也就是說,逆向執行跳轉的動畫,就能得到一個退出動畫。

  這時我們來回顧一下上一期的跳轉動畫:

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();
}
    
複製程式碼

  要想以相反的方向執行動畫,我們加入一個引數bool forward

void backDropFilterAnimate(BuildContext context, bool forward)

  使用forward來控制beginend,達到執行的效果。同時對forward進行判斷,如果為false嘗試暫停動畫

void backDropFilterAnimate(BuildContext context, bool forward) {
    /.../
    if (!forward) _backDropFilterController?.stop();
    
    _backDropFilterAnimation = Tween(
        /// 三元運算賦值
        begin: forward ? 0.0 : _backdropFilterSize,
        end: forward ? pythagoreanTheorem(s.width, s.height) * 2 : 0.0,
    ).animate(_backDropFilterCurve)
        ..addListener(() {
            setState(() {
                _backdropFilterSize = _backDropFilterAnimation.value;
            });
        });
    
    /.../
}
複製程式碼

  看到這裡可能會有小夥伴問了,AnimateController明明提供了reverse方法用於反向,為什麼還要使用一個bool來控制動畫執行方向呢?

  原因在於當使用reverse時,控制器會將beginend對調來執行動畫,但當我們執行退出動畫時,圓形不一定已經完全覆蓋,所以通過使用forward來判斷方向,可以使未完全覆蓋的動畫從停止處反向執行,不會造成閃爍的情況。

  至此,跳轉和退出動畫已經完美完成。

"X" & 空白處返回

  根據效果圖,在頁面的底部,會提供一個帶有旋轉動畫返回按鈕,點選可以返回。

  由於我的頁面時點選加號觸發的,所以這裡我引入了bottomHeight,用來確定加號的位置。從效果圖可以看到我的底部導航欄,它的高度我們假設是60.0,那按鈕的位置如何定義呢?

final double bottomHeight = 60.0;
/.../
Widget popButton() {
    return SizedBox(
        /// 此處假設為60.0
        width: widget.bottomHeight,
        height: widget.bottomHeight,
        child: Center(
            /// 套手勢監聽,並設定監聽行為
            child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                child: Icon(
                    Icons.add,
                    color: Colors.grey
                ),
                onTap: willPop,
            ),
        ),
    );
}
複製程式碼

  將它放入佈局中:

Stack(
    /.../
    children: <Widget>[
        Positioned(
            /// 將按鈕控制元件固定在檢視底部中央
            left: 0.0,
            right: 0.0,
            bottom: 0.0,
            child: popButton(),
        ),
    ],
)
複製程式碼

  按鈕定位完成,這時我們開始設計動畫。按鈕一共需要兩組動畫,一組是旋轉,一組是淡入淡出。

/// 初始化按鈕旋轉的角度
final double bottomButtonRotateDegree = 45.0;

/// 旋轉動畫相關
Animation<double> _popButtonAnimation;
AnimationController _popButtonController;
/// 淡入淡出相關
Animation<double> _popButtonOpacityAnimation;
AnimationController _popButtonOpacityController;

void popButtonAnimate(context, bool forward) {
    /// 與背景相同,判斷正反執行
    if (!forward) {
        _popButtonController?.stop();
        _popButtonOpacityController?.stop();
    }
    /// 轉換按鈕實際旋轉角度
    final double rotateDegree =
        widget.bottomButtonRotateDegree * (math.pi / 180);
        
    /// 
    _popButtonOpacityController = _popButtonController = AnimationController(
        duration: Duration(milliseconds: _animateDuration),
        vsync: this,
    );
    Animation _popButtonCurve = CurvedAnimation(
        parent: _popButtonController,
        curve: Curves.easeInOut,
    );
    _popButtonAnimation = Tween(
        begin: forward ? 0.0 : _popButtonRotateAngle,
        end: forward ? rotateDegree : 0.0,
    ).animate(_popButtonCurve)
        ..addListener(() {
            setState(() {
                _popButtonRotateAngle = _popButtonAnimation.value;
            });
        });
    /// 設定透明度最小值為0.01,防止背景顯示錯誤
    _popButtonOpacityAnimation = Tween(
        begin: forward ? 0.01 : _popButtonOpacity,
        end: forward ? 1.0 : 0.01,
    ).animate(_popButtonCurve)
        ..addListener(() {
            setState(() {
                _popButtonOpacity = _popButtonOpacityAnimation.value;
            });
        });
    _popButtonController.forward();
    _popButtonOpacityController.forward();
}
複製程式碼

  按鈕動畫構建完成,我們將它放到背景動畫中一起執行:

Future backDropFilterAnimate(BuildContext context, bool forward) async {
    /.../
    /// 使用相同的forward控制方向
    popButtonAnimate(context, forward);
    /.../
}
複製程式碼

  至此,按鈕的動畫會跟著背景一起聯動了,十分完美~

  但,彆著急結束,我們還有內容的動畫定製沒有完成,如果不需要如效果圖一般的元素動畫,可以出門右轉~

操作項動畫

  從效果圖我們可以看到,兩個操作項是依次淡入出現,並且帶有一定的垂直位移。這時問題出現了:我的操作項數量不確定,難道每一個操作項我都要專門寫一個動畫嗎?

  答案是:對了一半。為什麼這麼說?我們確實需要寫操作項的動畫,但我們不需要重複地去寫每一個操作項,只需要通過封裝操作項的內容,將動畫所有相關內容也組成數個List,問題就簡單了很多。

  以效果圖為例,我有兩個操作項,先進行宣告。

List<String> itemTitles = ["動態", "掃一掃"];
List<String> itemIcons = ["subscriptedAccount", "scan"];
List<Color> itemColors = [Colors.orange, Colors.teal];
List<Function> itemOnTap = [...];
複製程式碼

  將操作項所有的資訊儲存在四個陣列中。接下來我們建立兩組動畫共8個陣列的相關變數。

/// 操作項垂直偏移量
List<double> _itemOffset;
/// 操作項偏移動畫
List<Animation<double>> _itemAnimations;
/// 操作項偏移動畫曲線
List<CurvedAnimation> _itemCurveAnimations;
/// 操作項偏移動畫控制器
List<AnimationController> _itemAnimateControllers;
/// 操作項透明度
List<double> _itemOpacity;
/// 操作項透明度動畫
List<Animation<double>> _itemOpacityAnimations;
/// 操作項透明度動畫曲線
List<CurvedAnimation> _itemOpacityCurveAnimations;
/// 操作項透明度動畫控制器
List<AnimationController> _itemOpacityAnimateControllers;
複製程式碼

  那麼,該怎麼初始化動畫呢?

void initItemsAnimation() {
    /// 根據操作項內容,初始化動畫相關變數
    _itemOffset = <double>[for (int i=0; i<itemTitles.length; i++) 0.0];
    _itemAnimations = List<Animation<double>>(itemTitles.length);
    _itemCurveAnimations = List<CurvedAnimation>(itemTitles.length);
    _itemAnimateControllers = List<AnimationController>(itemTitles.length);
    _itemOpacity = <double>[for (int i=0; i<itemTitles.length; i++) 0.01];
    _itemOpacityAnimations = List<Animation<double>>(itemTitles.length);
    _itemOpacityCurveAnimations = List<CurvedAnimation>(itemTitles.length);    _itemOpacityAnimateControllers = List<AnimationController>(itemTitles.length);
    
    /// 遍歷操作性,初始化每一個動畫內容
    for (int i = 0; i < itemTitles.length; i++) {
        /// 垂直偏移動畫的設定
        _itemAnimateControllers[i] = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        _itemCurveAnimations[i] = CurvedAnimation(
            parent: _itemAnimateControllers[i],
            curve: Curves.ease,
        );
        /// 垂直偏移量設定為20
        _itemAnimations[i] = Tween(
            begin: -20.0,
            end: 0.0,
        ).animate(_itemCurveAnimations[i])                ..addListener(() {
                setState(() {
                    _itemOffset[i] = _itemAnimations[i].value;
                });
            });
        
        /// 透明度動畫的設定
        _itemOpacityAnimateControllers[i] = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        _itemOpacityCurveAnimations[i] = CurvedAnimation(
            parent: _itemOpacityAnimateControllers[i],
            curve: Curves.linear,
        );
        _itemOpacityAnimations[i] = Tween(
            begin: 0.01,
            end: 1.0,
        ).animate(_itemOpacityCurveAnimations[i])
            ..addListener(() {
                setState(() {
                    _itemOpacity[i] = _itemOpacityAnimations[i].value;
                });
            });
    }
}

/// 操作項動畫的執行
void itemsAnimate(bool forward) {
    for (int i = 0; i < _itemAnimateControllers.length; i++) {
        /// 每個操作項依次增加延時,形成連續效果
        Future.delayed(Duration(milliseconds: 50 * i), () {
            if (forward) {
                _itemAnimateControllers[i]?.forward();
                _itemOpacityAnimateControllers[i]?.forward();
            } else {
                _itemAnimateControllers[i]?.reverse();
                _itemOpacityAnimateControllers[i]?.reverse();
            }
        });
    }
}
複製程式碼

  建立操作項的widget,將動畫值進行繫結:

Widget item(BuildContext context, int index) {
    return Stack(
        overflow: Overflow.visible,
        children: <Widget>[
            Positioned(
                left: 0.0, right: 0.0,
                /// 繫結垂直偏移
                top: _itemOffset[index],
                child: Opacity(
                    /// 繫結透明度
                    opacity: _itemOpacity[index],
                    child: ...
                ),
            ),
        ],
    );
}
複製程式碼

  最後將動畫初始化放進initState,動畫執行新增至跳轉動畫。

@override
void initState() {
    initItemsAnimation();
    /.../
}

Future backDropFilterAnimate(BuildContext context, bool forward) async {
    /.../
    if (forward) {
        /// 以跳轉動畫二分之一的延時執行,效果更佳
        Future.delayed(
            Duration(milliseconds: _animateDuration ~/ 2),
            () { itemsAnimate(true); },
        );
    } else {
        itemsAnimate(false);
    }
}
複製程式碼

  一切就緒,儲存就可以看到精美的動畫效果了~

結語

  這個動畫個人耗時大約2小時,在思路非常清晰的情況下,將動畫效果實現不是一件難事,這樣的動畫其實相對不難,接下來可能會有內容揭開、位置自定義等花式的需求,讓我們拭目以待~

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

Flutter 實現底部擴散模糊動畫(二)頁面互動

相關文章