Have I Reached the End of my ListView?

Building on a previous post about seeing how a user interacts with a ListView in Xamarin.Forms, the next question is, “Has the user scrolled to the end?”  This can be useful, for instance, if the user is scrolling through a long feed of data, and only a portion is returned at a time.  When the user reaches the end, the app can automatically download more entries.  My colleague Lazareena and I recently collaborated on a solution for this, and here’s what we came up with.

Let’s start with what we want to see on the Forms side of our app. We’ll add this to our class that inherits from ListView:

public static readonly BindableProperty AtEndOfListProperty =
        BindableProperty.Create(nameof(AtEndOfList), typeof(bool), typeof(MyListView), false);

public bool AtEndOfList
{
    get { return (bool)GetValue(AtEndOfListProperty); }
    set
    {
        SetValue(AtEndOfListProperty, value);
    }
}

The first question might be, is this possible strictly using Xamarin.Forms classes, without using any custom renderers?  The answer, at least as of today, is no, though it seems like it ought to.  Start with adding handlers for ListView.ItemAppearing and ListView.ItemDisappearing, do some bookkeeping with the contents of the collection, and it should work.  Unfortunately, as noted in the documentation for ItemDisappearing:

This method is for virtualization usage only. It is not guaranteed to fire for all visible items when the List is removed from the screen. Additionally it fires during virtualization, which may not correspond directly with removal from the screen depending on the platform virtualization technique used.

So we can implement a limited version of this using ItemAppearing.  If we keep track of what item is the last in the collection, we can watch for an ItemAppearing event to say that it is being rendered.  This is perhaps the simplest solution if all you want to know is if the user has ever scrolled to the end.  We wanted an approach that would be a bit more responsive, and tell us if the ListView is currently at the end, even if the user scrolled to the end, then back.  Plus, we’ve already built up some infrastructure to observe ListView behavior, so it seemed natural to extend that platform.

That means we dive into custom renderers to update the bindable property AtEndOfList on each platform when we reach it (and unset it if the user scrolls back up).  Sounds simple, right?  It’s actually a little trickier than it first appears, as there are three scenarios to consider when implementing our custom renderer:

  1. Updating the value while scrolling
  2. Initializing the value properly upon initialization
  3. Handling device rotation or resizing

This post dives into a discussion of the implementation of the custom renderers to support this on Android, iOS, and UWP.  However if you just want the completed code, skip ahead to https://github.com/david-js/XFListViewInfo.

Android

The basic functionality of seeing where we are during scrolling is pretty straightforward.  The Scroll event handler provided by the ListViewRenderer tells us where we are in the list, how many items are on the screen, and how many items there are total.  So a little math, and:

_myListView.AtEndOfList = (e.FirstVisibleItem + 1 + e.VisibleItemCount >= e.TotalItemCount);

Handling the other two cases requires overriding OnLayout.  During the OnElementChanged call, we don’t have enough information to figure this out, so we wait until after our base class handles its part of OnLayout, then we can figure things out. Luckily, this method is called for both orientation changes and initial layout, so the same solution handles both cases.

A little explanation is in order. The Android system can tell us what elements are visible, but not how many there are in the list. We also don’t have a fully-enumerated List of items in the ListView’s collection.  Remember that ItemsSource does not need to be a List, it can be any IEnumerable.  In practice, it’s often an ICollection, the system interface which defines the Count property exposed by both List<T> and ObervableCollection<T>.  So if that’s the case, the quickest and easiest way to see how many elements there are (and hence if we’re showing all of them) is to use that Count property.  However if we can’t get the count, we’d prefer not to force an enumeration of the collection just for this purpose (e.g. by calling ToList()).  That is going to be more expensive that just iterating through the collection until we can prove whether or not we’re showing the last element. Also note that this is not meant to handle grouped ListView containers. That’s certainly possible to do with this approach, but requires more bookkeeping and is left as an exercise for another day.

iOS

The UITableView, which is used as the renderer for ListView, gives us the information we need to figure out where we are in the content anytime we want it with the custom renderer.  So we implement this helper within the renderer:

bool IsAtEndOfList()
{
    return Control.Frame.Height + Control.ContentOffset.Y >= Control.ContentSize.Height - 10;
}

That odd looking “- 10” is there as a way to smooth the experience out a bit.  As we’ve observed previously, lists on iOS like to “bounce” a little when you reach an end.  Plus, if we don’t include that, we won’t report we’re at the end until it’s scrolled all the way to the end of the last item in the list.  This gives it a bit more of a natural feel, though you may want to adjust it to your liking.

So the next question is, when should we use this to hit the three scenarios?

For scrolling, we add to the contentOffset handler we built previously (see more details on this on GitHub or the previous post on the topic):

protected override void OnElementChanged(ElementChangedEventArgs e)
{
    base.OnElementChanged(e);

    if (e.NewElement is MyListView)
    {
        ...
        _offsetObserver = Control.AddObserver("contentOffset", Foundation.NSKeyValueObservingOptions.New, HandleAction);
    }
}

private void HandleAction(Foundation.NSObservedChange obj)
{
    var effectiveY = Math.Max(Control.ContentOffset.Y, 0);
    if (!CloseTo(effectiveY, _prevYOffset) && _myListView != null)
    {
        ...
        _myListView.AtEndOfList = IsAtEndOfList();
    }
}

That handles scrolling, but doesn’t help with initialization or orientation change.  For that, we use a similar trick as for Android.  On Android, we used OnLayout, and the moral equivalent on iOS is LayoutSubviews:

public override void LayoutSubviews()
{
    base.LayoutSubviews();

    if (_myListView != null)
        _myListView.AtEndOfList = IsAtEndOfList();
}

Make sure the base class does its work, then see where we are in the list. LayoutSubviews gets called both during initial layout of the ListView and during orientation change, so we’re done.

UWP

As in our previous work on observing ListView scrolling behavior, UWP isn’t quite as convenient.  We don’t get access to the information we need until a PointerEntered event is raised, which gets us access to a ScrollViewer.  Then the ScrollViewer object lets us listen for ViewChanged events, which are fired whenever scrolling happens.

Once we’re in either the PointerEntered or ScrollViewer_ViewChanged event handler, it’s fairly straightforward to figure out if we’re at the end:

_myListView.AtEndOfList = (_scrollViewer.ViewportHeight + _scrollViewer.VerticalOffset + 5) >= _scrollViewer.ExtentHeight;

Like on iOS, we introduce a small “correction” because of the way that we get these measures on UWP.  It is very difficult to scroll all the way to the end.  Even if it looks like you’re at the end, you might be just a couple pixels shy — hence the correction, to make it behave just a bit more predictable from a user perspective.

This can, unfortunately, behave a bit oddly  in some circumstances.  In normal usage, it will work well enough, however things to be aware of:

  1. The behavior during scrolling is exactly as expected.
  2. Initialization happens when the pointer first enters the frame of the ListView.
  3. Orientation change, or other resizing, will only get picked up if there is a new PointerEntered event or scroll.

The Completed Code

In case you missed the link earlier, the code is available on GitHub: https://github.com/david-js/XFListViewInfo

 

Author

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.