動態

詳情 返回 返回

Flutter實現閒魚底部導航欄中間突出效果 - 動態 詳情

實現思路

Scaffold 組件中使用 bottomNavigationBarfloatingActionButton 屬性建立底部導航欄和浮動按鈕,同時使用 floatingActionButtonLocation 屬性指定浮動按鈕的位置。

默認情況下,當 floatingActionButton 融入 bottomNavigationBar 時,僅可實現如下圖效果:(指定 bottomNavigationBarBottomAppBar 組件,其 shape 屬性為 CircularNotchedRectangle,指定 floatingActionButtonLocationFloatingActionButtonLocation.centerDocked

所以需要自定義實現一個類似 CircularNotchedRectangle 類和 FloatingActionButtonLocation.centerDocked 類。

CircularNotchedRectangle 的實現原理

我們的目的是實現如下圖的效果:

可以將缺口部分分成三部分:

其中,A 和 C 段是關於圓心對稱的兩段二次貝塞爾曲線,用來平滑過渡,B 是一段圓弧。

B 通過圓的半徑可以輕鬆得到,重點來討論二次貝塞爾曲線如何實現。對於一段二次貝塞爾曲線,有三個點,即 P0(起始點)、P1(控制點)、P2(結束點):

我們希望 P0 和 P2 處的連接是平滑的,即連接點處兩段曲線的切線相同。由於 P0 所在的曲線為直線,因此我們僅考慮 P2 的平滑連接即可。

注意,下面的計算的笛卡爾座標系原點為圓心\(O\)

先指定 \(P_1(a,b)\)\(P_0(c,b)\)(a、c是經驗值)。現在問題轉化為求 \(P_2\) 的座標 \((x_2,y_2)\),另外我們還有下面的條件:

  • 圓的方程:\(x^2 + y^2 = R^2\ (R = r + Notch)\)
  • 直線方程:\(xx_2 + yy_2 = R^2\)
  • \(P_1\) 在直線上:\(ax_2 + by_2 = R^2\ ①\)
  • \(P_2\) 在圓上:\(x_2^2 + y_2^2 = R^2\ ②\)

通過聯立①式和②式,可得:

\((a^2 + b^2)x_2^2\ -\ 2aR^2x_2\ +\ R^4\ -\ b^2R^2 = 0\)

\((a^2 + b^2)y_2^2\ -\ 2bR^2y_2\ +\ R^4\ -\ a^2R^2 = 0\)

通過求根公式可得:

\(x_2 = \frac{aR^2\ \pm\ \sqrt{a^2R^4\ -\ (a^2\ +\ b^2)(R^4\ -\ b^2R^2)}}{a^2\ + \ b^2} = \frac{aR^2\ \pm\ \sqrt{a^2b^2R^2\ +\ b^4R^2\ -\ b^2R^4}}{a^2\ + \ b^2}\)

\(y_2 = \frac{bR^2\ \pm\ \sqrt{b^2R^4\ -\ (a^2\ +\ b^2)(R^4\ -\ a^2R^2)}}{a^2\ + \ b^2} = \frac{bR^2\ \pm\ \sqrt{a^2b^2R^2\ +\ a^4R^2\ -\ a^2R^4}}{a^2\ + \ b^2}\)

\(P_1\) 座標 \((a,b)\) 代入即可得到 \(P_2\) 的座標。(注意,還需要使用 \(P_2\) 在圓上這一條件判斷兩個 \(x_2\) 和兩個 \(y_2\) 的對應情況)

選取在圓心下面的一組解,再使用二次貝塞爾曲線連接 A 的兩端點即可得到 CircularNotchedRectangle 的效果。

所以,仿照 CircularNotchedRectangle 的實現,我們根據推導公式和入參選擇圓心上方或者下方的一組解即可實現我們需要的效果。

FloatingActionButtonLocation.centerDocked 的實現原理

通過查看源代碼可以發現,FloatingActionButtonLocation.centerDocked 調用了 _CenterDockedFabLocation 類,它繼承自 StandardFabLocation 類,並混入了 FabCenterOffsetXFabDockedOffsetY 兩個類。

StandardFabLocation 類繼承自 FloatingActionButtonLocation,需要重寫 getOffset() 來得到 FAB 的偏移量。

StandardFabLocation 類中已經重寫了 getOffset() ,它還定義了 getOffsetX()getOffsetY() 來獲取 X 和 Y 軸的偏移量。getOffsetX()getOffsetY()FabCenterOffsetXFabDockedOffsetY 兩個混入類中實現,得到正確的 X 和 Y 軸的偏移。

所以,我們的自定義類只需繼承 FloatingActionButtonLocation 並根據自定義位置重寫 getOffset() 即可。

代碼實現

自定義類 CircularCustomRectangle

class CircularCustomRectangle extends NotchedShape {
  /// FAB 融合進 bottomNavigationBar 的自定義樣式
  /// 如果 [guest] 向上或向下移動過半,則不對 [host] 處理
  const CircularCustomRectangle({
    this.inverted = false,
    this.protruded = true,
  });

  /// 控制在導航欄頂部還是底部作用效果,默認頂部,設置[true]表示作用在底部
  final bool inverted;

  /// 控制向上突出還是向下凹入,默認向上突出,設置[false]表示向下凹入
  final bool protruded;

  @override
  Path getOuterPath(Rect host, Rect? guest) {
    // 判斷 guest是否為 null 或沒有覆蓋 host,如果是則不對 host處理
    if (guest == null || !host.overlaps(guest)) {
      return Path()..addRect(host);
    }

    // 判斷 guest 是否向上或向下移動過半,如果過半則不對 host 處理
    if (protruded && guest.center.dy < 0) {
      return Path()..addRect(host);
    } else if (!protruded && guest.center.dy > 0) {
      return Path()..addRect(host);
    }

    // 設置對 host 處理的圓弧半徑
    final double r = guest.width / 2.0;

    // 生成一個圓弧半徑,用於在 B 段連接 P2、P3
    final Radius radius = Radius.circular(r);

    // 根據傳入參數進行指定位置、方向的處理
    final double invertMultiplier = inverted ? -1.0 : 1.0;
    final double protrudedMultiplier = protruded ? 1.0 : -1.0;

    /// 下面的計算邏輯,當前座標原點全部為 guest 的圓心

    // 根據 guest 的位置動態計算圓周上的點到 y 軸的距離,用來參與決定圓滑過渡開始的位置
    double d = math.sqrt(r * r - guest.center.dy * guest.center.dy);
    const double s1 = 15; // 經驗值,調整過渡的長度
    const double s2 = 2; // 經驗值,調整過渡的高度

    // a 是以圓心為座標原點時 P1 的橫座標
    final double a = -d - s2;
    // b 是以圓心為座標原點時 P1 的縱座標
    final double b = guest.center.dy;

    // 計算 x、y 的解的 delta
    final double sqrtDeltax = b.abs() * r * math.sqrt(a * a + b * b - r * r);
    final double sqrtDeltay = a.abs() * r * math.sqrt(b * b + a * a - r * r);
    // 計算 x 的兩個解
    final double p2xA = ((a * r * r) - sqrtDeltax) / (a * a + b * b);
    final double p2xB = ((a * r * r) + sqrtDeltax) / (a * a + b * b);

    // 計算 y 的兩個解
    // 先判斷兩個解的對應關係
    double p2yAtemp = ((b * r * r) - sqrtDeltay) / (a * a + b * b);
    double p2yBtemp = ((b * r * r) + sqrtDeltay) / (a * a + b * b);
    if (!(((p2xA * p2xA + p2yAtemp * p2yAtemp) - r * r).abs() < 5.0)) {
      double temp = p2yAtemp;
      p2yAtemp = p2yBtemp;
      p2yBtemp = temp;
    }
    // 再根據判斷結果確定兩個 x 的解與 y 的解的對應關係
    final double p2yA = p2yAtemp * invertMultiplier;
    final double p2yB = p2yBtemp * invertMultiplier;

    final List<Offset> p = List<Offset>.filled(6, Offset.zero);

    // 下面計算 P0、P1、P2,再根據 P0、P1、P2 鏡像得到 P3、P4、P5
    p[0] = Offset(-d - s1, b);
    p[1] = Offset(a, b);
    // 根據 protrudedMultiplier 的值,選擇要縱座標大於0的向上凸出還是縱座標小於0的向下凹入的座標
    p[2] = protrudedMultiplier * p2yA > protrudedMultiplier * p2yB
        ? Offset(p2xA, p2yA)
        : Offset(p2xB, p2yB);
    p[3] = Offset(-1.0 * p[2].dx, p[2].dy);
    p[4] = Offset(-1.0 * p[1].dx, p[1].dy);
    p[5] = Offset(-1.0 * p[0].dx, p[0].dy);

    /// 下面將座標原點從圓心轉換成以 host 的左上角為座標原點

    for (int i = 0; i < p.length; i += 1) {
      double x = p[i].dx + guest.center.dx; // x軸方向沒有變化,直接加 guest 的中心點座標即可
      double y =
          -p[i].dy + guest.center.dy; // y軸方向反向,需要先將原 y 軸反向再加 guest 的中心點座標
      p[i] = Offset(x, y);
    }

    // 根據位置點生成路徑
    final Path path = Path()..moveTo(host.left, host.top);
    if (!inverted) {
      path
        ..lineTo(p[0].dx, p[0].dy)
        ..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy)
        ..arcToPoint(
          p[3],
          radius: radius,
          clockwise: protruded,
        ) // 這裏的 clockwise 控制了圓弧的方向
        ..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy)
        ..lineTo(host.right, host.top)
        ..lineTo(host.right, host.bottom)
        ..lineTo(host.left, host.bottom);
    } else {
      path
        ..lineTo(host.right, host.top)
        ..lineTo(host.right, host.bottom)
        ..lineTo(p[5].dx, p[5].dy)
        ..quadraticBezierTo(p[4].dx, p[4].dy, p[3].dx, p[3].dy)
        ..arcToPoint(p[2], radius: radius, clockwise: protruded)
        ..quadraticBezierTo(p[1].dx, p[1].dy, p[0].dx, p[0].dy)
        ..lineTo(host.left, host.bottom);
    }

    return path..close();
  }
}

