這篇文章講的是如何在你的動畫中加入運動特性、運動球、重力、貝塞爾曲線、多邊形、不規則曲線,若是你正在找這方面資料,這個源碼你可要好好消化了。這都是動畫中的基礎,前端就是要酷炫,開始吧。前端
最佳體驗仍是閱讀原文(連接在下面)。react
https://preyea-regmi.medium.c...git
https://github.com/PreyeaRegm...github
大部分時間實現運動設計是一個有點累贅的移動應用程序。本文從更加實用的角度闡述瞭如何經過 Flutter 實現運動設計。咱們將採起一個簡單的運動設計從運球做爲一個參考,並開始建設它一步一步。全部版權保留給各自的做者,實現的完整源代碼能夠在 github 上找到。編程
https://github.com/PreyeaRegm...canvas
如今咱們將重點放在登陸/註冊交互上。因此,就像其餘的交互設計同樣,咱們將嘗試把它分解成多個場景,這樣咱們就能夠有一個清晰的總體概念,並將這些場景連接在一塊兒。api
在這個場景中,咱們在底部有一個彈跳的圖像和文字,一個彎曲的白色背景,一個品牌標題包圍着圖像的中心和變形蟲形狀的背景。拖動底部的內容,直到必定的距離被覆蓋,揭示動畫播放和場景轉換到下一個場景。微信
在這個中間場景中,曲線背景高度是動畫的。此外,在這個動畫,控制點的三次貝塞爾曲線也被平移和還原,以提供加速效果。側面的圖標和變形蟲背景也在垂直方向上 translated 以響應動畫的顯示。編程語言
當顯示動畫完成後,品牌標題被一個圓形圖標取代,一個標籤指示器從屏幕左側飛過來,相應的標籤被加載。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://github.com/ducafecat/...
https://github.com/ducafecat/...
https://ducafecat.tech/catego...
https://ducafecat.tech/catego...
https://space.bilibili.com/40...
https://space.bilibili.com/40...
https://space.bilibili.com/40...
https://space.bilibili.com/40...
https://space.bilibili.com/40...
https://space.bilibili.com/40...