用 Flutter 實現動畫 Motion Design

貓哥說

這篇文章講的是如何在你的動畫中加入運動特性、運動球、重力、貝塞爾曲線、多邊形、不規則曲線,若是你正在找這方面資料,這個源碼你可要好好消化了。這都是動畫中的基礎,前端就是要酷炫,開始吧。前端

最佳體驗仍是閱讀原文(連接在下面)。react

老鐵記得 轉發 ,貓哥會呈現更多 Flutter 好文~~~~

微信羣 ducafecat

b站 https://space.bilibili.com/40...

原文

https://preyea-regmi.medium.c...git

代碼

https://github.com/PreyeaRegm...github

參考

正文

大部分時間實現運動設計是一個有點累贅的移動應用程序。本文從更加實用的角度闡述瞭如何經過 Flutter 實現運動設計。咱們將採起一個簡單的運動設計從運球做爲一個參考,並開始建設它一步一步。全部版權保留給各自的做者,實現的完整源代碼能夠在 github 上找到。編程

https://github.com/PreyeaRegm...canvas

如今咱們將重點放在登陸/註冊交互上。因此,就像其餘的交互設計同樣,咱們將嘗試把它分解成多個場景,這樣咱們就能夠有一個清晰的總體概念,並將這些場景連接在一塊兒。api

場景 1: 初始狀態屏幕

在這個場景中,咱們在底部有一個彈跳的圖像和文字,一個彎曲的白色背景,一個品牌標題包圍着圖像的中心和變形蟲形狀的背景。拖動底部的內容,直到必定的距離被覆蓋,揭示動畫播放和場景轉換到下一個場景。微信

展現動畫(中間場景)

在這個中間場景中,曲線背景高度是動畫的。此外,在這個動畫,控制點的三次貝塞爾曲線也被平移和還原,以提供加速效果。側面的圖標和變形蟲背景也在垂直方向上 translated 以響應動畫的顯示。編程語言

場景 2: 後期顯示動畫狀態屏幕

當顯示動畫完成後,品牌標題被一個圓形圖標取代,一個標籤指示器從屏幕左側飛過來,相應的標籤被加載。ide

如今咱們有了設計中涉及的相關場景的概述。下一步,咱們嘗試將這些想法映射到實現細節中。那麼讓咱們開始吧。

咱們將使用 stack 做爲頂級容器來託管咱們全部的場景,並根據當前場景狀態,咱們將向 stack 添加各自的小部件,並動畫他們的幾何圖形。

@override
  Widget build(BuildContext context) {
    List<Widget> stackChildren = [];

    switch (currentScreenState) {
      case CURRENT_SCREEN_STATE.INIT_STATE:
        stackChildren.addAll(_getBgWidgets());
        stackChildren.addAll(_getDefaultWidgets());
        stackChildren.addAll(_getInitScreenWidgets());
        stackChildren.add(_getBrandTitle());

        break;
      case CURRENT_SCREEN_STATE.REVEALING_ANIMATING_STATE:
        stackChildren.addAll(_getBgWidgets());
        stackChildren.addAll(_getDefaultWidgets());
        stackChildren.add(_getBrandTitle());
        break;
      case CURRENT_SCREEN_STATE.POST_REVEAL_STATE:
        stackChildren.addAll(_getBgWidgets());
        stackChildren.addAll(_getDefaultWidgets());
        stackChildren.insert(stackChildren.length - 1, _getCurvedPageSwitcher());
        stackChildren.addAll(_getPostRevealAnimationStateWidgets());
        stackChildren.add(buildPages());
        break;
    }

    return Stack(children: stackChildren);
  }

對於場景 1,全部相應的小部件都被定位並添加到 stack 中。底部「向上滑動開始」小部件的彈跳效果也當即開始。

//Animation Controller for setting bounce animation for "Swipe up" text widget
    _swipeUpBounceAnimationController =
        AnimationController(duration: Duration(milliseconds: 800), vsync: this)
          ..repeat(reverse: true);

    //Animation for actual bounce effect
    _swipeUpBounceAnimation = Tween<double>(begin: 0, end: -20).animate(
        CurvedAnimation(
            parent: _swipeUpBounceAnimationController,
            curve: Curves.easeOutBack))
      ..addListener(() {
        setState(() {
          _swipeUpDy = _swipeUpBounceAnimation.value;
        });
      });

    //We want to loop bounce effect until user intercepts with drag touch event.
    _swipeUpBounceAnimationController.repeat(reverse: true);


