『MOJi辞書』的 HarmonyOS 版本采用 Flutter 开发。上线后,用户反馈单词详情页左右滑动过于灵敏,导致上下滑动手势常常被误判,体验不佳。

这个页面的实现并不复杂,本质上就是在一个 PageView 里面嵌套了 WebView。
class WordDetailPage extends StatefulWidget {
const WordDetailPage({super.key});
@override
State<StatefulWidget> createState() => _WordDetailPageState();
}
class _WordDetailPageState extends State<WordDetailPage> {
...
late final PageController _pageController;
@override
Widget build(BuildContext context) {
...
return PageView(controller: _pageController, children: _getPages());
}
List<Widget> _getPages() {
List<WordDetailWidget> pages = [];
... // 填充 WebView 组件
return pages;
}
}
做过 Android 开发的小伙伴应该对这个问题不陌生,我们之前在『ViewPager2 横向嵌套滚动』一文中,就通过自定义 ViewGroup 重写事件分发来解决类似问题。
那在 Flutter 中是否也能照猫画虎呢?于是我把问题告诉 AI,AI 与我不谋而合,马上就给我生成了一个 Widget:
class DirectionalPageView extends StatefulWidget {
const DirectionalPageView({
super.key,
required List<Widget> this.children,
this.initialPage = 0,
this.directionThreshold = 1.0,
this.moveThreshold = 8.0,
this.switchThreshold = 50.0,
this.duration = const Duration(milliseconds: 300),
this.curve = Curves.easeInOut,
this.onPageChanged,
}) : itemCount = null,
itemBuilder = null;
const DirectionalPageView.builder({
super.key,
required this.itemCount,
required this.itemBuilder,
this.initialPage = 0,
this.directionThreshold = 1.0,
this.moveThreshold = 8.0,
this.switchThreshold = 50.0,
this.duration = const Duration(milliseconds: 300),
this.curve = Curves.easeInOut,
this.onPageChanged,
}) : children = null;
final List<Widget>? children;
final int? itemCount;
final Widget Function(BuildContext context, int index)? itemBuilder;
final int initialPage;
final double directionThreshold;
final double moveThreshold;
final double switchThreshold;
final Duration duration;
final Curve curve;
final ValueChanged<int>? onPageChanged;
@override
State<DirectionalPageView> createState() => _DirectionalPageViewState();
}
class _DirectionalPageViewState extends State<DirectionalPageView> {
late final PageController _pageController;
double _dragStartX = 0;
double _dragStartY = 0;
bool _isDragging = false;
bool _isHorizontalGesture = false;
int _currentPage = 0;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_pageController = PageController(initialPage: widget.initialPage);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPointerDown(PointerDownEvent event) {
_dragStartX = event.position.dx;
_dragStartY = event.position.dy;
_isDragging = true;
_isHorizontalGesture = false;
}
void _onPointerMove(PointerMoveEvent event) {
if (!_isDragging) return;
final dx = event.position.dx - _dragStartX;
final dy = event.position.dy - _dragStartY;
final absDx = dx.abs();
final absDy = dy.abs();
if (!_isHorizontalGesture && (absDx > widget.moveThreshold || absDy > widget.moveThreshold)) {
_isHorizontalGesture = absDy == 0 || (absDx / absDy) > widget.directionThreshold;
if (!_isHorizontalGesture) {
_isDragging = false;
}
}
}
void _onPointerUp(PointerUpEvent event) {
if (!_isDragging) return;
final dx = event.position.dx - _dragStartX;
final velocity = dx.abs() > widget.switchThreshold ? (dx > 0 ? 1.0 : -1.0) : 0.0;
if (_isHorizontalGesture && velocity != 0) {
if (velocity > 0 && _currentPage > 0) {
_animateToPage(_currentPage - 1);
} else if (velocity < 0 && _currentPage < _totalPages - 1) {
_animateToPage(_currentPage + 1);
}
}
_isDragging = false;
_isHorizontalGesture = false;
}
void _animateToPage(int page) {
_pageController
.animateToPage(
page,
duration: widget.duration,
curve: widget.curve,
)
.then((_) {
if (_currentPage != page) {
setState(() => _currentPage = page);
widget.onPageChanged?.call(page);
}
});
}
int get _totalPages {
if (widget.children != null) {
return widget.children!.length;
}
return widget.itemCount ?? 0;
}
Widget _buildPage(int index) {
if (widget.children != null) {
return widget.children![index];
}
return widget.itemBuilder!(context, index);
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: (_) {
_isDragging = false;
_isHorizontalGesture = false;
},
behavior: HitTestBehavior.translucent,
child: PageView.builder(
controller: _pageController,
scrollBehavior: _isHorizontalGesture ? null : const _NoScrollBehavior(),
itemCount: _totalPages,
itemBuilder: (context, index) => _buildPage(index),
),
);
}
}
class _NoScrollBehavior extends ScrollBehavior {
const _NoScrollBehavior();
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return const NeverScrollableScrollPhysics();
}
}
extension DirectionalPageViewController on PageController {
void nextPage(Duration duration, Curve curve) {
final currentPage = (page?.round() ?? 0);
final position = positions.isNotEmpty ? positions.first : null;
if (position != null) {
final pageSize = position.viewportDimension;
final totalPages = (position.maxScrollExtent / pageSize).ceil() + 1;
if (currentPage < totalPages - 1) {
animateToPage(currentPage + 1, duration: duration, curve: curve);
}
}
}
void previousPage(Duration duration, Curve curve) {
final currentPage = (page?.round() ?? 0);
if (currentPage > 0) {
animateToPage(currentPage - 1, duration: duration, curve: curve);
}
}
}
用法也非常简单,只需将原本的 PageView 替换为 DirectionalPageView 即可。
class WordDetailPage extends StatelessWidget {
const WordDetailPage({super.key});
@override
Widget build(BuildContext context) {
return DirectionalPageView(children: _getPages());
}
List<Widget> _getPages() {
List<WordDetailWidget> pages = [];
... // 填充 WebView 组件
return pages;
}
}
效果如下:

