How to know if a widget is visible within a viewport?

With the rephrased question, I have a clearer understanding about what you're trying to do. You have a list of widgets, and want to decide whether to show a floating action button based on whether those widgets are currently being shown in the viewport.

I've written a basic example which shows this in action. I'll describe the various elements below, but please be aware that:

  1. It uses a GlobalKey which tends not to be overly efficient
  2. It runs continuously and does some non-optimal calculations each and every frame during scroll.

Therefore, it might cause your app to slow down. I'll leave it to someone else to optimize or write a better answer that uses a better knowledge of the render tree to do this same thing.

Anyways, here's the code. I'll first give you the relatively more naive way of doing it - using setState on a variable directly, as it's simpler:

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

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

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => new MyAppState();
}

class MyAppState extends State<MyApp> {
  GlobalKey<State> key = new GlobalKey();

  double fabOpacity = 1.0;

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Scrolling."),
        ),
        body: NotificationListener<ScrollNotification>(
          child: new ListView(
            itemExtent: 100.0,
            children: [
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              new MyObservableWidget(key: key),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder()
            ],
          ),
          onNotification: (ScrollNotification scroll) {
            var currentContext = key.currentContext;
            if (currentContext == null) return false;

            var renderObject = currentContext.findRenderObject();
            RenderAbstractViewport viewport = RenderAbstractViewport.of(renderObject);
            var offsetToRevealBottom = viewport.getOffsetToReveal(renderObject, 1.0);
            var offsetToRevealTop = viewport.getOffsetToReveal(renderObject, 0.0);

            if (offsetToRevealBottom.offset > scroll.metrics.pixels ||
                scroll.metrics.pixels > offsetToRevealTop.offset) {
              if (fabOpacity != 0.0) {
                setState(() {
                  fabOpacity = 0.0;
                });
              }
            } else {
              if (fabOpacity == 0.0) {
                setState(() {
                  fabOpacity = 1.0;
                });
              }
            }
            return false;
          },
        ),
        floatingActionButton: new Opacity(
          opacity: fabOpacity,
          child: new FloatingActionButton(
            onPressed: () {
              print("YAY");
            },
          ),
        ),
      ),
    );
  }
}

class MyObservableWidget extends StatefulWidget {
  const MyObservableWidget({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => new MyObservableWidgetState();
}

class MyObservableWidgetState extends State<MyObservableWidget> {
  @override
  Widget build(BuildContext context) {
    return new Container(height: 100.0, color: Colors.green);
  }
}

class ContainerWithBorder extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Container(
      decoration: new BoxDecoration(border: new Border.all(), color: Colors.grey),
    );
  }
}

There's a few easily fixable issues with this - it doesn't hide the button but just makes it transparent, it renders the entire widget each time, and it does the calculations for the position of the widget each frame.

This is a more optimized version, where it doesn't do the calculations if it doesn't need to. You might need to add more logic to it if your list ever changes (or you could just do the calculations each time and if the performance is good enough not worry about it). Note how it uses an animationController and AnimatedBuilder to make sure that only the relevant part builds each time. You could also get rid of the fading in/fading out by simply setting the animationController's value directly and doing the opacity calculation yourself (i.e. you may want it to get opaque as it starts scrolling into view, which would have to take into account the height of your object):

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

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

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => new MyAppState();
}

class MyAppState extends State<MyApp> with TickerProviderStateMixin<MyApp> {
  GlobalKey<State> key = new GlobalKey();

  bool fabShowing = false;

  // non-state-managed variables
  AnimationController _controller;
  RenderObject _prevRenderObject;
  double _offsetToRevealBottom = double.infinity;
  double _offsetToRevealTop = double.negativeInfinity;

  @override
  void initState() {
    super.initState();
    _controller = new AnimationController(vsync: this, duration: Duration(milliseconds: 300));
    _controller.addStatusListener((val) {
      if (val == AnimationStatus.dismissed) {
        setState(() => fabShowing = false);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Scrolling."),
        ),
        body: NotificationListener<ScrollNotification>(
          child: new ListView(
            itemExtent: 100.0,
            children: [
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              new MyObservableWidget(key: key),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder(),
              ContainerWithBorder()
            ],
          ),
          onNotification: (ScrollNotification scroll) {
            var currentContext = key.currentContext;
            if (currentContext == null) return false;

            var renderObject = currentContext.findRenderObject();

            if (renderObject != _prevRenderObject) {
              RenderAbstractViewport viewport = RenderAbstractViewport.of(renderObject);
              _offsetToRevealBottom = viewport.getOffsetToReveal(renderObject, 1.0).offset;
              _offsetToRevealTop = viewport.getOffsetToReveal(renderObject, 0.0).offset;
            }

            final offset = scroll.metrics.pixels;

            if (_offsetToRevealBottom < offset && offset < _offsetToRevealTop) {
              if (!fabShowing) setState(() => fabShowing = true);

              if (_controller.status != AnimationStatus.forward) {
                _controller.forward();
              }
            } else {
              if (_controller.status != AnimationStatus.reverse) {
                _controller.reverse();
              }
            }
            return false;
          },
        ),
        floatingActionButton: fabShowing
            ? new AnimatedBuilder(
                child: new FloatingActionButton(
                  onPressed: () {
                    print("YAY");
                  },
                ),
                builder: (BuildContext context, Widget child) => Opacity(opacity: _controller.value, child: child),
                animation: this._controller,
              )
            : null,
      ),
    );
  }
}

class MyObservableWidget extends StatefulWidget {
  const MyObservableWidget({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => new MyObservableWidgetState();
}

class MyObservableWidgetState extends State<MyObservableWidget> {
  @override
  Widget build(BuildContext context) {
    return new Container(height: 100.0, color: Colors.green);
  }
}

class ContainerWithBorder extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Container(
      decoration: new BoxDecoration(border: new Border.all(), color: Colors.grey),
    );
  }
}

https://pub.dev/packages/visibility_detector provides this functionality with its VisibilityDetector widget that can wrap any other Widget and notify when the visible area of the widget changed:

VisibilityDetector(
   key: Key("unique key"),
   onVisibilityChanged: (VisibilityInfo info) {
       debugPrint("${info.visibleFraction} of my widget is visible");
   },
   child: MyWidgetToTrack());
)

Here is the simplest approach, only three lines of code!

I don't guarantee it will work, but it is worth a try.

Precondition: You have to have the Key of the widget you're checking the visibility for.

final RenderObject? box = _widgetKey.currentContext?.findRenderObject(); //     !
if (box != null) {
  final double yPosition = (box as RenderBox).localToGlobal(Offset.zero).dy; // !
  print('Widget is visible in the viewport at position: $yPosition');
  // do stuff...
}
else {
  print('Widget is not visible.');
  // do stuff...
}

Tags:

Flutter