Flutter实现闲鱼底部导航栏中间突出效果

Posted on 2025-10-02 08:41  lifeisastory  阅读(79)  评论(0)    收藏  举报

实现思路

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