
實現思路
在 Scaffold 組件中使用 bottomNavigationBar 和 floatingActionButton 屬性建立底部導航欄和浮動按鈕,同時使用 floatingActionButtonLocation 屬性指定浮動按鈕的位置。
默認情況下,當 floatingActionButton 融入 bottomNavigationBar 時,僅可實現如下圖效果:(指定 bottomNavigationBar 為 BottomAppBar 組件,其 shape 屬性為 CircularNotchedRectangle,指定 floatingActionButtonLocation 為 FloatingActionButtonLocation.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 類,并混入了 FabCenterOffsetX,FabDockedOffsetY 兩個類。
StandardFabLocation 類繼承自 FloatingActionButtonLocation,需要重寫 getOffset() 來得到 FAB 的偏移量。
在 StandardFabLocation 類中已經重寫了 getOffset() ,它還定義了 getOffsetX() 和 getOffsetY() 來獲取 X 和 Y 軸的偏移量。getOffsetX() 和 getOffsetY() 在 FabCenterOffsetX,FabDockedOffsetY 兩個混入類中實現,得到正確的 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
浙公網安備 33010602011771號