Flutter炫酷的波紋路由動畫

  寫這個的一切原由都得從我某天切換了酷安App的夜間模式提及,看個Gif,忽略圖中其餘無關項。 bash

Gif
這種的動畫在awesome-Flutter上好像見到過,可是記得只是相似,有一個App的首次引導頁跟這個有點像,不過那個是在一個PageView切換的時候的動畫。上圖的酷安App是原生應用,能夠看到我在第三次切換主題的時候滑動了一個橫向的相似於Flutter ListView的東西,再次點擊切換主題,ListView的狀態變化了,因此我懷疑酷安是用StartActivity的方式(過久沒碰原生UI了,因此只是猜想)

Flutter端的實現

起初用showOverlay的方式來作一個,效果始終很差,有違和感。後來果斷採用自定義路由,最後整個動畫的難點全在自定義路由上,若是你對原理不感興趣,滑動到最後有現成的代碼ide

動畫分析

新的頁面是由點擊的控件中心所在的座標位置呈一個圓形逐漸擴散開來,最後撐滿整個屏幕,不會啥繪圖工具,用我Mac自帶的繪圖頂一下 工具

image.png
最中心的點是按鈕的中心座標,圖中第二個頁面的父佈局即爲整個圓形,這是整個動畫中間的某一時刻 最後會是這個樣子
image.png
經過以下代碼計算出這個按鈕所在的座標,固然也可使用GlobalObjectKey(value).currentContext來拿到控件的上下文

final RenderBox renderBox = iconContext.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
複製代碼

也就是說,圓的最大會恰好覆蓋住手機屏幕最遠的那個角落,若是小於了,則第二個頁面顯示不完整,大於了則會浪費多餘的動畫時長,看一下路由圓的直徑計算代碼,用了比較笨的判斷,用簡單的勾股定理計算出控件中心的座標到屏幕最遠的距離,路由頁面的大小即爲該距離的二倍。佈局

final RenderBox renderBox = context.findRenderObject();
    offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
    if (offset.dx > MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
      } else {
        circleRadius = sqrt(pow(offset.dx, 2) +
                pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
    if (offset.dx <= MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(offset.dy, 2))
            .toDouble();
      } else {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
  }
複製代碼

首先咱們實現這個圓的動畫,我就不單獨寫demo了,直接拿個人工具箱作實驗,定位到PageRouteBuilder的關鍵代碼動畫

transitionsBuilder: (
          BuildContext context,
              Animation<double> animation,
              Animation<double> _,
              Widget child,
            ) {
              return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  SizedBox(
                    height: routeConfig.circleRadius * 2 * animation.value,
                    width: routeConfig.circleRadius * 2 * animation.value,
                    child: ClipOval(
                      child: Align(
                        alignment: Alignment.center,
                        child: Container(
                          color: Colors.red,
                        ),
                      ),
                    ),
                  ),
                ],
              );
            }
複製代碼

這部分的代碼比較好理解,routeConfig.circleRadius即爲整個圓最大時的半徑,路由的新頁面收SziedBox的限制,而SizedBox包裹ClipOval控件來實現圓形,這部分的效果以下 ui

111.gif
其中的兩個問題, 1.SizedBox長寬都給定的計算出的能包裹手機屏幕的值,最後並無造成那樣大的一個圓 2.圓並非從按鈕中心擴散開來的 採用Positioned解決這兩個問題,起初我考慮的用Transform控件,傳入參數Matrix4.identity()..translate(x,y)的方式讓整個圓從控件中心展開,但並無解決第一個問題,因而換了Positioned,Positioned能夠設置與屏幕的上下左右邊距,能夠接收負數 觀察兩個臨界狀態,動畫剛開始與動畫結束, 剛開始時:animation.value=0,也就是說第二個頁面的大小此時爲0,此刻若是想要它在控件中心位置,它應該在哪呢? 咱們上面計算出來了控件中心所在的offset,offset.dx即爲控件中心與左屏幕的邊距,offset.dy爲與上屏幕的邊距, 在觀察動畫結束時:第二個頁面的大小爲圓的直徑,若是不對控件作偏移處理,它會是這個樣子
image.png
咱們須要作的就是將此刻的圓心移動到按鈕原始中心的位置,有一個這樣的圖就比較好計算了,此刻圓心須要向上的偏移量即爲:圓的半徑-起始按鈕中心距上屏幕的位置,向左的偏移量即爲:圓的半徑-起始按鈕中心距左屏幕的位置 而整個過程是一個動畫,在動畫的每一刻的計算方式都如此,因此次時的代碼改成

