1. 缘起
注: 本文有 Blibli 视频版,食用效果更加: www.bilibili.com/video/BV11p…
在桌面端中,有时候需要在宽度区域过窄时,同时支持水平和竖直双向滑动。比如 AndroidStudio 的文件树和编辑器区域,当宽度较窄时,水平方向通过拖拽底部滚动条来滚动视口。
在之前一直想实现这种效果,可惜未能实现,因为两个双向的 ScrollBar 同时存在会产生冲突,会出现一些交互上的问题。直到最近在玩 Flutter DevTools, 在 Debugger 面板中惊奇地发现,这个代码面板不就是我苦苦追求的 区域视口双向滑动
吗?!
可谓踏破铁鞋无觅处,得来全不费工夫。因为我是知道的:
Flutter DevTools 的 Web 界面是 Flutter 项目,而且是由官方维护的开源项目 devtools。
既然是开源的,从代码中得到 Debugger 面板代码区域,视口双向滑动的实现方式就有可行性。当你手中握有源码,并且其中有你非常需要的功能,那手撕它就会变得非常有趣,下面一起来看看吧。
2. DevTools 代码区域相关源码分析
Flutter DevTools 有几个功能页签,界面相关的代码在 screens
文件夹中,其中每个文件夹对应一个功能,今天的主角是 debugger
中的代码区域。
将代码 clone 到本地方便查看,其中很明显有个 codeview.dart
,很可能就是我们的目标文件。根据 Web 的界面,可以很快定位到对应代码实现的位置,从这里可以看出 Flutter DevTools 的开源项目分包还是非常好的。
认识一个源码中的某个组件,特别是 StatelessWidget
或 StatfulWidget
,可以从组件的构建逻辑开始看起,因为这是组合型组件逻辑的核心。
打开文件后,可以通过 AndroidStudio 的 Structure 页签,快速掌握当前文件中的类型结构信息。比如看到 _CodeViewState
的结构,找到 build 方法,双击就可以跳转到对应的源码位置。
如下构建逻辑中,当代码非空时,会通过 buildCodeArea 方法创建代码面板区域。到这里,就离真相越来越近了, buildCodeArea 方法中很可能就是区域视口双向滑动实现的场所。
继续查看,可以发现如下的核心代码:其中 tag1
和 tag2
处有两个 Scrollbar
,分别代表竖直和水平方向的滚动条。竖直方向上的滑动控制器是 textController
,在 tag3
处和 Lines 组件
绑定,也就是说 Lines 是一个竖直滚动的可滑动组件;水平方向上的滑动控制器是 horizontalController
,在 tag4
处和 SingleChildScrollView 组件
绑定,支持横向的滚动。
Widget contentBuilder(_, ScriptRef? script) {
if (lines.isNotEmpty) {
return DefaultTextStyle(
style: theme.fixedFontStyle,
child: Scrollbar( //-> ::tag1::
key: CodeView.debuggerCodeViewVerticalScrollbarKey,
controller: textController,
thumbVisibility: true,
// Only listen for vertical scroll notifications (ignore those
// from the nested horizontal SingleChildScrollView):
notificationPredicate: (ScrollNotification notification) =>
notification.depth == 1, //-> ::tag6::
child: ValueListenableBuilder<StackFrameAndSourcePosition?>(
valueListenable: widget.debuggerController?.selectedStackFrame ??
const FixedValueListenable<StackFrameAndSourcePosition?>(
null,
),
///略...
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final double fileWidth = calculateTextSpanWidth(
findLongestTextSpan(lines),
);
return Scrollbar( //-> ::tag2::
key:
CodeView.debuggerCodeViewHorizontalScrollbarKey,
thumbVisibility: true,
controller: horizontalController,
child: SingleChildScrollView( //-> ::tag4::
scrollDirection: Axis.horizontal,
controller: horizontalController,
child: SizedBox(
height: constraints.maxHeight,
width: math.max( //-> ::tag5::
constraints.maxWidth,
fileWidth,
),
child: Lines(
height: constraints.maxHeight,
codeViewController: widget.codeViewController, //-> ::tag3::
scrollController: textController,
///...
上面的两个Scrollbar、滑动控制器和滑动视口是双向滑动的核心,但并没有什么难点。除此之外,最难的一点是计算出内容宽度的临界值,也就是说,当约束的宽度尺寸小于哪个值时,允许进行拖拽滑动。因为如果宽度够大,是没必要拖拽滑动的。
这里很明显,当面板的宽度约束小于文字的最大宽度时,需要通过滚动来查看宽度之外的视图。所以在 tag5
处,通 过 SizedBox
组件对水平方向的组件施加紧约束,让内容宽度不小于 fileWidth
。也就是说,当面板区域小于fileWidth
之后,也就是宽度约束过小, 水平方向的 SingleChildScrollView 组件就会发挥效力。
下面来介绍一下,源码中如何计算最长文本宽度的。实现由于 debugger 功能需要支持单行的调试,以及点击方法时进行跳转。代码是作为行列表数据存在的,Lines 组件通过 ListView
对数据进行渲染。所以想要得到最长的一行文字,只需要找到最长一行的文字,并计算其宽度即可。也就是下面的 findLongestTextSpan
和 calculateTextSpanWidth
方法。
其中文本宽度的计算,可以通过 TextPainter
来处理,对应的代码如下:
/// Returns the width in pixels of the [span].
double calculateTextSpanWidth(TextSpan? span) {
final textPainter = TextPainter(
text: span,
textAlign: TextAlign.left,
textDirection: TextDirection.ltr,
)..layout();
return textPainter.width;
}
最后一点,也是最主要的一点需要处理。也有由于这一点,之前一直没能实现区域视口双向滑动的功能。下面是在竖直方向上 ScrollBar 构造时存在的一行代码:可以只监听竖直滚动的通知,忽略水平方滚动向通知。否则竖直方向滑动条展示的时机会有问题。
3.通过小案例提取精华
由于 debugger 代码面板中涉及到其他很多东西,这里来精简一下,做个区域视口双向滑动的最小案例,来方便大家理解和使用。如下所示,蓝色区域内有一行文字,当窗口宽度缩小到文本溢出时,底部会呈现滑动条支持水平滑动:
这里先总结一下实现区域视口的双向滚动的步骤:
- 需要两个可滑动的视口: SingleChildScrollView/ListView/CustomScrollview/GridView 等。
- 需要两个 Scrollbar 用于控制视口滑动,并且指定 ScrollController, 关联 [滑动视口] 和 [滑动条]。
- 约束水平方向的宽度,计算内容区尺寸宽度值,使小于该尺寸时,允许水平滑动。
- 竖直方向的 Scrollbar#notificationPredicate 返回 notification.depth == 1。 用于禁用水平方向响应滚动监听。
下面看一下案例的代码实现:其中六处的 tag 和上面一致。tag3
和 tag4
处是准备两个可滑动视口,这里简单期间使用 SingleChildScrollView,其他滑动组件都可以。 tag1
和 tag1
处是给出两个 Scrollbar,并绑定对应方向上的的滑动控制器; tag5
处对水平方向宽度约束的处理; tag6
处对竖直方向滚动条进行处理。
class ColorTextArea extends StatefulWidget {
const ColorTextArea({super.key});
@override
State<ColorTextArea> createState() => _ColorTextAreaState();
}
class _ColorTextAreaState extends State<ColorTextArea> {
final ScrollController _hCtrl = ScrollController();
final ScrollController _vCtrl = ScrollController();
@override
Widget build(BuildContext context) {
String text = '张风捷特烈@编程之王: https://github.com/toly1994328';
const TextStyle style = TextStyle(fontSize: 24,fontFamily: 'aldk',color: Colors.white, letterSpacing: 1);
return Scrollbar( //-> ::tag1::
thumbVisibility: true,
//-> ::tag6::
notificationPredicate: (ScrollNotification notification) => notification.depth == 1,
key: const Key('debuggerCodeViewVerticalScrollbarKey'),
controller: _vCtrl,
child: LayoutBuilder(
builder:(context, constraints){
final double boxHeight = 2500;
double boxWidth = calculateText(text,style);
return Scrollbar( //-> ::tag2::
key: const Key('debuggerCodeViewHorizontalScrollbarKey'),
thumbVisibility: true,
controller: _hCtrl,
child: SingleChildScrollView(//-> ::tag4::
controller: _hCtrl,
scrollDirection: Axis.horizontal,
child: SizedBox(
height: constraints.maxHeight,
width: max(boxWidth, constraints.maxWidth), //-> ::tag5::
child: SingleChildScrollView(
controller: _vCtrl, //-> ::tag3::
child: Container(
color: Colors.blue,
height: boxHeight,
child: Text( text ,style: style,),
)
))
),
);
} ,
),
);
}
/// 计算文字宽度
double calculateText(String text,TextStyle style) {
final TextPainter textPainter = TextPainter(
textAlign: TextAlign.left,
textDirection: TextDirection.ltr,
text: TextSpan(text: text,style: style)
);
textPainter.layout();
return textPainter.width;
}
@override
void dispose() {
_hCtrl.dispose();
_vCtrl.dispose();
super.dispose();
}
}
这样,Flutter 区域视口双向滑动的功能就从 Flutter DevTools 源码中扒出来了,然后分享给大家,这个功能在桌面端中是非常非常必要的。也希望大家在开源项目中遇到某些自己渴望的功能,也可以静下心来撕一撕,从源码中学习,师于源码。 那本文就到这里,谢谢观看 ~
尾声:
笔者将在 bilibli 不定期发布一些视频教程,欢迎大家可以关注和支持。另外公众号也可以关注一下,也会发布一些文章。
bilibli 账号: 张风捷特烈
公众号: 编程之王