Observing ListView Scrolling in Xamarin.Forms

Xamarin.Forms ListView gives some nice flexibility in how lists are displayed, but has some limitations in observing user interaction. In a recent project, I had a couple of things I wanted to know:

  1. Is the user scrolling up or down?
  2. Is the user at the start of the list?

What I needed was more or less this on the PCL side:

    public class MyListView : ListView
    {
        public static readonly BindableProperty
            LastScrollDirectionProperty =
                BindableProperty.Create(nameof(LastScrollDirection),
                typeof(string), typeof(MyListView), null);
        public static readonly BindableProperty
            AtStartOfListProperty =
                BindableProperty.Create(nameof(AtStartOfList),
                typeof(bool), typeof(MyListView), false);

        public string LastScrollDirection
        {
            get { return (string)GetValue(LastScrollDirectionProperty); }
            set { SetValue(LastScrollDirectionProperty, value); }
        }

        public bool AtStartOfList
        {
            get { return (bool)GetValue(AtStartOfListProperty); }
            set { SetValue(AtStartOfListProperty, value); }
        }
    }

This will walk through the tricky bits of the implementation. Some initialization and cleanup is omitted. You can see the code on GitHub for full details.

Scrolling Events on Android

This was the easiest platform, as the native control exposes a scrolling event that is easy to convert into the information I need:

        private int _prevFirstVisibleElement;

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

            if (e.NewElement is MyListView)
                Control.Scroll += Control_Scroll;
        }

        private void Control_Scroll(object sender, AbsListView.ScrollEventArgs e)
        {
            if (e.FirstVisibleItem != _prevFirstVisibleElement && Element is MyListView)
            {
                var direction = e.FirstVisibleItem > _prevFirstVisibleElement
                                        ? "Going down!" : "Going up!";
                var myList = Element as MyListView;
                myList.LastScrollDirection = direction;
                myList.AtStartOfList = (e.FirstVisibleItem == 0);
                _prevFirstVisibleElement = e.FirstVisibleItem;
            }
        }

This ignores some cleanup, but is basically all that you need to do. Monitor the position from call to call to infer the direction, and the check for start of list is straightfoward.

Scrolling Events on UWP

It’s a different model in UWP.  The list itself doesn’t seem to expose where it is in scrolling.  Instead you find the ScrollViewer object which is somewhere in the tree under the List.  The answer at https://stackoverflow.com/questions/36421674/uwp-catch-list-view-scroll-event gave me the clues I needed to figure this out.

First off, the ScrollViewer object doesn’t exist unless it’s needed. So we need to grab it when it’s created, making a two-part setup:

        private ScrollViewer _scrollViewer;

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

            if (e.NewElement is MyListView)
                List.PointerEntered += List_PointerEntered;
        }

        private void List_PointerEntered(object sender,
                        Windows.UI.Xaml.Input.PointerRoutedEventArgs e)
        {
            _scrollViewer = GetScrollViewer(List);
            if (_scrollViewer != null)
                _scrollViewer.ViewChanged += ScrollViewer_ViewChanged;
        }

GetScrollViewer is a method that searches the tree for a ScrollView object; for the implementation, see the stackoverflow article above or the GitHub link below.

This ensures that our event handler will get properly attached every time the list gets focus.

Once the event fires, the ScrollViewer gives us a vertical offset to compute the properties for the PCL:

        private void ScrollViewer_ViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
        {
            if (Element is MyListView)
            {
                var myList = Element as MyListView;

                if (_prevVerticalOffset != _scrollViewer.VerticalOffset)
                {
                    var direction = _scrollViewer.VerticalOffset > _prevVerticalOffset
                                            ? "Going down!" : "Going up!";
                    myList.LastScrollDirection = direction;
                    myList.AtStartOfList = (_scrollViewer.VerticalOffset == 0);
                    _prevVerticalOffset = _scrollViewer.VerticalOffset;
                }
            }
        }

And that’s just about it. With UWP, we don’t need to worry about disconnecting event handlers from UI elements in general; that’s handled automatically by the runtime.

Scrolling Events on iOS

The Xamarin.Forms iOS renderer for ListView does a nice job of preventing easy access to the kinds of events or callbacks that used on the other platforms.  These exist on iOS, but the ListViewRenderer does not permit access.

Luckily, there are the Key Value Observing APIs, which serve the purpose nicely.  Instead of directly asking to be notified when scrolling happens, we ask to be notified when the offset in the UITableView changes:

        private IDisposable _offsetObserver;

        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) && Element is MyListView)
            {
                var direction = effectiveY > _prevYOffset ? "Going down!" : "Going up!";
                var myList = Element as MyListView;
                myList.LastScrollDirection = direction;
                myList.AtStartOfList = CloseTo(effectiveY, 0);
                _prevYOffset = effectiveY;
            }
        }

CloseTo is a simple method to decide if two doubles are close enough to each other. Otherwise we end up with being told that we’re not at the top of the list when we’re ever-so-slightly away from it. It can be as sensitive or not as desired.

You might notice that we’re observing changes in contentOffset, but we really only care about contentOffset.Y.  As noted in the previous article about KVO, we can only observe properties that have the Export attribute; contentOffset has one, Y does not.  In this case, there’s no problem, but something to consider when looking at other use cases.

Unlike the UWP case, cleanup is important:

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            if (disposing && _offsetObserver != null)
            {
                _offsetObserver.Dispose();
                _offsetObserver = null;
            }
        }

The KVO Observer needs to be removed from watching the object, and Dispose() will do that properly.

Wrapping Up

This ended up being a bit more challenging that I expected. Android was first and was so easy that I was lulled into a false sense of security. The others came together with a bit of digging. And in the end, all of the implementations used extensibility mechanisms that look pretty safe to rely on, lessening my worries that this might break in future updates.

The completed project with all of this implemented is at https://github.com/david-js/XFListViewInfo.

Author

Leave a Reply

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