
复现抖音导航栏「内容边缘消隐」效果 — 核心:
ShaderMask+LinearGradient
原理
ShaderMask 将一个 Shader(着色器)作为透明度蒙版叠加在子 Widget 上:
- 蒙版颜色为 白色 → 子内容完全可见
- 蒙版颜色为 透明(黑色) → 子内容隐藏
配合 LinearGradient,在列表两端制造「内容渐隐消失」效果。
图层结构(从上到下):
LinearGradient Shader 透明 → 白 → 白 → 透明(blendMode: dstIn)
SingleChildScrollView 水平可滚动的导航标签
Container(背景) 决定渐隐消失到哪个颜色
Step 1:最简实现(固定两端渐隐)
ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
// 从左→右:透明 白 白 透明
colors: [
Colors.transparent, // 左侧边缘消失
Colors.white, // 左侧渐隐结束
Colors.white, // 右侧渐隐开始
Colors.transparent, // 右侧边缘消失
],
stops: [0.0, 0.05, 0.95, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn, // 关键:用 Shader 控制透明度
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: tabs), // 你的标签行
),
)
⚠️
BlendMode.dstIn是关键参数 — 它让 Shader 的 Alpha 通道直接控制子内容的透明度,而非改变颜色。
Step 2:进阶 — 根据滚动位置动态显/隐遮罩
当列表在最左端时不显示左遮罩;到最右端时不显示右遮罩。监听 ScrollController 即可:
class FadeNavBar extends StatefulWidget {
const FadeNavBar({super.key, required this.tabs});
final List<String> tabs;
@override
State<FadeNavBar> createState() => _FadeNavBarState();
}
class _FadeNavBarState extends State<FadeNavBar> {
final _ctrl = ScrollController();
bool _showLeft = false;
bool _showRight = true;
@override
void initState() {
super.initState();
_ctrl.addListener(() {
final pos = _ctrl.position;
setState(() {
_showLeft = pos.pixels > 0;
_showRight = pos.pixels < pos.maxScrollExtent;
});
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final left = _showLeft ? 0.06 : 0.0;
final right = _showRight ? 0.94 : 1.0;
return ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, left, right, 1.0], // 动态改变
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: SingleChildScrollView(
controller: _ctrl,
scrollDirection: Axis.horizontal,
child: Row(
children: widget.tabs
.map((t) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(t, style: const TextStyle(color: Colors.white)),
))
.toList(),
),
),
);
}
}
Step 3:封装成通用 Widget
class EdgeFadeWrapper extends StatelessWidget {
const EdgeFadeWrapper({
super.key,
required this.child,
this.fadeWidth = 0.08, // 渐隐区域占总宽比例(0~0.3)
this.showLeft = true,
this.showRight = true,
});
final Widget child;
final double fadeWidth;
final bool showLeft, showRight;
@override
Widget build(BuildContext context) {
final l = showLeft ? fadeWidth : 0.0;
final r = showRight ? 1.0 - fadeWidth : 1.0;
return ShaderMask(
shaderCallback: (b) => LinearGradient(
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, l, r, 1.0],
).createShader(b),
blendMode: BlendMode.dstIn,
child: child,
);
}
}
使用示例:
// 配合 Step 2 的动态状态使用
EdgeFadeWrapper(
showLeft: _showLeft,
showRight: _showRight,
fadeWidth: 0.08,
child: SingleChildScrollView(
controller: _ctrl,
scrollDirection: Axis.horizontal,
child: Row(children: tabs),
),
)
三种方案对比
| 方案 | 实现方式 | 性能 | 适用场景 |
|---|---|---|---|
| ShaderMask ✅ | LinearGradient + dstIn | 最佳,GPU 合成 | 通用,背景任意 |
| Stack + 渐变容器 | Container 覆盖在列表上 | 中等 | 背景必须纯色 |
| CustomPainter | drawRect + saveLayer | 较差,频繁重绘 | 需完全自定义时 |
注意事项
- 背景为渐变/图片时,必须用
ShaderMask,Stack 方案会穿帮。 - child 含 Image/Canvas 时,在
ShaderMask外层加RepaintBoundary避免不必要重绘。 - stops 数组长度必须和 colors 数组一致,否则运行时报错。
fadeWidth建议保持在0.05~0.15之间,太大影响内容可读性。