PAYATHON 2026

Flutter UI 中如何让右上角 badge 与卡片边框自然衔接?

技术老齐

普通的右上角角标,用 Stack 或 Column 配合局部圆角就能贴合卡片,无需使用 ClipPath / CustomPainter。如果 badge 需要向上凸出,并与卡片共用连续的曲线、描边或阴影,再使用自定义路径。

目前没有实际截图,无法判断连接处是否有内凹曲线。这里先按 badge 嵌入卡片右上角的效果处理。

为什么现在看起来像悬浮标签?

当前布局中的几个设置让 badge 呈现为独立的胶囊形状:

Positioned(
  top: -12,
  right: 16,
  // ...
)
  • top: -12 让 badge 向卡片上方突出。
  • right: 16 让 badge 与卡片右边缘留有距离。
  • BorderRadius.circular(30) 把四个角都设为圆角,保留了完整的胶囊轮廓。

clipBehavior: Clip.none 允许子组件绘制到 Stack 边界之外,但不会自动连接两个形状。

当前 BoxDecoration 没有设置 border,先处理卡片外轮廓即可。如果后续增加描边,还需要检查连接处是否出现多余线条。

让 badge 嵌入右上角

保留 Stack 时,可以把位置改为 top: 0、right: 0,再调整 badge 的圆角:

Positioned(
  top: 0,
  right: 0,
  child: Container(
    padding: const EdgeInsets.symmetric(
      horizontal: 14,
      vertical: 8,
    ),
    decoration: const BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.only(
        topRight: Radius.circular(20),
        bottomLeft: Radius.circular(16),
      ),
    ),
    child: Text(
      plan.tag,
      style: const TextStyle(color: Colors.black),
    ),
  ),
)

badge 的右上圆角设为 20,与卡片保持一致;左下圆角用于分隔 badge 和卡片内容,另外两个角保持直角。

Positioned 不会为正文预留空间,因此还需要增加卡片正文顶部的 padding,否则较长的标签或放大后的文字可能遮挡标题。

如果正文可以从 badge 下方开始,用 Column 更容易处理文字高度。

可替换的 _PlanCard 实现

这个实现沿用现有的 PlanModel 和 _FeatureRow,让 badge 参与正常布局。卡片标题排在 badge 下方,不需要估算标签高度:

class _PlanCard extends StatelessWidget {
  final PlanModel plan;

  const _PlanCard({required this.plan});

  @override
  Widget build(BuildContext context) {
    final isPremium = plan.isPremium;
    final isCustom = plan.isCustom;

    final cardColor = isPremium
        ? const Color(0xFF4A90E2)
        : const Color(0xFFF5F5F7);

    final textColor = isPremium ? Colors.white : Colors.black;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: ColoredBox(
          color: cardColor,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Align(
                alignment: Alignment.topRight,
                child: Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 14,
                    vertical: 8,
                  ),
                  decoration: const BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.only(
                      topRight: Radius.circular(20),
                      bottomLeft: Radius.circular(16),
                    ),
                  ),
                  child: Text(
                    plan.tag,
                    style: const TextStyle(
                      color: Colors.black,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                ),
              ),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(22, 12, 22, 22),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        plan.title,
                        style: TextStyle(
                          color: textColor,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      const SizedBox(height: 10),
                      if (!isCustom)
                        Text(
                          plan.price,
                          style: TextStyle(
                            fontSize: 34,
                            fontWeight: FontWeight.bold,
                            color: textColor,
                          ),
                        ),
                      const SizedBox(height: 5),
                      Text(
                        plan.subtitle,
                        style: TextStyle(
                          color: isPremium
                              ? Colors.white70
                              : Colors.grey,
                        ),
                      ),
                      const SizedBox(height: 20),
                      if (!isCustom) const Divider(),
                      const SizedBox(height: 10),
                      Expanded(
                        child: ListView.builder(
                          itemCount: plan.features.length,
                          itemBuilder: (_, i) => _FeatureRow(
                            text: plan.features[i],
                            isPremium: isPremium,
                            isCustom: isCustom,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ClipRRect 将整个组件裁剪为圆角矩形,badge 的顶部和右侧贴住卡片的外边界。角标位于卡片内部,卡片整体仍是圆角矩形。

什么时候需要 ClipPath 或 CustomPainter?

如果 badge 高于卡片顶部,或者连接处有内凹弧线,仅调整 BorderRadius 就不够了。

目标效果适合的方式
角标贴在圆角矩形内部Column / Stack + BorderRadius.only
badge 与卡片形成不规则外轮廓,需要裁剪背景和内容ClipPath + CustomClipper<Path>
需要沿完整轮廓绘制填充、描边或阴影CustomPainter;涉及 Material 形状和交互时也可考虑自定义 ShapeBorder

要让 badge 和卡片共用一个完整的外轮廓,应先用一条 Path 描述卡片、badge 及两者之间的连接曲线,再复用这条路径进行绘制或裁剪。

如果 badge 和卡片底色一致,可以用 Path.combine(PathOperation.union, ...) 合并两个相交形状。不过,合并操作不会自动生成平滑的连接圆角。需要内凹或平滑过渡时,应使用 quadraticBezierTo、cubicTo 或圆弧构造连接段。

ClipPath 只负责裁剪,不会自行绘制描边或阴影。需要完整外框时,可以让 CustomPainter 沿同一条路径绘制,避免给 badge 和卡片分别加边框后在连接处留下接缝。

布局时还要注意什么?

  • Stack 的 Clip.none 无法取消祖先组件的裁剪。badge 使用负坐标时,仍可能被外层裁掉;向上凸出的设计最好在组件内部预留高度。
  • 完整示例包含 Expanded,适用于原代码中 PageView 提供有限高度的场景。直接放进纵向 SingleChildScrollView 时,需要重新处理高度约束。
  • 标签长度和系统字体缩放会影响高度。使用 Column 能让正文随 badge 下移;可用高度过小时,仍需考虑让更多内容参与滚动。
  • 给 badge 和卡片分别添加阴影,容易在连接处产生重叠。需要整体阴影时,应沿统一的外轮廓处理。

备注:内容仅供参考。