『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 中大多数可滚动的组件(如 PageViewListView 等),它们的滑动效果都来自于 Scrollable 组件,在 Scrollable 内部针对不同方向的响应,是通过 RawGestureDetector 完成的,VerticalDragGestureRecognizerHorizontalDragGestureRecognizer 分别负责处理纵向和横向的滑动事件。

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.

由此可见,通过调整 PageViewtouchSlop 就能改变响应灵敏度。而这个值正是通过 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(
                ...
              ),
            ),
          );
        ],
      ),
    );
  }
}

最后看看效果,丝滑:

丝滑