Paginator: a platform-independent infinite loading component
First a HorizontalListView request
First, being an old sport, I was flattered that a young fellow calls me “mate”.
Second he was right: this list couldn't paginate...
So it was the perfect time for me to introduce a component I reused countless times in many of the projects I worked on (countless and many meaning here 4 and 3, but it's less impressive): THE PAGINATOR.
It's already available in the Sharpnado nuget packages since 0.9.4.
There are 2 things I really like in this component:
- Its name: it feels so powerful and dangerous
- Its composition vs inheritance philosophy
Me rambling again about composition vs inheritance
Here we go, I already explained a bit here why I think it's better to use composition in the view models than inheritance to implement view model initialization.
But we can also apply this strategy for paging...
Now let's first go back to our full inheritance strategy (and mostly applied).
public abstract class BaseViewModel : INotifyPropertyChanged
{
...
public async void Initialize(object parameter)
{
IsBusy = true;
HasErrors = false;
try
{
await InnerInitializationAsync(parameter);
}
catch (Exception exception)
{
ExceptionHandler.Handle(exception);
HasErrors = true;
ErrorMessage = "this is messy";
}
finally
{
IsBusy = false;
}
}
protected abstract Task InnerInitializationAsync(object parameter);
}
public abstract class PageableViewModel<T> : BaseViewModel
{
protect PageableViewModel(int pageSize)
{
...
}
public ObservableCollection<T> Items { get; set; }
protected int PageSize { get; set; }
protected override Task InnerInitializationAsync(object parameter)
{
return LoadPageAsync(1);
}
protected Task LoadPageAsync(int pageNumber)
{
...
}
}
So what's wrong with that ?
- You force your view models to fit in a global use case: maybe sometimes you don't need the two features, you want just the pagination or the task loading handling,
- It's way less readable: you need to override an abstract method, and your features are hidden in the hierarchy tree,
- It feels less natural than composition: if I want to load an async code, I use a
ViewModelLoader
, if want to paginate data I use aPaginator
.
So just like we created a ViewModelLoader
component to achieve a nice container for our initialization code, we create a Paginator
to handle all the page loading.
Our view models will have the choice to use either one of them or the two combined.
For example, if you already have a large code base implementing the BaseViewModel
strategy, you could still use the Paginator
in them, that's what components are about, decoupling and reusability.
What is the Paginator?
It's totally platform independant. It's pure netstandard. So you can use it in WPF, Xamarin.Forms, and why not Windows Forms.
It's a portable concept, we could easily translate it into java or the-last-trending-language.
Feature speaking, it's a component easing the pages loading by achieving coordination between manual page loading in the ViewModel
, and automatic loading (infinite list) on the View
side.
The same instance is shared by the ViewModel
and the View
.
It offers also some fine tuning like specifying the max number of items it can load, and a threshold point of the ListView (or RecyclerView
, or UICollectionView
, or HorizontalListView
, etc...) beyond which a new page will be loaded (thus implementing an infinite list behavior).
It exists as two forms:
- A standalone one using only
Task
(as a Gist here: https://gist.github.com/roubachof/d5b6c64d39ccf4e0adb7859a42533e4b) - A version included in my Sharpnado.Presentation.Forms repo and nuget package.
How the Paginator works
First let's have a look at our implementation in the Sharpnado.Presentation.Forms
project: https://github.com/roubachof/Sharpnado.Presentation.Forms/blob/master/Sharpnado.Presentation.Forms/Paging/Paginator.cs.
Although you can use the Paginator
anywhere, it's most likely you will create it in the ViewModel
, and plug it to your UI Control as a IInfiniteListLoader
.
Cause yes, the Paginator
bears two responsabilities:
- The automatic loading of next pages when scrolling down the UI scrollable component through the
OnScroll(int lastVisibleIndex)
method - The manual loading of pages in the view model through the
LoadPage
method
So it breaks SRP... But it makes it powerful cause the infinite loading part can make some decisions based on the actual state of the page loading task. And it's more simple to use since it's the same object but used with a different interface in the UI part.
To ease your understanding, in the following sections we will work on the example of the HorizontalListView
used in our Silly! app
The IInfiniteListLoader
(the View
side)
Basically, you will pass the Paginator
as an IInfiniteListLoader
to your ListView
. There is two places where you can plug it:
- On a scrolling event (like
RecyclerView.OnScrollListener
on Android, Scrolled event ofScrollView
on Xamarin.Forms andUICollectionView
on iOS, ...) - Directly on the
GetCell
of theUICollectionViewCell
or theOnBindViewHolder
of theRecyclerView.Adapter
(it seems trash but since theOnScroll
method implementation returns on average in less than 10 ticks it won't block your UI thread.)
So in our Xamarin.Forms HorizontalListView
, we have this property:
public static readonly BindableProperty InfiniteListLoaderProperty
= BindableProperty.Create(
nameof(ItemsSource),
typeof(IInfiniteListLoader),
typeof(HorizontalListView));
So our Paginator is set as an IInfiniteListLoader
by binding in our list view.
Android implementation
We use the RecyclerView.OnScrollListener
to call our component:
private class OnControlScrollChangedListener : RecyclerView.OnScrollListener
{
...
public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)
{
base.OnScrolled(recyclerView, dx, dy);
var infiniteListLoader = _element?.InfiniteListLoader;
if (infiniteListLoader == null)
{
return;
}
var linearLayoutManager = (LinearLayoutManager)recyclerView.GetLayoutManager();
int lastVisibleItem = linearLayoutManager.FindLastVisibleItemPosition();
if (_lastVisibleItemIndex == lastVisibleItem)
{
return;
}
_lastVisibleItemIndex = lastVisibleItem;
InternalLogger.Info($"OnScrolled( lastVisibleItem: {lastVisibleItem} )");
infiniteListLoader.OnScroll(lastVisibleItem);
}
iOS implementation
We use here the Scrolled
event of our UICollectionView
:
private void OnScrolled(object sender, EventArgs e)
{
var infiniteListLoader = Element?.InfiniteListLoader;
if (infiniteListLoader != null)
{
int lastVisibleIndex =
Control.IndexPathsForVisibleItems
.Select(path => path.Row)
.DefaultIfEmpty(-1)
.Max();
if (_lastVisibleItemIndex == lastVisibleIndex)
{
return;
}
_lastVisibleItemIndex = lastVisibleIndex;
InternalLogger.Info($"OnScrolled( lastVisibleItem: {lastVisibleIndex} )");
infiniteListLoader.OnScroll(lastVisibleIndex);
}
...
}
The concrete Paginator (the ViewModel
side)
In order to instantiate our Paginator in our view model, we will need some information:
Func<int, int, Task<PageResult<TResult>>> pageSourceLoader
int pageSize = PageSizeDefault
int maxItemCount = MaxItemCountDefault
float loadingThreshold = LoadingThresholdDefault
you can see that only the pageSourceLoader
is required, the others parameters have default values.
The pageSourceLoader
It's a Func
taking a page number, a page count, and returning a PageResult<TResult>
:
public struct PageResult<TItem>
{
public static readonly PageResult<TItem> Empty = new PageResult<TItem>(0, new List<TItem>());
public PageResult(int totalCount, IReadOnlyList<TItem> items)
{
TotalCount = totalCount;
Items = items ?? new List<TItem>();
}
public int TotalCount { get; }
public IReadOnlyList<TItem> Items { get; }
}
In most of the cases, your TItem
will be the type of your list items. So in fact your page source loader will:
- Call REST service
- Create
ViemModel
items object fromModel
objects - Add them to your
ObservableRangeCollection
- Fill and return the
PageResult
Remark: TItem could also be the type of your model objects, in that case you will use the optional OnTaskCompleted
callback to transform the models into view models and raise "property changed" on your collection. It can be helpful if you need to create different view models from these models later.
The others arguments
- The
pageSize
is self explanatory, if you don't know what it is, please come back later :), - The
maxItemCount
is the maximum item count that the paginator will load. If the count is reached, theOnScroll
method will not load any more pages, - The
loadingThreshold
is more related to the UI layer, it will specify the point on your UI control beyond which the next page loading will be triggered. It's a percentage from the currentpageSize
. From the doc: Let's say you have 40 items loaded in your List and page size is 10, if the threshold is 0.5, the loading of the next page will be triggered when element 35 will become visible. If threshold is 0.25, it will be triggered at element 37.
Jeez enough gibberish man show me a concrete example
Let's get back to our beacon in the night, our home sweet home, our "you'll never walk alone", our Silly! app.
In the following example we will learn to use our ViewModelLoader
with the Paginator
.
The SillyInfinitePeopleVm view model
Let's have a look at the SillyInfinitePeopleVm, and first its constructor.
public class SillyInfinitePeopleVm : ANavigableViewModel
{
private const int PageSize = 10;
private readonly ISillyDudeService _sillyDudeService;
public SillyInfinitePeopleVm(
INavigationService navigationService,
ISillyDudeService sillyDudeService,
ErrorEmulator errorEmulator)
: base(navigationService)
{
_sillyDudeService = sillyDudeService;
...
SillyPeople = new ObservableRangeCollection<SillyDudeVmo>();
SillyPeoplePaginator = new Paginator<SillyDude>(
LoadSillyPeoplePageAsync,
pageSize: PageSize,
loadingThreshold: 1f);
SillyPeopleLoader = new ViewModelLoader<IReadOnlyCollection<SillyDude>>(
ApplicationExceptions.ToString,
SillyResources.Empty_Screen);
}
public ViewModelLoader<IReadOnlyCollection<SillyDude>>
SillyPeopleLoader { get; }
public Paginator<SillyDude>
SillyPeoplePaginator { get; }
public ObservableRangeCollection<SillyDudeVmo>
SillyPeople { get; set; }
...
}
We are interested in these 3 properties that will be coordinated:
- SillyPeopleLoader
- SillyPeoplePaginator
- SillyPeople
The paginator is instantiated with the data source LoadSillyPeoplePageAsync
, the pageSize
will be 10, and the loadingThreshold
1 (which means that as soon as the first item of the last page is shown, a new page is loaded).
Let's have a look to the data source of our paginator:
private async Task<PageResult<SillyDude>> LoadSillyPeoplePageAsync(
int pageNumber, int pageSize)
{
PageResult<SillyDude> resultPage =
await _sillyDudeService.GetSillyPeoplePage(pageNumber, pageSize);
var viewModels = resultPage.Items
.Select(dude => new SillyDudeVmo(dude, GoToSillyDudeCommand))
.ToList();
SillyPeople.AddRange(viewModels);
return resultPage;
}
So the paginator will use this method to automatically add new pages through the OnScroll
method, or manually with the LoadPage
.
Now the interesting part, let's see how our ViewModelLoader
interacts with the Paginator
. Let's go to the Load
which is called when we navigate for the first time to the view model:
private void Load()
{
SillyPeople = new ObservableRangeCollection<SillyDudeVmo>();
RaisePropertyChanged(nameof(SillyPeople));
SillyPeopleLoader.Load(async () =>
{
...
return (await SillyPeoplePaginator.LoadPage(1)).Items;
});
}
The signature of our ViewModelLoader
Load method (TData
is here IReadOnlyCollection<SillyDude>
):
void Load(Func<Task<IReadOnlyCollection<SillyDude>>> loadingTaskSource, bool isRefreshing = false)
Now remember the ViewModelLoader
is in charge of the initialization of our view model. In our semantic case, the initialization is the loading of the first page of our list of silly dudes. So we just call manually LoadPage(1)
which will return the PageResult
for our first page. It will handle all the cases such as error, empty state, etc...
And that's it for our view model.
The SillyInfinitePeoplePage view
For the View part, our HorizontalListView
already supports IInfiniteListLoader
, then it's just about setting a binding in our SillyInfinitePeoplePage:
...
<renderedViews:HorizontalListView Grid.Row="3"
Margin="-16,8"
CollectionPadding="0,8"
EnableDragAndDrop="True"
InfiniteListLoader="{Binding SillyPeoplePaginator}"
ItemHeight="144"
ItemSpacing="8"
ItemTemplate="{StaticResource DudeTemplateSelector}"
ItemWidth="144"
ItemsSource="{Binding SillyPeople}"
SnapStyle="Center" />
...
Thanks
Thanks for following me throughout this kinda long post for such a simple component. From my point of view, it enlights well some common pitfall we meet in our day to day MVVM
developer life.
Don't forget to checkout embedded documentation on the standalone paginator and the Sharpnado.Presentation.Forms. It's quite verbose ;)