写这个系列写到第五十七篇,前面已经陆陆续续把布局、列表、文本、图片这些高频组件过了一遍,唯独 Scrollbar 一直压着没写。原因很简单,在绝大多数开发者眼里它就是一个“滚动时露一下的灰条”,既不值得单独占一篇,也不觉得有什么适配难度。但等我真的在 OpenHarmony 真机上做滚动页面时,才发现这条“灰线”远没有想象中简单。Scrollbar 在 Flutter 里被归类为基础组件,实际承担的职责有三层:提示用户这里还能继续滚动、反馈当前滚动到了什么位置、以及提供一个可以直接拖拽到目标位置的快捷入口。在手机屏幕上这些职责可以被弱化,但在平板、电视、折叠屏这些形态上,Scrollbar 做得不像系统原生,用户几乎立刻就会有疏离感。这篇文章就把 Scrollbar 在 Flutter for OpenHarmony 场景下的原理、参数、自定义和排查经验完整整理一遍,适合正在做跨端迁移或者准备把 Flutter 应用部署到 OpenHarmony 设备上的开发者。
1. 先搞清楚 Scrollbar 在 Flutter 里的真实身份
1.1 它不是一个“画线的 Widget”,而是一个滚动观察者
很多刚接触 Flutter 的开发者,看到 Scrollbar 的第一个反应是把它当成装饰组件:外面包一层,里面放列表,滚动时有个指示条跟着动。这个理解不算错,但如果只停留在这一层,后面几乎所有适配问题都解释不通。
Scrollbar 的底层逻辑是“观察”。它本身不监听触摸事件,也不驱动任何滚动,它唯一做的事情是监听滚动位置的变化,然后把位置信息换算成一条可以绘制的滑块。Flutter 内部完成这个工作的角色有三个:ScrollController、ScrollPosition 和 ScrollbarPainter。当你给 Scrollbar 指定一个 controller,它就会订阅这个 controller 关联的 ScrollPosition;每当滚动位置变化、viewport 尺寸变化、内容总长度变化时,ScrollbarPainter 就会根据这几个关键值重新计算滑块的长度和位置,然后触发重绘。
所以 Scrollbar 能不能正常工作,前置条件非常明确:它的 controller 必须和真正执行滚动的 Scrollable 是同一个。很多人在 OpenHarmony 上“加了 Scrollbar 但没反应”,几乎都是这一个原因。你给 Scrollbar 配了一个 controller,但 ListView 用的是另一套默认的 PrimaryScrollController,两边各听各的,滚动条当然永远不动。
1.2 滑块长度背后的几何换算
ScrollbarPainter 的计算不复杂,核心就是等比映射。假设 viewportDimension 是视口高度,maxScrollExtent 是内容总高度减去视口高度之后的剩余可滚动距离,那么滑块长度大致等于视口高度乘以一个比例,再约束到可接受范围内。直观理解就是:把全部内容等比缩小成一条“轨道”,把当前可见的视口等比缩小成一个“滑块”,滑块在轨道上的位置就是当前滚动位置的比例。
这个换算带来一个很典型的边界情况:当内容不满一屏、maxScrollExtent 为 0 时,滑块没有可绘制的空间,Scrollbar 会自动隐藏。很多人在 OpenHarmony 上测试时,只放了三五个条目进去,发现滚动条不出现,以为组件坏了,实际上内容根本没有可滚动空间。
还有一点值得注意:Scrollbar 的滑块并不是严格按照比例绘制的,它内部会设置一个最小滑块长度,防止内容过长时滑块缩成一条细线找不到。这个 minThumbExtent 默认值在不同平台上略有差异,在 OpenHarmony 上如果你觉得滑块太短不好抓取,应该显式把它调大,后面我会详细讲参数配置。
1.3 通知与重绘的联动方式
Scrollbar 监听滚动变化,主要靠的是 ScrollNotification 这一套通知机制。滚动发生时,Scrollable 会发送 ScrollUpdateNotification、ScrollStartNotification、ScrollEndNotification,这些通知会沿着 widget 树向上冒泡。Scrollbar 本身是在滚动区域的上层,所以它能收到这些冒泡上来的通知,再根据自己的 notificationPredicate 决定要不要处理。
这个机制解释了另一个常见的困惑:为什么 Scrollbar 包在列表外面,列表滚动时它就能知道位置?并不是 Scrollbar 通过 controller 去“问”列表,而是滚动通知自己冒泡到了 Scrollbar 这里。controller 在这里的作用更像是建立一个额外的关联通道,让 Scrollbar 在初始化时就知道应该关注哪个滚动位置。理解这一点后,再看 notificationPredicate、嵌套滚动这些高级用法,思路就会清晰很多。
2. OpenHarmony 场景下的适配策略
2.1 先参考系统原生滚动条的交互习惯
在 Flutter 侧做适配之前,我建议先在 OpenHarmony 原生应用里把系统滚动体验摸一遍。OpenHarmony 的 ArkUI 体系里,Scroll 组件对滚动条是有明确模式控制的,支持自动显示、常驻显示和强制隐藏,并且滚动条本身往往是可交互的。用户在中高端设备上能直接拖拽滑块,部分系统界面还支持点击轨道跳转。这种交互习惯和 Flutter 默认的 Material 风格滚动条有明显差异。
Flutter 的 Scrollbar 默认只支持拖拽滑块,点击轨道不会有任何反应,而且滑块的触控目标非常窄。如果你只是把 Scrollbar 原样搬到 OpenHarmony 上,用户拖不到、点不了,自然称不上“完美适配”。我的做法是:先确定目标交互模型,再倒推 Flutter 侧怎么改。触摸屏设备至少要做到“能看到当前位置、能拖拽快速定位”;遥控器设备还要额外考虑 hover 反馈和焦点变化时的滚动条状态切换。
2.2 用平台和设备形态判断选择参数
OpenHarmony 的设备形态跨度太大,从手机到平板,再到电视盒子、办公大屏,滚动条不可能只用一套参数。我习惯写一个配置函数,统一根据屏幕尺寸和运行环境返回不同的 Scrollbar 参数,代码大概长这样:
class ScrollbarConfig { final double thickness; final double radius; final bool interactive; final bool thumbVisibility; final Duration timeToFade; const ScrollbarConfig({ required this.thickness, required this.radius, required this.interactive, required this.thumbVisibility, required this.timeToFade, }); } ScrollbarConfig _resolveScrollbarConfig(BuildContext context) { final size = MediaQuery.sizeOf(context); final shortestSide = size.shortestSide; if (shortestSide >= 600) { // 平板、折叠屏展开态,滚动条常驻,便于快速定位 return const ScrollbarConfig( thickness: 6, radius: 3, interactive: true, thumbVisibility: true, timeToFade: Duration(seconds: 30), ); } else if (shortestSide < 400 && _isTvDevice(context)) { // TV 盒子场景,滚动条必须清晰可见且容易命中 return const ScrollbarConfig( thickness: 10, radius: 5, interactive: true, thumbVisibility: true, timeToFade: Duration.zero, ); } else { // 手机场景,滚动时出现,停止后淡出 return const ScrollbarConfig( thickness: 4, radius: 2, interactive: true, thumbVisibility: false, timeToFade: Duration(milliseconds: 600), ); } }这里 _isTvDevice 可以根据 OpenHarmony 的设备信息去判断,不同实现版本可能有差异,但思路是通用的:thumb 的宽度在触摸屏上不只是视觉,它同时承担触控热区的职责。4 个逻辑像素的滑块在手指操作时几乎不可能精准命中,所以要尽量通过外边距和命中扩展来保证有效热区至少覆盖 20 个逻辑像素以上。
2.3 滚动物理效果也要一起调
Scrollbar 不是独立工作的,它和滚动 physics 强相关。OpenHarmony 设备上如果使用默认的 BouncingScrollPhysics,列表拉到边缘会回弹,回弹过程中 ScrollPosition 的 pixels 会越过边界,Scrollbar 的滑块位置也会跟着来回移动。手机场景下这个现象是正常的,用户看惯了;但在平板上,如果用户习惯的是“硬边界 + 滚动条稳定”,回弹会让滚动条看起来在抖动。
我的经验是根据设备形态选择物理效果。手机保持回弹,平板和 TV 使用 ClampingScrollPhysics,让滚动条在边缘时更稳定。在 Flutter for OpenHarmony 的适配实现里,直接给列表设置 physics 为 ClampingScrollPhysics 就能避免大部分越界抖动。如果你不想在业务层到处写 physics,可以封装一个统一的基础列表组件,内部根据设备尺寸自动切换。
3. 实战:把标准 Scrollbar 正确用起来
3.1 最标准的接入方式,和最容易错的那一行
先从最常规的写法开始。很多人以为 Scrollbar 会自动找到某个滚动物体,实际上它必须通过 controller 建立连接。标准写法是这样的:
final ScrollController _controller = ScrollController(); Scrollbar( controller: _controller, child: ListView.builder( controller: _controller, itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), )这里最核心的细节是:Scrollbar 设置了 controller,ListView 也必须要用同一个 controller。如果你只在 Scrollbar 上设置 controller,而 ListView 用的是它自己默认的 PrimaryScrollController,那么 Scrollbar 监听的是一个没人滚动的 ScrollPosition,滚动条自然不会亮。
如果你不想显式创建 controller,也可以利用 PrimaryScrollController。在没有任何显式 controller 的情况下,ListView 会默认绑定 PrimaryScrollController,而 Scrollbar 在未指定 controller 时也会尝试使用 PrimaryScrollController。所以最简单的写法其实是不传任何 controller:
Scrollbar( child: ListView.builder( itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), )这种写法在单列表页面里没有问题,但代价是你无法精确控制,而且一旦页面里出现第二个 Scrollable,混乱就开始了。所以我更建议所有需要 Scrollbar 的页面都显式创建 controller,哪怕多写几行,后面排查问题时真的能省下大量时间。
3.2 控制显示与淡出的关键参数
在 OpenHarmony 手机上,通常希望滚动条只在滚动时出现,停止后快速淡出。这组参数是:
Scrollbar( controller: _controller, thumbVisibility: false, timeToFade: const Duration(milliseconds: 500), fadeDuration: const Duration(milliseconds: 200), child: ListView.builder( controller: _controller, itemCount: 100, itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), )thumbVisibility 为 false 时,滚动条不会常驻,但会在检测到滚动通知时显示;timeToFade 是停止滚动后多久开始淡出;fadeDuration 是淡出过程的时长。这组参数在手机场景下能模拟出接近系统的手感。但到了平板上,我建议把 thumbVisibility 设为 true,让滚动条常驻,否则用户很难判断长列表是否可以继续滚动。
这里有一个容易踩的坑:thumbVisibility 设为 true 之后,滚动条并不是“永不透明”。如果你同时设置了很短的 timeToFade,你会发现滚动条即使常驻,停止滚动后还是会慢慢变淡。因为 Flutter 的实现里,thumbVisibility 控制的是“是否有显示机会”,而不是“是否一直保持完全不透明”。想要真正的常驻不透明,timeToFade 必须给一个很大的值,比如 30 秒,或者干脆放弃 Scrollbar 的淡出机制,改用自绘方案。
3.3 开启滑块拖动:interactive 的边界
设置 interactive: true 之后,用户按住 thumb 拖动,可以让列表快速移动。这个交互在桌面端很常见,但在触摸屏上,Flutter 做了更加保守的处理:thumb 的默认宽度太窄,直接触摸命中率低,所以官方实现里会给 thumb 附近扩展一个更大的命中区域。这个命中区域在 OpenHarmony 上表现基本正常,但在高分辨率平板上,因为逻辑像素和物理像素的比例不同,命中区域依然可能偏小。
如果发现拖动总是“抓不住”,优先检查 thickness 和 crossAxisMargin、mainAxisMargin 是否把空间压得太小。Scrollbar 会根据 thickness 绘制 thumb,交互区域也会受到外边距的影响。我的经验是厚度至少 6,并且不要在 Scrollbar 外层再套一个会压缩布局尺寸的 Container,否则命中区域会被裁剪掉。
另外要注意:interactive 的拖动走的是 Scrollbar 内部自己的手势识别,和列表的拖动手势是两套体系。在 OpenHarmony 低端设备上,两套手势同时监听可能会产生一些竞争,表现就是指哪打哪不精准。如果不能接受,可以考虑用 RawScrollbar 配合自定义手势处理,这个在后文的自绘部分会展开。
3.4 横竖屏和从右到左布局的方向问题
ScrollbarOrientation 默认会根据 Scrollable 的 axisDirection 自动选择方向,但水平滚动时最好显式指定:
Scrollbar( controller: _controller, scrollbarOrientation: ScrollbarOrientation.bottom, child: ListView( scrollDirection: Axis.horizontal, controller: _controller, children: const [ SizedBox(width: 200, child: Placeholder()), SizedBox(width: 200, child: Placeholder()), ], ), )OpenHarmony 对 RTL 语言的支持比较完善,如果你的应用面向阿拉伯语地区,Scrollbar 默认会跟随 Directionality 自动跑到左侧。你需要验证一下 fling 行为和方向是否跟系统一致,不要手动强制把 Scrollbar 放在右侧,否则 RTL 用户会觉得非常别扭。这个细节虽然小,但往往就在验收测试时被翻出来。
4. 自定义一版“鸿蒙风格”的滚动条
4.1 为什么默认的 Material 样式不够用
Flutter 默认 Scrollbar 是标准 Material 灰条。这套视觉在浅色界面上勉强够看,但到了 OpenHarmony 的深色模式、高亮度屏幕、以及 TV 大屏下,默认的半透明灰滑块对比度不足,边缘不够清晰。OpenHarmony 原生控件更倾向于“窄轨道 + 圆角滑块”,轨道颜色更淡、滑块颜色更实、拖动时颜色加深。
如果只是调整颜色和圆角,不需要自绘,用 ScrollbarTheme 就够了:
Theme( data: Theme.of(context).copyWith( scrollbarTheme: ScrollbarThemeData( thumbColor: WidgetStatePropertyAll(Colors.grey.shade600), trackColor: WidgetStatePropertyAll(Colors.grey.shade200), radius: const Radius.circular(4), thickness: WidgetStatePropertyAll(6), mainAxisMargin: 4, crossAxisMargin: 2, minThumbExtent: 40, ), ), child: Scrollbar( controller: _controller, child: ListView.builder( controller: _controller, itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), ), )这套主题的好处是全局生效,不需要每个页面都改。minThumbExtent 建议设置到 40 左右,因为太短的滑块几乎无法点击,视觉上也不够专业。这个经验是从 OpenHarmony 高分辨率平板上踩出来的,默认值有几次短到用户以为页面卡住了。
4.2 给滚动条加上“点击轨道跳转”
默认 Scrollbar 点击轨道不会跳转。但 OpenHarmony 的原生滚动条有点击轨道跳转的交互,如果你追求“像系统组件”,这一步绕不开。
可靠的做法是:在滚动条区域用 LayoutBuilder 拿到轨道高度,再通过 Listener 监听用户点击的位置,先判断是否落在 thumb 范围内,如果是就交给 Scrollbar 的拖动逻辑;如果不是,就根据点击纵坐标计算出目标偏移,然后调用 controller.jumpTo。核心代码如下:
LayoutBuilder( builder: (context, constraints) { final trackHeight = constraints.maxHeight; return Listener( onPointerDown: (event) { final localY = event.localPosition.dy; final position = _controller.position; final extent = position.maxScrollExtent; final thumbStart = (position.pixels / extent) * trackHeight; final thumbExtent = (trackHeight / (trackHeight + extent)) * trackHeight; final inThumb = localY >= thumbStart && localY <= thumbStart + thumbExtent; if (!inThumb && extent > 0) { final target = (localY / trackHeight) * extent; _controller.jumpTo(target.clamp(0.0, extent)); } }, child: Scrollbar( controller: _controller, interactive: true, child: ListView.builder( controller: _controller, itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), ), ); }, )这段代码注意两个问题。第一,jumpTo 是瞬时跳转,没有动画,如果你想要平滑滚动,改成 animateTo 并给一个合理时长;第二,Listener 如果包的范围过大,会拦截列表自身的滚动事件,所以精确做法不是把整个列表包进 Listener,而是只让 Listener 覆盖滚动条所在的窄条区域。我实际项目里最终是封装了一个 _TrackJumpScrollbar 组件,内部用 RawGestureDetector 处理手势竞技场,对外暴露 controller 和 child,业务页面不用关心手势冲突。
4.3 完全自绘一个轻量滚动指示器
如果想把视觉和交互彻底握在自己手里,可以抛开 Scrollbar 组件,直接用 CustomPainter 自己画。思路是:用 NotificationListener 监听 ScrollUpdateNotification,从 notification.metrics 中读取 pixels、viewportDimension、maxScrollExtent,然后触发重绘。
class OhosScrollIndicator extends StatefulWidget { const OhosScrollIndicator({ super.key, required this.child, }); final Widget child; @override State<OhosScrollIndicator> createState() => _OhosScrollIndicatorState(); } class _OhosScrollIndicatorState extends State<OhosScrollIndicator> { ScrollMetrics? _metrics; @override Widget build(BuildContext context) { return NotificationListener<ScrollNotification>( onNotification: (notification) { setState(() => _metrics = notification.metrics); return false; }, child: Stack( children: [ widget.child, Positioned( right: 2, top: 0, bottom: 0, width: 4, child: CustomPaint( painter: _IndicatorPainter(metrics: _metrics), ), ), ], ), ); } } class _IndicatorPainter extends CustomPainter { _IndicatorPainter({this.metrics}); final ScrollMetrics? metrics; @override void paint(Canvas canvas, Size size) { if (metrics == null || metrics!.maxScrollExtent <= 0) { return; } final trackPaint = Paint()..color = const Color(0x22666666); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, size.width, size.height), const Radius.circular(2), ), trackPaint, ); final extent = metrics!.maxScrollExtent + metrics!.viewportDimension; final thumbHeight = (metrics!.viewportDimension / extent) * size.height; final thumbTop = (metrics!.pixels / metrics!.maxScrollExtent) * (size.height - thumbHeight); final thumbPaint = Paint()..color = const Color(0x99666666); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(0, thumbTop, size.width, thumbHeight), const Radius.circular(2), ), thumbPaint, ); } @override bool shouldRepaint(covariant _IndicatorPainter oldDelegate) { return oldDelegate.metrics?.pixels != metrics?.pixels || oldDelegate.metrics?.maxScrollExtent != metrics?.maxScrollExtent || oldDelegate.metrics?.viewportDimension != metrics?.viewportDimension; } }自绘方案的好处是彻底去除淡入淡出、hover、平台判定这些隐式逻辑,状态机完全由自己控制,非常适合做 OpenHarmony 多设备适配。缺点是代码量上去了,手势交互也要自己实现。我的建议是:如果只是想让现有应用在 OpenHarmony 上更统一,用 ScrollbarTheme 改样式就够了;如果是从零开始的新应用,自绘滚动指示器可以作为统一设计语言的一部分。
5. 与不同滚动容器组合的实战细节
5.1 Scrollbar 和 ListView、GridView、PageView 的配合差异
很多开发者问过“为什么 Scrollbar 放在 GridView 上无效”,其实根因和 ListView 一样,都是 controller 没配对。但因为不同滚动容器的默认 controller 行为不同,实际表现会有差异。我整理了一张表,方便快速对照。
| 滚动容器 | 默认是否走 PrimaryScrollController | 与 Scrollbar 配合的最稳方式 | 需要特别注意 |
|---|---|---|---|
| ListView | 是 | 显式 controller | 页面里不能出现第二个未指定 controller 的 Scrollable |
| GridView | 是 | 显式 controller | 跨轴方向间距会挤压 thumb 的绘制区域 |
| SingleChildScrollView | 是 | 显式 controller | 内容不满一屏时 Scrollbar 不显示,是正常现象 |
| PageView | 否 | 显式 controller | 一页一页跳变,滑块是离散移动 |
| CustomScrollView | 否 | 显式 controller | 多个 sliver 的 extent 汇总成整体,Scrollbar 感知不到内部 sliver 边界 |
PageView 需要单独说。它的 pixels 变化是离散的,一页一页跳,Scrollbar 的滑块也跟着一格一格跳。如果你希望滑块在页面切换时连续过渡,而不是突然瞬移,可以给 PageView 设置自定义 ScrollPhysics 来平滑插值,但那样会破坏 PageView 的翻页手感,所以一般不建议。更合理的做法是:分页场景不要用 Scrollbar,改用分页指示器,比如圆点或数字指示,语义更清晰。
5.2 用 notificationPredicate 解决多滚动区域串扰
一个页面里如果有两个滚动区域,你把 Scrollbar 包在其中一个外面,它依然会收到来自另一个区域的滚动通知吗?答案是会。Scrollbar 的实现原理是监听 ScrollNotification,而所有滚动通知都会从发生滚动的 Scrollable 开始,沿 widget 树向上冒泡。只要 Scrollbar 在通知冒泡路径上,它就会收到。
解决办法是使用 notificationPredicate 过滤。例如页面外层是一个大列表,内层是一个横向滚动的标签栏,你只想让 Scrollbar 响应外层纵向滚动:
Scrollbar( notificationPredicate: (notification) { return notification.metrics.axis == Axis.vertical && notification.depth == 0; }, controller: _outerController, child: OuterList( controller: _outerController, child: InnerHorizontalTabs(), ), )depth 表示当前通知距离最里层 Scrollable 的深度,0 表示通知来源于当前这个 Scrollable 本身。如果你不判断 depth,内层横向标签栏滚动时,外层 Scrollbar 也会跟着动。加上 axis 判断之后,横向通知直接被过滤,纵向通知即便 depth 不为 0,也只会匹配到自己想要的那个滚动列表。这个组合条件在实战里非常可靠,推荐记下来。
5.3 嵌套滚动时 controller 的选择误区
嵌套滚动是另一个高发坑。比如外层是 RefreshIndicator 包着列表,内层还有一个独立的滚动列表。如果你只创建一个 controller 给外层,内层列表就不能绑定同一个 controller,否则两个列表会共用同一个 ScrollPosition,内外滚动互相干扰。正确做法是分别创建 controller,然后只在外层包 Scrollbar。
如果非要在一个页面里同时显示两个滚动条,那就要写两个 Scrollbar,并各自带上独立的 controller 和 notificationPredicate。此时特别要注意通知过滤,因为两个滚动通知都会冒泡到同一个祖先节点,不过滤的话,两个 Scrollbar 会同时收到两个列表的通知,滑块位置会串。这种场景下,调试的优先顺序是:先单独跑通每个列表的滚动,再叠加 Scrollbar,最后加过滤条件,三步走基本能排查干净。
6. 排查实录:Scrollbar 在 OpenHarmony 上最容易踩的坑
6.1 滚动条死活不出现
“加了 Scrollbar 没效果”是我被问到最多的一个问题。排查步骤基本固定:先看内容是不是不满一屏,再看 controller 是否和滚动容器绑定,最后看 thumbVisibility 是不是设成了 false 且当前没有滚动事件触发。
在 OpenHarmony 真机上,还有一个容易被忽略的点:如果你在页面初始化时就尝试读取 ScrollPosition,比如在 initState 里直接访问 controller.position,而此时 controller 还没有 attach 到 Scrollable,你会得到一个空状态或者直接抛异常。Scrollbar 自然画不出任何内容。正确做法是等第一次 Layout 完成后,再依赖滚动通知去更新状态,不要试图在 build 之前强行读取滚动位置。
6.2 thumb 拖动跟手,但内容“甩尾”
Scrollbar 的 interactive 拖动走的是它内部自己的手势识别,和列表的拖动手势是两套体系。在 OpenHarmony 真机上,如果列表的滚动惯性比较大,松开 thumb 的瞬间往往还会带着内容继续滑动一段距离,滚动条已经停下来了,内容还在往前冲,看起来就像“甩尾”。这是 Flutter 默认行为,在阻尼参数不同的 OpenHarmony 设备上会更明显。
处理办法有两个:要么在滚动结束或 thumb 释放后调用 controller.position.hold,配合代码把残余动能清掉;要么直接放弃 Scrollbar 的 interactive,自己写拖动映射,拖动过程中实时调用 controller.jumpTo,丢弃传入的 velocity。后者更可控,我自绘方案里用的就是这一套,OpenHarmony 上拖动的跟手性比默认好很多。
6.3 深色模式下滚动条隐身
默认 Scrollbar 的 thumb 颜色来自主题相关的一套 Material 颜色。在浅色模式下能看到,但切到 OpenHarmony 深色模式后,如果主题色没有同步,灰条在深色背景上就会变成“深灰压深灰”。建议不要依赖默认值,通过 ScrollbarTheme 显式指定 thumbColor,并且要保证明暗对比足够明显。
我的习惯是设置两套颜色:浅色模式下用灰色 600,深色模式下用灰色 300。如果你在应用里有全局主题管理,可以把这个配置放进主题扩展里,这样新页面就不用重复处理。实际问题里我还遇到过 TV 上 OLED 屏幕的纯黑背景下滚动条太亮刺眼的情况,这时候可以把 thumbColor 调成半透明灰色,视觉上收敛很多。
6.4 Scrollbar 重绘开销大导致列表卡顿
滚动是很高频的事件,Scrollbar 收到通知后会触发整个 Scrollbar 子树的重绘。如果列表项很复杂,这种重绘开销在低端 OpenHarmony 设备上会比较明显。优化手段有两个:一是给 Scrollbar 的 child 包一层 RepaintBoundary,让列表自身不跟随 Scrollbar 重绘;二是尽量使用 RawScrollbar,它在某些版本里比 Scrollbar 少一层主题相关的间接层,重绘范围更小。
另一个与重绘相关的现象是抖动。当 thumbVisibility 为 false、timeToFade 设置得很短时,连续滚动中每次通知都会重置淡出计时器,视觉上滑块会闪烁。解决方法是把 timeToFade 拉长到 800 毫秒以上,或者改成自绘滚动指示器,淡出状态完全由自己控制,避免系统默认计时逻辑带来的闪烁。
6.5 电视遥控器方向键滚动时,滚动条没有反馈
最后分享一个和 TV 相关的经验。遥控器场景下,用户不是滑动屏幕,而是通过方向键或飞梭触发滚动。此时 Scrollbar 的显示逻辑如果只依赖 ScrollUpdateNotification,其实也能触发,但视觉反馈会很迟钝。我习惯在 TV 设备上额外监听焦点变化和按键事件,一旦检测到方向键按下,就主动让滚动条进入高亮状态,用 100 到 200 毫秒的动画从低透明度过渡到高透明度。
这样做的目的是让用户明确感知“焦点还在列表内、滚动位置有效”。如果滚动条一直默默无闻,用户在电视上很难判断当前焦点移动到哪个列表区域了,尤其是页面里有多段可滚动内容的时候。这个细节不做成单独组件的话,很容易在适配 OpenHarmony 多形态设备时被漏掉,但一旦做了,整个界面的系统质感会明显提升。
Scrollbar 这个组件,放在普通应用里可能真的只是“一条灰线”,但在 OpenHarmony 这种设备形态跨度极大的平台上,它反而成了跨端适配的一面镜子:要不要常驻、要不要交互、拖拽热区多大、深色模式怎么处理、遥控器怎么反馈,每一项都直接影响用户对这个应用“像不像系统原生”的判断。我个人在实际调试中最深的体会是:滚动条从来不是画上去就完事的,它需要和手势、物理滚动、平台习惯一起设计,才能做到真正“适配”。希望这篇把原理、参数、自定义和排查串起来的记录,能帮你少踩几个我踩过的坑。