Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[visibility_detector] Adding SliverVisibilityDetector #174

Closed
jamesblasco opened this issue Sep 17, 2020 · 3 comments
Closed

[visibility_detector] Adding SliverVisibilityDetector #174

jamesblasco opened this issue Sep 17, 2020 · 3 comments
Labels
p: visibility_detector Related to package:visibility_detector

Comments

@jamesblasco
Copy link

jamesblasco commented Sep 17, 2020

VisibilityDetector uses RenderProxy, and can not be used to detect when a sliver appears/dissapears.
It would be amazing to have a SliverVisibilityDetector.

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:visibility_detector/src/visibility_detector_layer.dart';

/// A [SliverVisibilityDetector] widget fires a specified callback when the sliver
/// widget changes visibility.
///
/// 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.
class SliverVisibilityDetector extends SingleChildRenderObjectWidget {
  /// Constructor.
  ///
  /// `key` is required to properly identify this widget; it must be unique
  /// among all [SliverVisibilityDetector] widgets.
  ///
  /// `child` must not be null.
  ///
  /// `onVisibilityChanged` may be null to disable this [VisibilityDetector].
  const SliverVisibilityDetector({
    @required Key key,
    @required Widget sliver,
    @required this.onVisibilityChanged,
  })  : assert(key != null),
        assert(sliver != null),
        super(key: key, child: sliver);

  /// The callback to invoke when this widget's visibility changes.
  final VisibilityChangedCallback onVisibilityChanged;

  /// See [RenderObjectWidget.createRenderObject].
  @override
  RenderSliverVisibilityDetector createRenderObject(BuildContext context) {
    return RenderSliverVisibilityDetector(
      key: key,
      onVisibilityChanged: onVisibilityChanged,
    );
  }

  /// See [RenderObjectWidget.updateRenderObject].
  @override
  void updateRenderObject(
      BuildContext context, RenderSliverVisibilityDetector renderObject) {
    assert(renderObject.key == key);
    renderObject.onVisibilityChanged = onVisibilityChanged;
  }
}

/// The [RenderSliver] corresponding to the [VisibilityDetector] widget.
///
/// [RenderVisibilityDetector] is a bridge between [VisibilityDetector] and
/// [VisibilityDetectorLayer].
class RenderSliverVisibilityDetector extends RenderProxySliver {
  /// Constructor.  See the corresponding properties for parameter details.
  RenderSliverVisibilityDetector({
    RenderSliver child,
    @required this.key,
    @required VisibilityChangedCallback onVisibilityChanged,
  })  : assert(key != null),
        _onVisibilityChanged = onVisibilityChanged,
        super(child);

  /// The key for the corresponding [VisibilityDetector] widget.  Never null.
  final Key key;

  VisibilityChangedCallback _onVisibilityChanged;

  /// See [VisibilityDetector.onVisibilityChanged].
  VisibilityChangedCallback get onVisibilityChanged => _onVisibilityChanged;

  /// Used by [VisibilityDetector.updateRenderObject].
  set onVisibilityChanged(VisibilityChangedCallback value) {
    _onVisibilityChanged = value;
    markNeedsCompositingBitsUpdate();
    markNeedsPaint();
  }

  // See [RenderObject.alwaysNeedsCompositing].
  @override
  bool get alwaysNeedsCompositing => onVisibilityChanged != null;

  /// See [RenderObject.paint].
  @override
  void paint(PaintingContext context, Offset offset) {
    if (onVisibilityChanged == null) {
      // No need to create a [VisibilityDetectorLayer].  However, in case one
      // already exists, remove all cached data for it so that we won't fire
      // visibility callbacks when the layer is removed.
      VisibilityDetectorLayer.forget(key);
      super.paint(context, offset);
      return;
    }
   
    final layer = VisibilityDetectorLayer(
        key: key,
        widgetSize: getWidgetSize(),
        paintOffset: offset,
        onVisibilityChanged: onVisibilityChanged);
    context.pushLayer(layer, super.paint, offset);
  }

  @protected
  Size getWidgetSize() {
    assert(geometry != null);
    assert(!debugNeedsLayout);
    switch (constraints.axisDirection) {
      case AxisDirection.up:
      case AxisDirection.down:
        return Size(constraints.crossAxisExtent, geometry.scrollExtent);
      case AxisDirection.right:
      case AxisDirection.left:
        return Size(geometry.scrollExtent, constraints.crossAxisExtent);
    }
    return null;
  }
}

The only important change for a SliverVisibilityDetector would be to use RenderSliverProxy and calculate the widgetSize with the geometry.scrollExtent. Right now RenderVisibilityDetector uses semanticBounds, that in RenderSliver uses geometry.paintExtent and doesn't correspond with the full size of the widget and only the part that is being painted.

Let me know if you would be interested to implement this or accept a PR. Otherwise I will make a package so it can be available to other developers

Full Example with SliverVisibilityDetector
// 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';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:visibility_detector/src/visibility_detector_layer.dart';


/// A [SliverVisibilityDetector] widget fires a specified callback when the sliver
/// widget changes visibility.
///
/// 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.
class SliverVisibilityDetector extends SingleChildRenderObjectWidget {
 /// Constructor.
 ///
 /// `key` is required to properly identify this widget; it must be unique
 /// among all [SliverVisibilityDetector] widgets.
 ///
 /// `child` must not be null.
 ///
 /// `onVisibilityChanged` may be null to disable this [VisibilityDetector].
 const SliverVisibilityDetector({
   @required Key key,
   @required Widget sliver,
   @required this.onVisibilityChanged,
 })  : assert(key != null),
       assert(sliver != null),
       super(key: key, child: sliver);