自定義類 FloatingButtonCustomLocation

class FloatingButtonCustomLocation extends FloatingActionButtonLocation {
  /// 控制 FAB 位置的自定義類
  FloatingButtonCustomLocation(
    this.location, {
    this.offsetX = 0,
    this.offsetY = 0,
  });

  /// [location] 表示參照物,比如 [FloatingActionButtonLocation.startTop] 表示以 [bottomNavigationBar] 左上角為原點
  FloatingActionButtonLocation location;

  /// [offsetX] 表示 X 方向的偏移量
  final double offsetX;

  /// [offsetY] 表示 Y 方向的偏移量
  final double offsetY;

  @override
  Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
    Offset offset = location.getOffset(scaffoldGeometry);
    return Offset(offset.dx + offsetX, offset.dy + offsetY);
  }
}

優化

如果 floatingActionButtonLocation 屬性使用了自定義的類,在點擊導航欄按鈕時會讓 FAB 執行縮放動畫,我們可以在 floatingActionButtonAnimator 屬性中使用自定義的動畫類來取消這個縮放動畫:

class ScalingCustomAnimation extends FloatingActionButtonAnimator {
  /// 控制 FAB 動畫的自定義類
  ScalingCustomAnimation();

  @override
  Offset getOffset({
    required Offset begin,
    required Offset end,
    required double progress,
  }) {
    return Offset.lerp(begin, end, progress)!;
  }

  @override
  Animation<double> getRotationAnimation({required Animation<double> parent}) {
    return Tween<double>(begin: 1.0, end: 1.0).animate(parent);
  }

  @override
  Animation<double> getScaleAnimation({required Animation<double> parent}) {
    return Tween<double>(begin: 1.0, end: 1.0).animate(parent);
  }
}

參考資料

https://zhuanlan.zhihu.com/p/394087615

https://juejin.cn/post/7153097948195192863

https://goo.gl/Ufzrqn

Add a new 評論

Some HTML is okay.