Search code examples
flutterdartflutter-layoutflutter-windows

Flutter Desktop: Disable scrolling when hover a Container


I have a particular case of scroll in my code, a certain container handle mouse wheel event in order to increment or decrement a counter. All these boxes are contained in column and in a singleChildScrollView.

The problem is when I use my mouse wheel under this special container, my counter increment but the SingleChildScrollView catch the event too...

Here the problem:

enter image description here

I can fix this using MouseRegion over my SpecialContainer and change the physics to NeverScrollableScrollPhysics in my SingleChildScrollView when I enter the MouseRegion. But it doesn't look very optimized because all children will be rebuild. In my original project, this case would result in unnecessary rebuild.

If you have a idiomatic solution like catch event or something else without rebuilding children, I'll be happy! :)

you can use this code to reproduce my problem:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Capture Scroll',
      theme: ThemeData(
        primarySwatch: Colors.indigo,
      ),
      home: const CaptureScrollWidget(title: 'Capture Scroll'),
    );
  }
}

class CaptureScrollWidget extends StatefulWidget {
  const CaptureScrollWidget({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<CaptureScrollWidget> createState() => _CaptureScrollWidgetState();
}

class _CaptureScrollWidgetState extends State<CaptureScrollWidget> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: SizedBox(
          height: 500,
          child: SingleChildScrollView(
            controller: ScrollController(),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                box(Colors.green),
                box(Colors.blue),
                specialBox(Colors.purple),
                box(Colors.red),
                box(Colors.yellow),
              ],
            ),
          ),
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

  Widget box(Color color) {
    return Container(width: 400, height: 200, color: color.withOpacity(0.4));
  }

  Widget specialBox(Color color) {
    return Listener(
      behavior: HitTestBehavior.opaque,
      onPointerSignal: (PointerSignalEvent event) {
        if (event is PointerScrollEvent) {
          setState(() {
            _counter += event.scrollDelta.dy.sign.round();
          });
        }
      },
      child: Container(
        width: 400,
        height: 200,
        color: color.withOpacity(0.4),
        child: Center(
            child: Text(
          _counter.toString(),
          style: const TextStyle(fontSize: 48),
        )),
      ),
    );
  }
}

Solution

  • You can use a bool on State class and MouseRegion to detect mouse position.

     bool isHovered = false;
    

    Provide scroll physics based on this bool.

    child: SingleChildScrollView(
      physics: isHovered ? NeverScrollableScrollPhysics() : null,
    
    MouseRegion(
      onEnter: (v) {
        isHovered = true;
      },
      onExit: (v) {
        isHovered = false;
      },
      child: specialBox(Colors.purple),
    ),
    

    Separating box class to widget.

    class CaptureScrollWidget extends StatefulWidget {
      const CaptureScrollWidget({Key? key, required this.title}) : super(key: key);
    
      final String title;
    
      @override
      State<CaptureScrollWidget> createState() => _CaptureScrollWidgetState();
    }
    
    class _CaptureScrollWidgetState extends State<CaptureScrollWidget> {
      int _counter = 0;
    
      bool isHovered = false;
      @override
      Widget build(BuildContext context) {
        debugPrint("rebuild the Scaffold");
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: SizedBox(
              height: 500,
              child: SingleChildScrollView(
                physics: isHovered ? NeverScrollableScrollPhysics() : null,
                controller: ScrollController(),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    const box(Colors.green),
                    const box(Colors.blue),
                    MouseRegion(
                      onEnter: (v) {
                        isHovered = true;
                      },
                      onExit: (v) {
                        isHovered = false;
                      },
                      child: specialBox(Colors.purple),
                    ),
                    const box(Colors.red),
                    const box(Colors.yellow),
                  ],
                ),
              ),
            ),
          ), // This trailing comma makes auto-formatting nicer for build methods.
        );
      }
    
      Widget specialBox(Color color) {
        debugPrint("build  specialBox");
        return Listener(
          behavior: HitTestBehavior.deferToChild,
          onPointerSignal: (PointerSignalEvent event) {
            if (event is PointerScrollEvent) {
              setState(() {
                _counter += event.scrollDelta.dy.sign.round();
              });
            }
          },
          child: Container(
            width: 400,
            height: 200,
            color: color.withOpacity(0.4),
            child: Center(
                child: Text(
              _counter.toString(),
              style: const TextStyle(fontSize: 48),
            )),
          ),
        );
      }
    }
    
    class box extends StatelessWidget {
      final Color color;
      const box(this.color, {super.key});
    
      @override
      Widget build(BuildContext context) {
        debugPrint("build  box");
        return Container(width: 400, height: 200, color: color.withOpacity(0.4));
      }
    }
    

    If you need more control not to rebuild the parent widget, use ValueNotifier

    class CaptureScrollWidget extends StatelessWidget {
      const CaptureScrollWidget({Key? key, required this.title}) : super(key: key);
    
      final String title;
    
      @override
      Widget build(BuildContext context) {
        debugPrint("rebuild the Scaffold");
    
        ValueNotifier<int> _counter = ValueNotifier(0);
    
        ValueNotifier<bool> isHovered = ValueNotifier(false);
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: Center(
            child: SizedBox(
              height: 500,
              child: ValueListenableBuilder<bool>(
                valueListenable: isHovered,
                builder: (context, value, child) => SingleChildScrollView(
                  physics: value ? const NeverScrollableScrollPhysics() : null,
                  controller: ScrollController(),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      const box(Colors.green),
                      const box(Colors.blue),
                      MouseRegion(
                        onEnter: (v) {
                          isHovered.value = true;
                        },
                        onExit: (v) {
                          isHovered.value = false;
                        },
                        child: Listener(
                          behavior: HitTestBehavior.deferToChild,
                          onPointerSignal: (PointerSignalEvent event) {
                            if (event is PointerScrollEvent) {
                              _counter.value += event.scrollDelta.dy.sign.round();
                            }
                          },
                          child: Container(
                            width: 400,
                            height: 200,
                            color: Colors.purple.withOpacity(0.4),
                            child: Center(
                                child: ValueListenableBuilder(
                              valueListenable: _counter,
                              builder: (context, value, child) => Text(
                                value.toString(),
                                style: const TextStyle(fontSize: 48),
                              ),
                            )),
                          ),
                        ),
                      ),
                      const box(Colors.red),
                      const box(Colors.yellow),
                    ],
                  ),
                ),
              ),
            ),
          ), // This trailing comma makes auto-formatting nicer for build methods.
        );
      }
    }
    
    class box extends StatelessWidget {
      final Color color;
      const box(this.color, {super.key});
    
      @override
      Widget build(BuildContext context) {
        debugPrint("build  box");
        return Container(width: 400, height: 200, color: color.withOpacity(0.4));
      }
    }