AI 生成的这个 DirectionalPageView,解决了横向滑动过于灵敏的问题,使纵向的滑动得以识别,但新问题也随之而来——从上图中可以看到,页面切换要等到手指释放后才触发,滑动过程完全不跟手,体验大打折扣。
我转念一想,作为一个声明式 UI,Flutter 应该有更加优雅的方式来处理手势冲突,而不是像 Android View 那样手动处理。于是我开始搜资料找文档查源码,还真让我找到了解决方案。
Flutter 中大多数可滚动的组件(如 PageView、ListView 等),它们的滑动效果都来自于 Scrollable 组件,在 Scrollable 内部针对不同方向的响应,是通过 RawGestureDetector 完成的,VerticalDragGestureRecognizer 和 HorizontalDragGestureRecognizer 分别负责处理纵向和横向的滑动事件。
class Scrollable extends StatefulWidget {
...
@override
ScrollableState createState() => ScrollableState();
}
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin implements ScrollContext {
...
@override
@protected
void setCanDrag(bool value) {
...
if (!value) {
...
} else {
switch (widget.axis) {
case Axis.vertical:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
(VerticalDragGestureRecognizer instance) {
...
},
),
};
case Axis.horizontal:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
(HorizontalDragGestureRecognizer instance) {
...
},
),
};
}
}
...
}
}
再看它们相应的判断逻辑:
abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
...
bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop);
}
class HorizontalDragGestureRecognizer extends DragGestureRecognizer {
...
@override
bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) {
return _globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings);
}
}
class VerticalDragGestureRecognizer extends DragGestureRecognizer {
...
@override
bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) {
return _globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings);
}
}
double computeHitSlop(PointerDeviceKind kind, DeviceGestureSettings? settings) {
switch (kind) {
case PointerDeviceKind.mouse:
return kPrecisePointerHitSlop;
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
return settings?.touchSlop ?? kTouchSlop;
}
}
computeHitSlop() 方法会根据 Pointer 的类型确定当前命中需要的最小像素,触摸(PointerDeviceKind.touch)默认值是 kTouchSlop,目前是 18.0。
为什么说“目前”?文档是这么解释的:
The distance a touch has to travel for the framework to be confident that the gesture is a scroll gesture, or, inversely, the maximum distance that a touch can travel before the framework becomes confident that it is not a tap.
A total delta less than or equal to
kTouchSlopis not considered to be a drag, whereas if the delta is greater thankTouchSlopit is considered to be a drag.This value was empirically derived. We started at 8.0 and increased it to 18.0 after getting complaints that it was too difficult to hit targets.
由此可见,通过调整 PageView 的 touchSlop 就能改变响应灵敏度。而这个值正是通过 DeviceGestureSettings 传入 computeHitSlop() 的,而 DeviceGestureSettings 又可以通过 MediaQuery 来设置。所以我们只需给 PageView 包一层 MediaQuery,把 touchSlop 调大:
class WordDetailPage extends StatefulWidget {
const WordDetailPage({super.key});
@override
State<StatefulWidget> createState() => _WordDetailPageState();
}
class _WordDetailPageState extends State<WordDetailPage> {
...
late final PageController _pageController;
@override
Widget build(BuildContext context) {
...
return MediaQuery(
data: MediaQuery.of(context).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 48)), // 用于调整滑动灵敏度
child: PageView(controller: _pageController, children: _getPages()),
);
}
List<Widget> _getPages() {
List<WordDetailWidget> pages = [];
... // 填充 WebView 组件
return pages;
}
}
但是不要忘记,在子组件中需要把 touchSlop 切换回默认的 kTouchSlop:
class WordDetailWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
...
return Scaffold(
body: Stack(
children: [
...
KeepAliveWrapper(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: kTouchSlop)),
child: InAppWebView(
...
),
),
);
],
),
);
}
}
最后看看效果,丝滑:

