visibility_detector 0.1.5

  • Readme
  • Changelog
  • Example
  • Installing
  • 97

VisibilityDetector #

A VisibilityDetector widget wraps an existing Flutter widget and fires a callback when the widget's visibility changes. (It actually reports when the visibility of the VisibilityDetector itself changes, and its visibility is expected to be identical to that of its child.)

Callbacks are not fired immediately on visibility changes. Instead, callbacks are deferred and coalesced such that the callback for each VisibilityDetector will be invoked at most once per VisibilityDetectorController.updateInterval (unless forced by VisibilityDetectorController.notifyNow()). Callbacks for all VisibilityDetector widgets are fired together synchronously between frames.

VisibilityDetectorController.notifyNow() may be used to force triggering pending visibility callbacks; this might be desirable just prior to tearing down the widget tree (such as when switching views or when exiting the application).

For more details, see the documentation to the VisibilityDetector, VisibilityInfo, and VisibilityDetectorController classes.

Example usage #

@override
Widget build(BuildContext context) {
  return VisibilityDetector(
    key: Key('my-widget-key'),
    onVisibilityChanged: (visibilityInfo) {
      var visiblePercentage = visibilityInfo.visibleFraction * 100;
      debugPrint(
          'Widget ${visibilityInfo.key} is ${visiblePercentage}% visible');
    },
    child: someOtherWidget,
  );
}

See the example/ directory for a sample application. To build it, first create the default Flutter project files:

cd example
flutter create .

and then it can be run with flutter run.

Widget tests #

Widget tests that use VisibilityDetectors usually should set:

VisibilityDetectorController.instance.updateInterval = Duration.zero;

This will have two effects:

  1. Visibility changes will be reported immediately, which can be less surprising for automated tests.

  2. It avoids the following assertion when tearing down the widget tree:

    The following assertion was thrown running a test:
    A Timer is still pending even after the widget tree was disposed.

    See https://github.com/flutter/flutter/issues/24166 for details.

If setting updateInterval = Duration.zero is undesirable, to address each of the corresponding issues above, tests alternatively can:

  1. Wait sufficiently long for callbacks to fire:

    await tester.pump(VisibilityDetectorController.instance.updateInterval);
    
  2. Avoid the "Timer is still pending..." assertion by explicitly destroying the widget tree before the test completes:

    await tester.pumpWidget(Placeholder());
    

See test/widget_test.dart for examples.

Known limitations #

  • VisibilityDetector considers only its bounding box. It does not take widget opacity into account.

  • The reported visibleFraction might not account for overlapping widgets that obscure the VisbilityDetector.

0.1.5 #

  • Compatibility fixes to demo.dart for Flutter 1.13.8.

  • Moved demo.dart to an examples/ directory, renamed it, and added instructions to README.md.

  • Adjusted tests to use TestWindow instead of RenderView.

  • Added a "Known limitations" section to README.md.

0.1.4 #

  • Style and comment adjustments.

  • Fix a potential infinite loop in the demo app and add tests for it.

0.1.3 #

  • Fixed positioning of text selection handles for EditableText-based widgets (e.g. TextField, CupertinoTextField) when used within a VisibilityDetector.

  • Added VisibilityDetectorController.widgetBoundsFor.

0.1.2 #

  • Compatibility fixes for Flutter 1.3.0.

0.1.1 #

  • Added VisibilityDetectorController.forget.

example/lib/main.dart

// Copyright 2018 the Dart project authors.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd

import 'dart:collection';

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

const String title = 'VisibilityDetector Demo';

/// The width of each cell of our pseudo-table of [VisibilityDetector] widgets.
const double cellWidth = 125;

//// The height of each cell of our pseudo-table.
const double cellHeight = _rowHeight - 2 * _rowPadding;

/// The height of each row of the pseudo-table.  This includes [_rowPadding] on
/// top and bottom.
const double _rowHeight = 75;

/// The external padding around each row of the pseudo-table.
const double _rowPadding = 5;

/// The internal padding for each cell of the pseudo-table.
const double _cellPadding = 10;

/// The external padding around the widgets in the visibility report section.
const double _reportPadding = 5;

/// The height of the visibility report.
const double _reportHeight = 200;

/// The [Key] to the main [ListView] widget.
const mainListKey = Key('MainList');

/// Returns the [Key] to the [VisibilityDetector] widget in each cell of the
/// pseudo-table.
Key cellKey(int row, int col) => Key('Cell-$row-$col');

/// A callback to be invoked by the [VisibilityDetector.onVisibilityChanged]
/// callback.  We use the extra level of indirection to allow widget tests to
/// reuse this demo app with a different callback.
final visibilityListeners =
    <void Function(RowColumn rc, VisibilityInfo info)>[];

void main() => runApp(const VisibilityDetectorDemo());

/// The root widget for the demo app.
class VisibilityDetectorDemo extends StatelessWidget {
  const VisibilityDetectorDemo({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: title,
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const VisibilityDetectorDemoPage(),
    );
  }
}

/// The main page [VisibilityDetectorDemo].
class VisibilityDetectorDemoPage extends StatefulWidget {
  const VisibilityDetectorDemoPage({Key key}) : super(key: key);

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

class VisibilityDetectorDemoPageState
    extends State<VisibilityDetectorDemoPage> {
  /// Whether the pseudo-table should be shown.
  bool _tableShown = true;

  /// Toggles the visibility of the pseudo-table of [VisibilityDetector]
  /// widgets.
  void _toggleTable() {
    setState(() {
      _tableShown = !_tableShown;
    });
  }

  @override
  Widget build(BuildContext context) {
    // Our pseudo-table of [VisibilityDetector] widgets.  We want to scroll both
    // vertically and horizontally, so we'll implement it as a [ListView] of
    // [ListView]s.
    final table = !_tableShown
        ? null
        : ListView.builder(
            key: mainListKey,
            scrollDirection: Axis.vertical,
            itemExtent: _rowHeight,
            itemBuilder: (BuildContext context, int rowIndex) {
              return DemoPageRow(rowIndex: rowIndex);
            },
          );

    return Scaffold(
      appBar: AppBar(title: const Text(title)),
      floatingActionButton: FloatingActionButton(
        shape: const Border(),
        onPressed: _toggleTable,
        child: _tableShown ? const Text('Hide') : const Text('Show'),
      ),
      body: Column(
        children: <Widget>[
          _tableShown ? Expanded(child: table) : const Spacer(),
          const VisibilityReport(title: 'Visibility'),
        ],
      ),
    );
  }
}

/// An individual row for the pseudo-table of [VisibilityDetector] widgets.
class DemoPageRow extends StatelessWidget {
  const DemoPageRow({Key key, this.rowIndex}) : super(key: key);

  final int rowIndex;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      scrollDirection: Axis.horizontal,
      padding: const EdgeInsets.all(_rowPadding),
      itemBuilder: (BuildContext context, int columnIndex) {
        return DemoPageCell(rowIndex: rowIndex, columnIndex: columnIndex);
      },
    );
  }
}

/// An individual cell for the pseudo-table of [VisibilityDetector] widgets.
class DemoPageCell extends StatelessWidget {
  DemoPageCell({Key key, this.rowIndex, this.columnIndex})
      : _cellName = 'Item $rowIndex-$columnIndex',
        _backgroundColor = ((rowIndex + columnIndex) % 2 == 0)
            ? Colors.pink[200]
            : Colors.yellow[200],
        super(key: key);

  final int rowIndex;
  final int columnIndex;

  /// The text to show for the cell.
  final String _cellName;

  final Color _backgroundColor;

  /// [VisibilityDetector] callback for when the visibility of the widget
  /// changes.  Triggers the [visibilityListeners] callbacks.
  void _handleVisibilityChanged(VisibilityInfo info) {
    for (final listener in visibilityListeners) {
      listener(RowColumn(rowIndex, columnIndex), info);
    }
  }

  @override
  Widget build(BuildContext context) {
    return VisibilityDetector(
      key: cellKey(rowIndex, columnIndex),
      onVisibilityChanged: _handleVisibilityChanged,
      child: Container(
        width: cellWidth,
        decoration: BoxDecoration(color: _backgroundColor),
        padding: const EdgeInsets.all(_cellPadding),
        alignment: Alignment.center,
        child: FittedBox(
          fit: BoxFit.scaleDown,
          child: Text(_cellName, style: Theme.of(context).textTheme.headline4),
        ),
      ),
    );
  }
}

/// A widget that lists the reported visibility percentages of the
/// [VisibilityDetector] widgets on the page.
class VisibilityReport extends StatelessWidget {
  const VisibilityReport({Key key, this.title}) : super(key: key);

  /// The text to use for the heading of the report.
  final String title;

  @override
  Widget build(BuildContext context) {
    final headingTextStyle =
        Theme.of(context).textTheme.headline6.copyWith(color: Colors.white);

    final heading = Container(
      padding: const EdgeInsets.all(_reportPadding),
      alignment: Alignment.centerLeft,
      decoration: const BoxDecoration(color: Colors.black),
      child: Text(title, style: headingTextStyle),
    );

    final grid = Container(
      padding: const EdgeInsets.all(_reportPadding),
      decoration: BoxDecoration(color: Colors.grey[300]),
      child: const SizedBox(
        height: _reportHeight,
        child: VisibilityReportGrid(),
      ),
    );

    return Column(children: <Widget>[heading, grid]);
  }
}

/// The portion of [VisibilityReport] that shows data.
class VisibilityReportGrid extends StatefulWidget {
  const VisibilityReportGrid({Key key}) : super(key: key);

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

class VisibilityReportGridState extends State<VisibilityReportGrid> {
  /// Maps [row, column] indices to the visibility percentage of the
  /// corresponding [VisibilityDetector] widget.
  final _visibilities = SplayTreeMap<RowColumn, double>();

  /// The [Text] widgets used to fill our [GridView].
  List<Text> _reportItems;

  /// See [State.initState].  Adds a callback to [visibilityListeners] to update
  /// the visibility report with the widget's visibility.
  @override
  void initState() {
    super.initState();

    visibilityListeners.add(_update);
    assert(visibilityListeners.contains(_update));
  }

  @override
  void dispose() {
    visibilityListeners.remove(_update);

    super.dispose();
  }

  /// Callback added to [visibilityListeners] to update the state.
  void _update(RowColumn rc, VisibilityInfo info) {
    setState(() {
      if (info.visibleFraction == 0) {
        _visibilities.remove(rc);
      } else {
        _visibilities[rc] = info.visibleFraction;
      }

      // Invalidate `_reportItems` so that we regenerate it lazily.
      _reportItems = null;
    });
  }

  /// Populates [_reportItems].
  List<Text> _generateReportItems() {
    final entries = _visibilities.entries;
    final items = <Text>[];

    for (final i in entries) {
      final visiblePercentage = (i.value * 100).toStringAsFixed(1);
      items.add(Text('${i.key}: $visiblePercentage%'));
    }

    // It's easier to read cells down than across, so sort by columns instead of
    // by rows.
    final tailIndex = items.length - items.length ~/ 3;
    final midIndex = tailIndex - tailIndex ~/ 2;
    final head = items.getRange(0, midIndex);
    final mid = items.getRange(midIndex, tailIndex);
    final tail = items.getRange(tailIndex, items.length);
    return collate([head, mid, tail]).toList(growable: false);
  }

  @override
  Widget build(BuildContext context) {
    _reportItems ??= _generateReportItems();

    return GridView.count(
      crossAxisCount: 3,
      childAspectRatio: 8,
      padding: const EdgeInsets.all(5),
      children: _reportItems,
    );
  }
}

/// A class for storing a [row, column] pair.
class RowColumn extends Comparable<RowColumn> {
  RowColumn(this.row, this.column);

  final int row;
  final int column;

  @override
  bool operator ==(dynamic other) {
    if (other is RowColumn) {
      return row == other.row && column == other.column;
    }
    return false;
  }

  @override
  int get hashCode => hashValues(row, column);

  /// See [Comparable.compareTo].  Sorts [RowColumn] objects in row-major order.
  @override
  int compareTo(RowColumn other) {
    if (row < other.row) {
      return -1;
    } else if (row > other.row) {
      return 1;
    }

    if (column < other.column) {
      return -1;
    } else if (column > other.column) {
      return 1;
    }

    return 0;
  }

  @override
  String toString() {
    return '[$row, $column]';
  }
}

/// Returns an [Iterable] containing the nth element (if it exists) of every
/// [Iterable] in `iterables` in sequence.
///
/// Unlike [zip](https://pub.dev/documentation/quiver/latest/quiver.iterables/zip.html),
/// returns a single sequence and continues until *all* [Iterable]s are
/// exhausted.
///
/// For example, `collate([[1, 4, 7], [2, 5, 8, 9], [3, 6]])` would return a
/// sequence `1, 2, 3, 4, 5, 6, 7, 8, 9`.
@visibleForTesting
Iterable<T> collate<T>(Iterable<Iterable<T>> iterables) sync* {
  assert(iterables != null);

  final iterators = [for (final iterable in iterables) iterable.iterator];
  if (iterators.isEmpty) {
    return;
  }

  // ignore: literal_only_boolean_expressions, https://github.com/dart-lang/linter/issues/453
  while (true) {
    var exhaustedCount = 0;
    for (final i in iterators) {
      if (i.moveNext()) {
        yield i.current;
        continue;
      }

      exhaustedCount += 1;
      if (exhaustedCount == iterators.length) {
        // All iterators are at their ends.
        return;
      }
    }
  }
}

Use this package as a library

1. Depend on it

Add this to your package's pubspec.yaml file:


dependencies:
  visibility_detector: ^0.1.5

2. Install it

You can install packages from the command line:

with Flutter:


$ flutter pub get

Alternatively, your editor might support flutter pub get. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:


import 'package:visibility_detector/visibility_detector.dart';
  
Popularity:
Describes how popular the package is relative to other packages. [more]
94
Health:
Code health derived from static analysis. [more]
100
Maintenance:
Reflects how tidy and up-to-date the package is. [more]
100
Overall:
Weighted score of the above. [more]
97
Learn more about scoring.

We analyzed this package on May 24, 2020, and provided a score, details, and suggestions below. Analysis was completed with status completed using:

  • Dart: 2.8.1
  • pana: 0.13.8-dev
  • Flutter: 1.17.0

Dependencies

Package Constraint Resolved Available
Direct dependencies
Dart SDK >=2.3.0 <3.0.0
flutter 0.0.0
Transitive dependencies
collection 1.14.12
meta 1.1.8
sky_engine 0.0.99
typed_data 1.1.6
vector_math 2.0.8
Dev dependencies
flutter_test
visibility_detector_example