//Animated value used by corresponding "Swipe up to Start" Widget in _getInitScreenWidgets() method
 Positioned(
          right: 0,
          left: 0,
          bottom: widget.height * .05,
          child: Transform.translate(
              offset: Offset(0, _swipeUpDy),
              child: IgnorePointer(
                child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      Icon(
                        Icons.upload_rounded,
                        color: Colors.deepPurple,
                        size: 52,
                      ),
                      Text(
                        "Swipe up to start",
                        style: TextStyle(color: Colors.grey.shade800),
                      )
                    ]),
              ))),

爲了實現這個小部件的拖動行爲,一個可滾動的小部件也被放置在頂部,覆蓋屏幕的下半部分。「向上滑動開始」也會根據拖動距離進行 translated,一旦跨過閾值(可滾動部件高度的 70%) ,就會播放顯示動畫。

//A simple container with a SingleChildScrollView. The trick is to set the child of SingleChildScrollView height
//exceed the height of parent scroll widget so it can be scrolled. The BouncingScrollPhysics helps the scroll retain its
//original position if it doesn't cross the threshold to play reveal animation.
//This widget is added by _getInitScreenWidgets() method
Positioned(
        right: 0,
        left: 0,
        bottom: 0,
        child: Container(
          height: widget.height * .5,
          child: SingleChildScrollView(
            controller: _scrollController,
            physics: BouncingScrollPhysics(),
            child: Container(
              height: widget.height * .5 + .1,
              // color:Colors.yellow,
            ),
          ),
        ),
      ),

 //Intercepts the bounce animation and start dragg animation
  void _handleSwipe() {
    _swipeUpBounceAnimationController.stop(canceled: true);
    double dy = _scrollController.position.pixels;

    double scrollRatio =
        math.min(1.0, _scrollController.position.pixels / _swipeDistance);

    //If user scroll 70% of the scrolling region we proceed towards reveal animation
    if (scrollRatio > .7)
      _playRevealAnimation();
    else
      setState(() {
        _swipeUpDy = dy * -1;
      });
  }

在顯示動畫中,使用 CustomPainter 繪製曲線背景和變形蟲背景。在動畫製做過程當中,曲線背景的高度以及中間控制點都被內插到了屏幕高度的 75% 。相似地,用貝塞爾曲線繪製的變形蟲也是垂直平移的。

//Update scene state to "reveal" and start corresponding animation
//This method is called when drag excced our defined threshold
  void _playRevealAnimation() {
    setState(() {
      currentScreenState = CURRENT_SCREEN_STATE.REVEALING_ANIMATING_STATE;
      _revealAnimationController.forward();
      _amoebaAnimationController.forward();
    });
  }

//Animation controller for expanding the curve animation
    _revealAnimationController =
        AnimationController(duration: Duration(milliseconds: 500), vsync: this)
          ..addStatusListener((status) {
            if (status == AnimationStatus.completed)
              setState(() {
                currentScreenState = CURRENT_SCREEN_STATE.POST_REVEAL_STATE;
                _postRevealAnimationController.forward();
              });
          });

//Animation to translate the brand label
    _titleBaseLinePosTranslateAnim = RelativeRectTween(
            begin: RelativeRect.fromLTRB(
                0,
                widget.height -
                    _initialCurveHeight -
                    widget.height * .2 -
                    arcHeight,
                0,
                _initialCurveHeight),
            end: RelativeRect.fromLTRB(
                0,
                widget.height - _finalCurveHeight - 20 - arcHeight,
                0,
                _finalCurveHeight))
        .animate(CurvedAnimation(
            parent: _revealAnimationController, curve: Curves.easeOutBack));

//Animation to translate side icons
    _sideIconsTranslateAnim = RelativeRectTween(
            begin: RelativeRect.fromLTRB(
                0,
                widget.height -
                    _initialCurveHeight -
                    widget.height * .25 -
                    arcHeight,
                0,
                _initialCurveHeight),
            end: RelativeRect.fromLTRB(
                0,
                widget.height -
                    _finalCurveHeight -
                    widget.height * .25 -
                    arcHeight,
                0,
                _finalCurveHeight))
        .animate(CurvedAnimation(
            parent: _revealAnimationController, curve: Curves.easeInOutBack));

