Get index of the SliverList child when it's scrolled out using ScrollNotification

Viewed 902

I have this application that has a sliverlist and a sliverAppbar. I need to get the current scroll position of each item in the sliverlist onscroll and determine if it has crossed the sliverAppbar and update the sliverappbar with the Title of the item. Say starting from Item1 once it crosses the SliverAppBar that means I am viewing Item1 content and when Item2 crosses the SliverAppBar, update the SliverAppBar with the title Item2 to mean the user is viewing Item2 content

I am trying to implement this using a NotificationListener<ScrollEndNotification> but I am stuck at the second NotificationListener that is supposed to emit notifications to the top of the parent at this line ScrollEndNotification(metrics: , context:context).dispatch(context); it throws an error that I should provide a metrics parameter which I don't know what to provide.

            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  ScrollEndNotification(metrics: , context:context).dispatch(context);
                  return  AutoScrollTag(
                        key: ValueKey(index),
                        controller: controller,
                        index: index,
                        child: Padding(
                     padding: const EdgeInsets.only(
                      top: 30.0, left: 20.0, right: 20.0),
                      child: Container(
                       color: Colors.red,
                     //height: 120.0
                     //height: A varying height

                        ),),},); ),

The complete code is

           Widget build(BuildContext context) {
           return Scaffold(
           backgroundColor: Colors.grey[100],
           body: NotificationListener<ScrollEndNotification>(
                    onNotification: (notification) {
                      if (notification is ScrollEndNotification) {
                     ///Here I need to know what widget index bubbled the notification, its position 
                     ///on the screen and its index 
                     //in the list, in order to do further implementation like update the 
                      //SliverAppBar
                      print('$notification');          
                      return true;
                    },
              child: CustomScrollView(
                 controller: controller
             slivers: <Widget>[
              SliverAppBar(
            title: Text(title),
             ), 
         SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  ScrollEndNotification(metrics: , context:context).dispatch(context);
                  return  AutoScrollTag(
                        key: ValueKey(index),
                        controller: controller,
                        index: index,
                        child: Padding(
                     padding: const EdgeInsets.only(
                      top: 30.0, left: 20.0, right: 20.0),
                      child: Container(
                       color: Colors.red,
                     //height: 120.0
                     //height: A varying height
                        ),),},); ),

Also if you have a better implementation on how I can achieve this do help me out. In short, I need to keep track when an item is scrolled off the screen and get the say index of it in the Sliverlist. Keeping in mind that the item has a variable size container that expands according to the number of children in it. It is a common UX pattern in ecommerce apps. Eg Viewing menus as the user scrolls down and updating what menu the user is viewing as the title crosses the screen.

Providing a link to the gist so you get an idea of the complete implementation

2 Answers

I implemented what you want for various height children.
To make a various child's height, give a height randomly.

  1. Get each index children's height and save distance from top.
  2. Add a scroll listener for get current scroll position
  3. Get just hidden child index with n'th child's distance and current position
  4. Change the title with just hidden child index

enter image description here

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:scroll_to_index/scroll_to_index.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> {
  String title = 'Viewing item 0';
  Random random = Random();

  Map<int, double> itemHeight = {};
  int currentHideIndex = 0;

  final _mainScrollController = ScrollController();
  @override
  void initState() {
    super.initState();
    _mainScrollController.addListener(_onMainScroll);
  }

  void _onMainScroll() {
    int justHiddenIndex =
        findIndexJustHidden(_mainScrollController.position.pixels);
    print('justHiddenIndex: $justHiddenIndex');
    if (currentHideIndex != justHiddenIndex) {
      setState(() {
        title = 'Viewing item ${justHiddenIndex + 1}';
      });
      currentHideIndex = justHiddenIndex;
    }
  }

  int findIndexJustHidden(currentPosition) {
    int index = -1;
    for (var item in itemHeight.entries) {
      if (currentPosition > item.value) {
        index = item.key;
      } else {
        if (index != 0) {
          return index;
        }
      }
    }
    return index;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[100],
      body: CustomScrollView(
        controller: _mainScrollController,
        slivers: <Widget>[
          SliverAppBar(
            title: Text(title),
            pinned: true,
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                double randomHeight;
                if (!itemHeight.containsKey(index)) {
                  randomHeight = (random.nextInt(100) + 40) * 1.0;
                  print('index: $index, randomHeight: $randomHeight');
                  double beforeSumHeight =
                      index == 0 ? 0 : itemHeight[index - 1];
                  print({
                    'index': index,
                    'beforeSumHeight': beforeSumHeight,
                    'height': randomHeight
                  });
                  itemHeight[index] = beforeSumHeight + randomHeight;
                } else {
                  randomHeight = index == 0
                      ? itemHeight[index]
                      : itemHeight[index] - itemHeight[index - 1];
                }

                return AutoScrollTag(
                  key: ValueKey(index),
                  controller: AutoScrollController(),
                  index: index,
                  child: Container(
                    height: randomHeight,
                    decoration: BoxDecoration(
                        color: Colors.red.withOpacity(0.5),
                        border: Border(
                          bottom: BorderSide(
                            color: Color(0XFF000000).withOpacity(0.08),
                            width: 1.0,
                            style: BorderStyle.solid,
                          ),
                        )),
                    padding: EdgeInsets.only(top: 10, left: 20.0, right: 20.0),
                    child: Text('$index'),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildBody() {
    return Container();
  }
}

class WidgetSize extends StatefulWidget {
  final Widget child;
  final Function onChange;

  const WidgetSize({
    Key key,
    @required this.onChange,
    @required this.child,
  }) : super(key: key);

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

class _WidgetSizeState extends State<WidgetSize> {
  @override
  Widget build(BuildContext context) {
    SchedulerBinding.instance.addPostFrameCallback(postFrameCallback);
    return Container(
      key: widgetKey,
      child: widget.child,
    );
  }

  var widgetKey = GlobalKey();
  var oldSize;

  void postFrameCallback(_) {
    var context = widgetKey.currentContext;
    if (context == null) return;

    var newSize = context.size;
    if (oldSize == newSize) return;

    oldSize = newSize;
    widget.onChange(newSize);
  }
}

You don't need to use ScrollEndNotification. In fact, I find the UX more responsive when the SliverAppBar updates during scrolling, and this is the most similar result to an onscroll handler anyways. Your idea of using notifications works fine if you change to just using the regular ScrollNotification. The problem is actually pretty straightforward in this case because the SliverList children have fixed height. The children are always 150 pixels high (120 pixels is content with 30 padding).

With this in mind, we can actually calculate the index of the child that gets scrolled out in the notification handler. I had to change the SliverAppBar to be pinned to keep the bar (and the scrolled out index) visible during scroll.

Note that if the height of the child items wasn't fixed we'd need to calculate each row's height every time, so this approach would need to be refactored.

Essentially the logic is:

// Get scroll position `progress` and subtract `SliverAppBar` height.
double progress = notification.metrics.pixels - 60;
// Calculate index scrolled off the screen.
index = (progress ~/ 150) - 1;

I've also added a valid index check so that negative indices don't get displayed by mistake.

((index >= 0) ? index.toString() : "")

Complete code:

class _MyHomePageState extends State<MyHomePage> {
  late AutoScrollController controller =
      AutoScrollController(initialScrollOffset: 0);
  int index = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[100],
      body: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification notification) {
          double progress = notification.metrics.pixels - 60;
          setState(() {
            index = (progress ~/ 150) - 1;
          });
          return true;
        },
        child: CustomScrollView(
          controller: controller,
          slivers: <Widget>[
            SliverAppBar(
              title: Text(
                  widget.title + " " + ((index >= 0) ? index.toString() : "")),
              expandedHeight: 60,
              pinned: true,
            ),
            SliverList(delegate: SliverChildBuilderDelegate(
              (BuildContext context, int localIndex) {
                return AutoScrollTag(
                  key: ValueKey(localIndex),
                  controller: controller,
                  index: localIndex,
                  child: Padding(
                    padding: const EdgeInsets.only(
                        top: 30.0, left: 20.0, right: 20.0),
                    child: Container(color: Colors.red, height: 120.0),
                  ),
                );
              },
            )),
          ],
        ),
      ),
    );
  }
}
Related