Search code examples
flutterflutter-webmousewheelsmooth-scrollingflutter-pageview

Flutter Web "smooth scrolling" on WheelEvent within a PageView


With the code below

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) => MaterialApp(
        home: const MyHomePage(),
      );
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) => DefaultTabController(
        length: 2,
        child: Scaffold(
          appBar: AppBar(
            title: const Center(
            child: Text('use the mouse wheel to scroll')),
            bottom: TabBar(
              tabs: const [
                Center(child: Text('ScrollView')),
                Center(child: Text('PageView'))
              ],
            ),
          ),
          body: TabBarView(
            children: [
              SingleChildScrollView(
                child: Column(
                  children: [
                    for (int i = 0; i < 10; i++)
                      Container(
                        height: MediaQuery.of(context).size.height,
                        child: const Center(
                          child: FlutterLogo(size: 80),
                        ),
                      ),
                  ],
                ),
              ),
              PageView(
                scrollDirection: Axis.vertical,
                children: [
                  for (int i = 0; i < 10; ++i)
                    const Center(
                      child: FlutterLogo(size: 80),
                    ),
                ],
              ),
            ],
          ),
        ),
      );
}

You can see, running it on dartpad or from this video,

that using the mouse wheel to scroll a PageView provides a mediocre experience (at best),

This is a known issue #35687 #32120, but I'm trying to find a workaround

to achieve either smooth scrolling for the PageView or at least prevent the "stutter".

Can someone help me out or point me in the right direction?

I'm not sure the issue is with PageScrollPhysics;

I have a gut feeling that the problem might be with WheelEvent

since swiping with multitouch scroll works perfectly


Solution

  • The problem arises from chain of events:

    1. user rotate mouse wheel by one notch,
    2. Scrollable receives PointerSignal and calls jumpTo method,
    3. _PagePosition's jumpTo method (derived from ScrollPositionWithSingleContext) updates scroll position and calls goBallistic method,
    4. requested from PageScrollPhysics simulation reverts position back to initial value, since produced by one notch offset is too small to turn the page,
    5. another notch and process repeated from step (1).

    One way to fix issue is perform a delay before calling goBallistic method. This can be done in _PagePosition class, however class is private and we have to patch the Flutter SDK:

    // <FlutterSDK>/packages/flutter/lib/src/widgets/page_view.dart
    // ...
    
    class _PagePosition extends ScrollPositionWithSingleContext implements PageMetrics {
      //...
    
      // add this code to fix issue (mostly borrowed from ScrollPositionWithSingleContext):
      Timer timer;
    
      @override
      void jumpTo(double value) {
        goIdle();
        if (pixels != value) {
          final double oldPixels = pixels;
          forcePixels(value);
          didStartScroll();
          didUpdateScrollPositionBy(pixels - oldPixels);
          didEndScroll();
        }
        if (timer != null) timer.cancel();
        timer = Timer(Duration(milliseconds: 200), () {
          goBallistic(0.0);
          timer = null;
        });
      }
    
      // ...
    }
    

    Another way is to replace jumpTo with animateTo. This can be done without patching Flutter SDK, but looks more complicated because we need to disable default PointerSignalEvent listener:

    import 'dart:async';
    import 'package:flutter/gestures.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    class PageViewLab extends StatefulWidget {
      @override
      _PageViewLabState createState() => _PageViewLabState();
    }
    
    class _PageViewLabState extends State<PageViewLab> {
      final sink = StreamController<double>();
      final pager = PageController();
    
      @override
      void initState() {
        super.initState();
        throttle(sink.stream).listen((offset) {
          pager.animateTo(
            offset,
            duration: Duration(milliseconds: 200),
            curve: Curves.ease,
          );
        });
      }
    
      @override
      void dispose() {
        sink.close();
        pager.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Mouse Wheel with PageView'),
          ),
          body: Container(
            constraints: BoxConstraints.expand(),
            child: Listener(
              onPointerSignal: _handlePointerSignal,
              child: _IgnorePointerSignal(
                child: PageView.builder(
                  controller: pager,
                  scrollDirection: Axis.vertical,
                  itemCount: Colors.primaries.length,
                  itemBuilder: (context, index) {
                    return Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Container(color: Colors.primaries[index]),
                    );
                  },
                ),
              ),
            ),
          ),
        );
      }
    
      Stream<double> throttle(Stream<double> src) async* {
        double offset = pager.position.pixels;
        DateTime dt = DateTime.now();
        await for (var delta in src) {
          if (DateTime.now().difference(dt) > Duration(milliseconds: 200)) {
            offset = pager.position.pixels;
          }
          dt = DateTime.now();
          offset += delta;
          yield offset;
        }
      }
    
      void _handlePointerSignal(PointerSignalEvent e) {
        if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
          sink.add(e.scrollDelta.dy);
        }
      }
    }
    
    // workaround https://github.com/flutter/flutter/issues/35723
    class _IgnorePointerSignal extends SingleChildRenderObjectWidget {
      _IgnorePointerSignal({Key key, Widget child}) : super(key: key, child: child);
    
      @override
      RenderObject createRenderObject(_) => _IgnorePointerSignalRenderObject();
    }
    
    class _IgnorePointerSignalRenderObject extends RenderProxyBox {
      @override
      bool hitTest(BoxHitTestResult result, {Offset position}) {
        final res = super.hitTest(result, position: position);
        result.path.forEach((item) {
          final target = item.target;
          if (target is RenderPointerListener) {
            target.onPointerSignal = null;
          }
        });
        return res;
      }
    }
    
    

    Here is demo on CodePen.