Search code examples
flutterflutter-navigationflutter-widget

Flutter: Detect rebuild of any widget which is not visible on screen but is in the widget tree


Summary:

As showing a page/route using the Navigator, a new branch is created from the nearest MaterialApp parent. Meaning both pages (Main & New) will be in memory and will rebuild if they are listening to the same ChangeNotifier.

I am having trouble finding out which widget is on-screen currently visible to the user. I need this to handle a scenario to skip performing asynchronous or long processes with some side effects, from a widget that might be in the widget tree but currently not visible.

Note: The sample code given here represents the basic architecture of the app I am currently working on, but reproduces the exact problem.

I am having this problem with a very different and complex widget tree that I have in my app, executing the doLongProcess() from a widget that is not visible on the screen. Also doLongProcess() changes some common property in my app which causes an issue, as any background widget can modify the details which are visible on the other widget.

I am looking for a solution to this issue, if there's any other way to achieve the goal except finding which widget is on the screen then please let me know that as well.

My final goal is to allow the long process to be executed from only the visible widget(s).

Please run the app once, to understand the following details properly.

Note 2: I have tried to use mounted property of the state to determine if it can be used or not but it shows true for both widgets (MainPage TextDisplay and NewPage TextDisplay)

Let me know in the comments if more details or I missed something which is required.


Use the following sample code with provider dependency included for reproducing the problem:

// add in pubspec.yaml:  provider: ^4.3.2+1

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    print('MainPage: build');
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TextDisplay(
              name: 'MainPage TextDisplay',
            ),
            SizedBox(
              height: 20,
            ),
            RaisedButton(
              child: Text('Open New Page'),
              onPressed: () => Navigator.of(context).push(MaterialPageRoute(
                builder: (context) => NewPage(),
              )),
            ),
          ],
        ),
      ),
    );
  }
}

class TextDisplay extends StatefulWidget {
  final String name;

  const TextDisplay({Key key, @required this.name}) : super(key: key);

  @override
  _TextDisplayState createState() => _TextDisplayState();
}

class _TextDisplayState extends State<TextDisplay> {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: ChangeNotifierProvider.value(
        value: dataHolder,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Center(child: Text(widget.name)),
            SizedBox(
              height: 20,
            ),
            Consumer<DataHolder>(
              builder: (context, holder, child) {
                // need to detect if this widget is on the screen,
                // only then we should go ahead with this long process
                // otherwise we should skip this long process
                doLongProcess(widget.name);

                return Text(holder.data);
              },
            ),
            RaisedButton(
              child: Text('Randomize'),
              onPressed: () => randomizeData(),
            ),
          ],
        ),
      ),
    );
  }

  void doLongProcess(String name) {
    print('$name: '
        'Doing a long process using the new data, isMounted: $mounted');
  }
}

class NewPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('NewPage: build');
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: true,
        title: Text('New Page'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TextDisplay(
              name: 'NewPage TextDisplay',
            ),
          ],
        ),
      ),
    );
  }
}

/////////////////// Data Holder Class and methods ///////////////////

class DataHolder extends ChangeNotifier {
  String _data;

  String get data => _data ?? 'Nothing to show, Yet!';

  setData(String newData) {
    print('\n new data found: $newData');
    _data = newData;
    notifyListeners();
  }
}

final dataHolder = DataHolder();

randomizeData() {
  int mills = DateTime.now().millisecondsSinceEpoch;
  dataHolder.setData(mills.toString());
}


Solution

  • Posting solution for others to refer.

    Refer to this flutter plugin/package: https://pub.dev/packages/visibility_detector

    The solution code:

    // add in pubspec.yaml:  provider: ^4.3.2+1
    
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import 'package:visibility_detector/visibility_detector.dart';
    
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
            visualDensity: VisualDensity.adaptivePlatformDensity,
          ),
          home: MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      MyHomePage({Key key, this.title}) : super(key: key);
    
      final String title;
    
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      @override
      Widget build(BuildContext context) {
        print('MainPage: build');
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                TextDisplay(
                  name: 'MainPage TextDisplay',
                ),
                SizedBox(
                  height: 20,
                ),
                RaisedButton(
                  child: Text('Open New Page'),
                  onPressed: () => Navigator.of(context).push(MaterialPageRoute(
                    builder: (context) => NewPage(),
                  )),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class TextDisplay extends StatefulWidget {
      final String name;
    
      const TextDisplay({Key key, @required this.name}) : super(key: key);
    
      @override
      _TextDisplayState createState() => _TextDisplayState();
    }
    
    class _TextDisplayState extends State<TextDisplay> {
      /// this holds the latest known status of the widget's visibility
      /// if [true] then the widget is fully visible, otherwise it is false.
      ///
      /// Note: it is also [false] if the widget is partially visible since we are
      /// only checking if the widget is fully visible or not
      bool _isVisible = true;
    
      @override
      Widget build(BuildContext context) {
        return Container(
          child: ChangeNotifierProvider.value(
            value: dataHolder,
    
            /// This is the widget which identifies if the widget is visible or not
            /// To my suprise this is an external plugin which is developed by Google devs 
            /// for the exact same purpose
            child: VisibilityDetector(
              key: ValueKey<String>(widget.name),
              onVisibilityChanged: (info) {
                // print('\n ------> Visibility info:'
                //     '\n name: ${widget.name}'
                //     '\n visibleBounds: ${info.visibleBounds}'
                //     '\n visibleFraction: ${info.visibleFraction}'
                //     '\n size: ${info.size}');
    
                /// We use this fraction value to determine if the TextDisplay widget is 
                /// fully visible or not
                /// range for fractional value is:  0 <= visibleFraction <= 1
                ///
                /// Meaning we can also use fractional values like, 0.25, 0.3 or 0.5 to 
                /// find if the widget is 25%, 30% or 50% visible on screen
                _isVisible = info.visibleFraction == 1;
              },
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Center(child: Text(widget.name)),
                  SizedBox(
                    height: 20,
                  ),
                  Consumer<DataHolder>(
                    builder: (context, holder, child) {
                      /// now that we have the status of the widget's visiblity
                      /// we can skip the long process when the widget is not visible.
                      if (_isVisible) {
                        doLongProcess(widget.name);
                      }
    
                      return Text(holder.data);
                    },
                  ),
                  RaisedButton(
                    child: Text('Randomize'),
                    onPressed: () => randomizeData(),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    
      void doLongProcess(String name) {
        print('\n  ============================ \n');
        print('$name: '
            'Doing a long process using the new data, isMounted: $mounted');
        final element = widget.createElement();
        print('\n name: ${widget.name}'
            '\n element: $element'
            '\n owner: ${element.state.context.owner}');
        print('\n  ============================ \n');
      }
    }
    
    class NewPage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        print('NewPage: build');
        return Scaffold(
          appBar: AppBar(
            automaticallyImplyLeading: true,
            title: Text('New Page'),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                TextDisplay(
                  name: 'NewPage TextDisplay',
                ),
              ],
            ),
          ),
        );
      }
    }
    
    /////////////////// Data Holder Class and methods ///////////////////
    
    class DataHolder extends ChangeNotifier {
      String _data;
    
      String get data => _data ?? 'Nothing to show, Yet!';
    
      setData(String newData) {
        print('\n new data found: $newData');
        _data = newData;
        notifyListeners();
      }
    }
    
    final dataHolder = DataHolder();
    
    randomizeData() {
      int mills = DateTime.now().millisecondsSinceEpoch;
      dataHolder.setData(mills.toString());
    }