 /// The callback to invoke when this widget's visibility changes.
 final VisibilityChangedCallback onVisibilityChanged;

 /// See [RenderObjectWidget.createRenderObject].
 @override
 RenderSliverVisibilityDetector createRenderObject(BuildContext context) {
   return RenderSliverVisibilityDetector(
     key: key,
     onVisibilityChanged: onVisibilityChanged,
   );
 }

 /// See [RenderObjectWidget.updateRenderObject].
 @override
 void updateRenderObject(
     BuildContext context, RenderSliverVisibilityDetector renderObject) {
   assert(renderObject.key == key);
   renderObject.onVisibilityChanged = onVisibilityChanged;
 }
}

/// The [RenderSliver] corresponding to the [VisibilityDetector] widget.
///
/// [RenderVisibilityDetector] is a bridge between [VisibilityDetector] and
/// [VisibilityDetectorLayer].
class RenderSliverVisibilityDetector extends RenderProxySliver {
 /// Constructor.  See the corresponding properties for parameter details.
 RenderSliverVisibilityDetector({
   RenderSliver child,
   @required this.key,
   @required VisibilityChangedCallback onVisibilityChanged,
 })  : assert(key != null),
       _onVisibilityChanged = onVisibilityChanged,
       super(child);

 /// The key for the corresponding [VisibilityDetector] widget.  Never null.
 final Key key;

 VisibilityChangedCallback _onVisibilityChanged;

 /// See [VisibilityDetector.onVisibilityChanged].
 VisibilityChangedCallback get onVisibilityChanged => _onVisibilityChanged;

 /// Used by [VisibilityDetector.updateRenderObject].
 set onVisibilityChanged(VisibilityChangedCallback value) {
   _onVisibilityChanged = value;
   markNeedsCompositingBitsUpdate();
   markNeedsPaint();
 }

 // See [RenderObject.alwaysNeedsCompositing].
 @override
 bool get alwaysNeedsCompositing => onVisibilityChanged != null;

 /// See [RenderObject.paint].
 @override
 void paint(PaintingContext context, Offset offset) {
   if (onVisibilityChanged == null) {
     // No need to create a [VisibilityDetectorLayer].  However, in case one
     // already exists, remove all cached data for it so that we won't fire
     // visibility callbacks when the layer is removed.
     VisibilityDetectorLayer.forget(key);
     super.paint(context, offset);
     return;
   }
  
   final layer = VisibilityDetectorLayer(
       key: key,
       widgetSize: getWidgetSize(),
       paintOffset: offset,
       onVisibilityChanged: onVisibilityChanged);
   context.pushLayer(layer, super.paint, offset);
 }

 @protected
 Size getWidgetSize() {
   assert(geometry != null);
   assert(!debugNeedsLayout);
   switch (constraints.axisDirection) {
     case AxisDirection.up:
     case AxisDirection.down:
       return Size(constraints.crossAxisExtent, geometry.scrollExtent);
     case AxisDirection.right:
     case AxisDirection.left:
       return Size(geometry.scrollExtent, constraints.crossAxisExtent);
   }
   return null;
 }
}


/// EXAMPLE

const String title = 'VisibilityDetector Demo';


/// 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) => Key('Cell-$row');

/// 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(int row, 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
       : CustomScrollView(
           key: mainListKey,
           slivers: [
             ...List.generate(
                 100,
                 (rowIndex) => DemoPageCell(
                       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 cell for the pseudo-table of [VisibilityDetector] widgets.
class DemoPageCell extends StatelessWidget {
 DemoPageCell({Key key, this.rowIndex})
     : _cellName = 'Item $rowIndex',
       _backgroundColor = ((rowIndex) % 2 == 0)
           ? Colors.pink[200]
           : Colors.yellow[200],
       super(key: key);

 final int rowIndex;

 /// 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(rowIndex, info);
   }
 }

 @override
 Widget build(BuildContext context) {
   return SliverVisibilityDetector(
     key: cellKey(rowIndex),
     onVisibilityChanged: _handleVisibilityChanged,
     sliver: SliverToBoxAdapter(
       child: Container(
         height: _rowHeight,
         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<int, 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(int row, VisibilityInfo info) {
   setState(() {
     if (info.visibleFraction == 0) {
       _visibilities.remove(row);
     } else {
       print(info.size);
       _visibilities[row] = 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,
   );
 }
}

/// 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;
     }
   }
 }
}

@jamesderlin jamesderlin added the p: visibility_detector Related to package:visibility_detector label Sep 18, 2020
@jamesderlin
Copy link
Collaborator

We can accept PRs if you've signed the CLA.

I'm not very familiar with slivers, so to be clear: is the issue about using, for example, a SliverList as the child of a VisibilityDetector, where the VisibilityDetector would report the visibility of the SliverList itself (which likely always would be 100% visible) and not of the content within the SliverList?

@jamesblasco
Copy link
Author

The idea was to report the content of the sliver. It might not look useful for a SliverList, but there are many more sliver with smaller size. My code was not working as expected when it reached the bottom boundary. I decided to use something different, so I won't be working on this more.

@jamesderlin
Copy link
Collaborator

I feel like for the case of slivers, either the SliverList/SliverGrid/etc. should already know how much of its content is visible, or child widgets within them should be using VisibilityDetector instead. (Again, I'm not super familiar with slivers, so I might be totally wrong about use cases.)

Anyway, if you later decide that you want this after all with a clear and compelling use case, feel free to create a pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
p: visibility_detector Related to package:visibility_detector
Projects
None yet
Development

No branches or pull requests

2 participants