『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 kTouchSlop is not considered to be a drag, whereas if the delta is greater than kTouchSlop it 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(
                ...
              ),
            ),
          );
        ],
      ),
    );
  }
}

最后看看效果,丝滑:

丝滑