//Tween for animating height of the curve during reveal process
_swipeArcAnimation =
        Tween<double>(begin: _initialCurveHeight, end: _finalCurveHeight)
            .animate(CurvedAnimation(
                parent: _revealAnimationController, curve: Curves.easeInCubic));

//Animation for the mid control point of cubic bezier curve to show acceleration effect in response to user drag.
    _swipeArchHeightAnimation = TweenSequence<double>(
      <TweenSequenceItem<double>>[
        TweenSequenceItem<double>(
          tween: Tween<double>(begin: 0, end: 200),
          weight: 50.0,
        ),
        TweenSequenceItem<double>(
          tween: Tween<double>(begin: 200, end: 0),
          weight: 50.0,
        ),
      ],
    ).animate(CurvedAnimation(
        parent: _revealAnimationController, curve: Curves.easeInCubic));

//Animation Controller for amoeba background
    _amoebaAnimationController =
        AnimationController(duration: Duration(milliseconds: 350), vsync: this);

    _amoebaOffsetAnimation =
        Tween<Offset>(begin: Offset(0, 0), end: Offset(-20, -70)).animate(
            CurvedAnimation(
                parent: _amoebaAnimationController,
                curve: Curves.easeInOutBack));

完成動畫後,場景 2 就設置好了。在這個場景中,品牌標題被圖標所取代,標籤指示器從屏幕左側顯示。

//Animation controller for showing animation after reveal
    _postRevealAnimationController =
        AnimationController(duration: Duration(milliseconds: 600), vsync: this);

 //Scale animation for showing center logo after reveal is completed
    _centerIconScale = Tween<double>(begin: 0, end: .5).animate(CurvedAnimation(
      parent: _postRevealAnimationController,
      curve: Curves.fastOutSlowIn,
    ));

//_centerIconScale animation used by FAB in the middle
 Positioned.fromRelativeRect(
        rect: _titleBaseLinePosTranslateAnim.value.shift(Offset(0, 18)),
        child: ScaleTransition(
            scale: _centerIconScale,
            child: FloatingActionButton(
                backgroundColor: Colors.white,
                elevation: 5,
                onPressed: null,
                child: Icon(Icons.monetization_on_outlined,
                    size: 100,
                    color: isLeftTabSelected
                        ? Colors.deepPurple
                        : Colors.pinkAccent))),
      ),

//Tab selection is done by "CurvePageSwitchIndicator" widget
Positioned(
      top: 0,
      bottom: _titleBaseLinePosTranslateAnim.value.bottom,
      left: 0,
      right: 0,
      child: CurvePageSwitchIndicator(widget.height, widget.width, arcHeight, 3,
          true, _onLeftTabSelectd, _onRightTabSelectd),
    );


//The build method of CurvePageSwitchIndicator consisting of "CurvePageSwitcher" CustomPainter to paint tab selection arc
//and Gesture detectors stacked on top to intercept left and right tap event.
///When the reveal scene is completed, left tab is selected and the tab selection fly
//towards from the left side of the screen
  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      Transform(
          transform: Matrix4.identity()
            ..setEntry(0, 3, translationDxAnim.value)
            ..setEntry(1, 3, translationDyAnim.value)
            ..rotateZ(rotationAnim.value * 3.14 / 180),
          alignment: Alignment.bottomLeft,
          child: Container(
            height: double.infinity,
            width: double.infinity,
            child: CustomPaint(
              painter: CurvePageSwitcher(
                  widget.arcHeight,
                  widget.arcBottomOffset,
                  showLeftAsFirstPage,
                  pageTabAnimationController!),
            ),
          )),
      Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          Expanded(
              child: Stack(children: [
            Positioned(
                left: 0,
                right: 20,
                bottom: 0,
                top: 90,
                child: Transform.rotate(
                    angle: -13 * 3.14 / 180,
                    child: Align(
                        alignment: Alignment.center,
                        child: Text(
                          "Login",
                          style: TextStyle(
                              color: showLeftAsFirstPage
                                  ? Colors.white
                                  : Colors.white60,
                              fontSize: 22,
                              fontWeight: FontWeight.w800),
                        )))),
            GestureDetector(onTap: _handleLeftTab,
              )
          ])),
          Expanded(
              child: Stack(children: [
            Positioned(
                left: 20,
                right: 0,
                bottom: 0,
                top: 90,
                child: Transform.rotate(
                    angle: 13 * 3.14 / 180,
                    child: Align(
                        alignment: Alignment.center,
                        child: Text("Signup",
                            style: TextStyle(
                                color: !showLeftAsFirstPage
                                    ? Colors.white
                                    : Colors.white60,
                                fontSize: 22,
                                fontWeight: FontWeight.w800))))),
                GestureDetector(onTap: _handleRightTab,
            )
          ])),
        ],
      ),
    ]);
  }

製表符指示器也使用貝塞爾曲線繪製,並定位在場景 1 的曲面背景之上,但在單獨的 CustomPainter 中。爲了實現製表位選擇效果,在繪製製表位選擇曲線時使用剪輯路徑。

//The paint method of "CurvePageSwitcher" to draw tab selection arc
void _drawSwipeAbleArc(Canvas canvas, Size size) {
    Path path = Path();

    path.moveTo(-2, size.height - archBottomOffset);
    path.cubicTo(
        -2,
        size.height - archBottomOffset,
        size.width / 2,
        size.height - arcHeight - archBottomOffset,
        size.width + 2,
        size.height - archBottomOffset);
    path.moveTo(size.width + 2, size.height - archBottomOffset);
    path.close();

    double left, right;
    if (showLeftAsFirstPage) {
      left = size.width / 2 - size.width / 2 * animation.value;
      right = size.width / 2;
      swipeArcPaint.color = Colors.green;
    } else {
      left = size.width / 2;
      right = size.width * animation.value;
      swipeArcPaint.color = Colors.deepPurple;
    }

    canvas.clipRect(Rect.fromLTRB(left, 0, right, size.height));

    canvas.drawPath(path, swipeArcPaint);
  }

除此以外,兩個容器以各自的標籤顏色相互頂部放置。根據選定的選項卡,保留相應的容器,將另外一個容器 translated 到 x 軸的相反端,從而丟棄另外一個容器。

///The background for selected tab. On the basis of tab selected, the foreground container is translated away,
///revealing the underlying background container. If the screen state is just set to reveal, then in the
///initial state no foreground container is added which is signified by _tabSelectionAnimation set to null.
///_tabSelectionAnimation is only set when either of the tab is pressed.
  List<Widget> _getBgWidgets() {
    List<Widget> widgets = [];
    Color foreGroundColor;
    Color backgroundColor;
    if (isLeftTabSelected) {
      foreGroundColor = Colors.deepPurple;
      backgroundColor = Colors.pink;
    } else {
      foreGroundColor = Colors.pink;
      backgroundColor = Colors.deepPurple;
    }

    widgets.add(Positioned.fill(child: Container(color: foreGroundColor)));

    if (_tabSelectionAnimation != null)
      widgets.add(PositionedTransition(
          rect: _tabSelectionAnimation!,
          child: Container(
            decoration: BoxDecoration(
              color: backgroundColor
            ),
          )));

    widgets.add(Container(
      height: double.infinity,
      width: double.infinity,
      child: CustomPaint(
        painter: AmoebaBg(_amoebaOffsetAnimation),
      ),
    ));


    return widgets;
  }

由於我不能獲得確切的圖片和資源,我使用了我能在網上找到的最接近的一個。

因此總的來講,咱們獲得的結果以下。


© 貓哥

https://ducafecat.tech/

https://github.com/ducafecat

往期

開源

GetX Quick Start

https://github.com/ducafecat/...

新聞客戶端

https://github.com/ducafecat/...

strapi 手冊譯文

https://getstrapi.cn

微信討論羣 ducafecat

系列集合

譯文

https://ducafecat.tech/catego...

開源項目

https://ducafecat.tech/catego...

Dart 編程語言基礎

https://space.bilibili.com/40...

Flutter 零基礎入門

https://space.bilibili.com/40...

Flutter 實戰從零開始 新聞客戶端

https://space.bilibili.com/40...

Flutter 組件開發

https://space.bilibili.com/40...

Flutter Bloc

https://space.bilibili.com/40...

Flutter Getx4

https://space.bilibili.com/40...

Docker Yapi

https://space.bilibili.com/40...

相關文章
相關標籤/搜索