return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  Positioned(
                    top: routeConfig.offset.dy -
                        routeConfig.circleRadius * animation.value,
                    left: routeConfig.offset.dx -
                        routeConfig.circleRadius * animation.value,
                    child: SizedBox(
                      height: routeConfig.circleRadius * 2 * animation.value,
                      width: routeConfig.circleRadius * 2 * animation.value,
                      child: ClipOval(
                        child: Align(
                          alignment: Alignment.center,
                          child: Container(
                            color: Colors.red,
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              );
複製代碼

傳入的是負數是由於初始距離上、左屏幕的距離爲0,若是是正數,圓只會愈來愈遠離屏幕上與左,看一下此時的效果 this

111.gif
主要的動畫已經出來了,我當時的第一想法就是將這個大紅色替換成我須要路由的Widget就大功告成了,而後成了下面這個樣子
111.gif
這也是當時困擾了我半天的問題,這個問題是因爲解決上面兩個問題時,及時偏移了整個圓的座標,才致使圓包含的子頁面(咱們想要路由到的頁面)座標也被更改了,而我想要的是路由的頁面始終顯示到屏幕的位置,既然如此,再使用一個Stack+Positioned的組合,負負得正,上一個Positioned怎麼傳的值,我就傳相反數進去,更改以下:

return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  Positioned(
                    top: routeConfig.offset.dy -
                        routeConfig.circleRadius * animation.value,
                    left: routeConfig.offset.dx -
                        routeConfig.circleRadius * animation.value,
                    child: SizedBox(
                      height: routeConfig.circleRadius * 2 * animation.value,
                      width: routeConfig.circleRadius * 2 * animation.value,
                      child: ClipOval(
                        child: Stack(
                          children: <Widget>[
                            Positioned(
                              top: routeConfig.circleRadius * animation.value -
                                  routeConfig.offset.dy,
                              left: routeConfig.circleRadius * animation.value -
                                  routeConfig.offset.dx,
                              child: Align(
                                alignment: Alignment.center,
                                child: Container(
                                  width: MediaQuery.of(context).size.width,
                                  height: MediaQuery.of(context).size.height,
                                  child: child,
                                ),
                              ),
                            )
                          ],
                        ),
                      ),
                    ),
                  ),
                ],
              );
複製代碼

效果以下: spa

111.gif
**最後再本身把路由時間改一下就好了

附上ripple_router.dart

import 'dart:math';
import 'package:flutter/material.dart';

class RouteConfig {
  Offset offset;
  double circleRadius;
  RouteConfig.fromContext(BuildContext context) {
    final RenderBox renderBox = context.findRenderObject();
    offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
    if (offset.dx > MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
      } else {
        circleRadius = sqrt(pow(offset.dx, 2) +
                pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
    if (offset.dx <= MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(offset.dy, 2))
            .toDouble();
      } else {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
  }
}

// double circleRadius
class RippleRoute extends PageRouteBuilder {
  final Widget widget;
  final RouteConfig routeConfig;

  RippleRoute(this.widget, this.routeConfig)
      : super(
          // 設置過分時間
          transitionDuration: Duration(seconds: 1),
          // 構造器
          pageBuilder: (
            // 上下文和動畫
            BuildContext context,
            Animation<double> animation,
            Animation<double> _,
          ) {
            return widget;
          },
          opaque: false,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> _,
            Widget child,
          ) {
            return Stack(
              alignment: Alignment.center,
              children: <Widget>[
                Positioned(
                  top: routeConfig.offset.dy -
                      routeConfig.circleRadius * animation.value,
                  left: routeConfig.offset.dx -
                      routeConfig.circleRadius * animation.value,
                  child: SizedBox(
                    height: routeConfig.circleRadius * 2 * animation.value,
                    width: routeConfig.circleRadius * 2 * animation.value,
                    child: ClipOval(
                      child: Stack(
                        children: <Widget>[
                          Positioned(
                            top: routeConfig.circleRadius * animation.value -
                                routeConfig.offset.dy,
                            left: routeConfig.circleRadius * animation.value -
                                routeConfig.offset.dx,
                            child: Align(
                              alignment: Alignment.center,
                              child: Container(
                                width: MediaQuery.of(context).size.width,
                                height: MediaQuery.of(context).size.height,
                                child: child,
                              ),
                            ),
                          )
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            );
          },
        );
}


複製代碼

###使用方法: 在Flutter裏面,可將點擊的按鈕套上一個Builder,如如下的樣式,在跳轉邏輯中以下,各類計算都被我封裝到了RouteConfig這個類裏面了,經過context構造並傳入PageRouteBuilder就好了3d

Builder(
      builder: (iconContext) {
        return InkWell(
          child: Icon(
            Icons.***,
          ),
          onTap: () {
          Navigator.of(context).push(
            RippleRoute(
                NewPage(),
                RouteConfig.fromContext(context)),
          );
          },
        );
      },
    );
複製代碼

不只是路由相同的頁面來切換主題,也能夠用於任何的路由場景 code

111.gif

那如何用這個路由切換主題?將你不包含Theme的頁面獨立出來,再切換主題路由新頁面是套上一個新的Theme就好啦 總結了下,是寫騷了一點,不過個人確想不到比較官方的寫法了哈哈,好幾迴轉gif我就懶得貼表情包了哈哈

相關文章
相關標籤/搜索