Incremental Loading (Pagination) with MVVM and MVUX

In my first post in this sequence on MVVM and MVUX we built out a simple application that searched movies in The Mobile Database (TMDB) using the text entered to match against the movie title. What’s interesting about the TMDB api is that the search results are actually paginated with the initial request only returning a small subset (eg 20) of the total results. In this post we’re going to look at how we can implement paginated, also referred to as incremental, loading for retrieving search results.

Rather than building incremental loading from scratch we’re going to take advantage of the ISupportIncrementalLoading interface that the ListView control supports. By providing the ListView with an ItemsSource that implements the ISupportIncrementalLoading interface, the ListView will automatically call the LoadMoreItemsAsync method as more items are required (ie when the user scrolls down to a stuitable place in the ListView).

Model-View-ViewModel (MVVM)

As we did previously, we’ll start with the Model-View-ViewModel (MVVM) version of the application and add support for the ISupportIncrementalLoading interface.

Implementing ISupportIncrementalLoading

We’re going to keep the implementation of the ISupportIncrementalLoading interface very simple. It’ll start by sub-classing the ObservableCollection class and adding implementations for the HasMoreItems property and LoadMoreItemsAsync method that are required by the ISupportIncrementalLoading interface.

public class IncrementalObservableCollection<T> : ObservableCollection<T>, ISupportIncrementalLoading
{
    public Func<Task<(IEnumerable<T> Results, int TotalResults)>>? LoadCallback { get; set; }

    public bool HasMoreItems => Count < TotalResults;

    public int PageNumber { get; set; }

    public int TotalResults { get; set; }

    public IAsyncOperation<LoadMoreItemsResult> LoadMoreItemsAsync(uint count)
        => InternalLoadMoreItemsAsync().AsAsyncOperation();

    private async Task<LoadMoreItemsResult> InternalLoadMoreItemsAsync()
    {
        if (LoadCallback is null)
        {
            return new LoadMoreItemsResult();
        }
        var results = await LoadCallback();
        this.AddRange(results.Results);
        TotalResults = results.TotalResults;
        OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs(nameof(TotalResults)));
        return new LoadMoreItemsResult((uint)results.Results.Count());
    }
}

If we go back to the Search method from the original post the main change we need to make is instead of returning an IReadOnlyList<Result> for the Movies property, we’re now going to be returning an instance of the IncrementalObservableCollection. As the IncrementalObservableCollection is an ObservableCollection, as items are added, the ListView will automatically be updated to display those items. Furthermore, because IncrementalObservableCollection implements ISupportIncrementalLoading the LoadCallback function will be invoked as more items are requested by the ListView.

Here’s the updated code for the MainViewModel.

public partial class MainViewModel : ObservableObject
{
    private IApiClient _client;

    [ObservableProperty]
    private IncrementalObservableCollection<Result> _movies;

    [ObservableProperty]
    private string? _searchText;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsNotSearching))]
    private bool _isSearching;

    public bool IsNotSearching => !IsSearching;

    [ObservableProperty]
    private bool _isError;

    [ObservableProperty]
    private bool _noResults;

    public MainViewModel(
        IApiClient client)
    {
        _client = client;
    }

    [RelayCommand]
    public async Task Search()
    {
        try
        {
            IsSearching = true;
            IsError = false;

            var moviesResult = await _client.GetMovies(SearchText, 1);
            var movies = new IncrementalObservableCollection<Result>();
            movies.AddRange(moviesResult.Results);
            movies.TotalResults = moviesResult.TotalResults ?? 0;
            movies.PageNumber = 1;
            movies.LoadCallback = async () =>
            {
                var moreMovies = await _client.GetMovies(SearchText, ++Movies.PageNumber);
                return (moreMovies.Results, moreMovies.TotalResults ?? 0);
            };
            NoResults = movies.Count == 0;
            Movies = movies;
        }
        catch
        {
            IsError = true;
        }
        finally
        {
            IsSearching = false;
        }
    }
}

As you can see there’s a bit of fussing around with creating a new IncrementalObservableCollection and providing it a delegate for the LoadCallback.

We’ve made some minor changes to the XAML to include the current results count and total results.

<Page x:Class="MvvmVersusMvux.Presentation.MainPage"
	  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
	  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	  xmlns:local="using:MvvmVersusMvux.Presentation"
	  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
	  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
	  mc:Ignorable="d"
	  xmlns:utu="using:Uno.Toolkit.UI"
	  NavigationCacheMode="Required"
	  Background="{ThemeResource BackgroundBrush}">

	<Grid utu:SafeArea.Insets="All">
		<VisualStateManager.VisualStateGroups>
			<VisualStateGroup>
				<VisualState x:Name="Searching">
					<VisualState.StateTriggers>
						<StateTrigger IsActive="{Binding IsSearching}" />
					</VisualState.StateTriggers>
					<VisualState.Setters>
						<Setter Target="SearchProgress.Visibility"
								Value="Visible" />
					</VisualState.Setters>
				</VisualState>
				<VisualState x:Name="Error">
					<VisualState.StateTriggers>
						<StateTrigger IsActive="{Binding IsError}" />
					</VisualState.StateTriggers>
					<VisualState.Setters>
						<Setter Target="ErrorText.Visibility"
								Value="Visible" />
					</VisualState.Setters>

				</VisualState>
				<VisualState x:Name="NoResults">
					<VisualState.StateTriggers>
						<StateTrigger IsActive="{Binding NoResults}" />
					</VisualState.StateTriggers>
					<VisualState.Setters>
						<Setter Target="NoResultsText.Visibility"
								Value="Visible" />
					</VisualState.Setters>

				</VisualState>
				<VisualState x:Name="Results">
					<VisualState.StateTriggers>
						<StateTrigger IsActive="{Binding IsNotSearching}" />
					</VisualState.StateTriggers>
					<VisualState.Setters>
						<Setter Target="ResultsListView.Visibility"
								Value="Visible" />
						<Setter Target="ResultsCountText.Visibility"
								Value="Visible" />
					</VisualState.Setters>

				</VisualState>

			</VisualStateGroup>
		</VisualStateManager.VisualStateGroups>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto" />
			<RowDefinition Height="Auto" />
			<RowDefinition Height="Auto" />
			<RowDefinition />
		</Grid.RowDefinitions>
		<utu:NavigationBar Content="Movies" />
		<TextBox Text="{Binding SearchText, Mode=TwoWay}"
				 Grid.Row="1" />
		<StackPanel Grid.Row="2">
			<Button Content="Search"
					Command="{Binding SearchCommand}" />
			<TextBlock x:Name="ResultsCountText" Visibility="Collapsed">
				<Run Text="Results: " />
				<Run Text="{Binding Movies.Count}" />
				<Run Text="/" />
				<Run Text="{Binding Movies.TotalResults}" />
			</TextBlock>
		</StackPanel>
		<ListView x:Name="ResultsListView"
				  Grid.Row="3"
				  ItemsSource="{Binding Movies}"
				  Visibility="Collapsed">
			<ListView.ItemTemplate>
				<DataTemplate>
					<TextBlock Text="{Binding Title}" />
				</DataTemplate>
			</ListView.ItemTemplate>
		</ListView>
		<TextBlock x:Name="ErrorText"
				   Grid.Row="3"
				   Visibility="Collapsed"
				   Text="Error" />
		<TextBlock x:Name="NoResultsText"
				   Grid.Row="3"
				   Visibility="Collapsed"
				   Text="No Results" />
		<ProgressRing x:Name="SearchProgress"
					  Visibility="Collapsed"
					  Grid.Row="3" />
	</Grid>
</Page>

And the running application:

Model-View-Update-eXtended (MVUX)

The Model-View-Update-eXtended (MVUX) IListFeed already implements the ISupportIncrementalLoading interface, so the only change we really need to make is to the way that the IListFeed is created. Here’s the updated MainModel.

public partial record MainModel
{
    private IApiClient _client;

    public MainModel(
        IApiClient client)
    {
        _client = client;
    }

    public IState<string> SearchText => State<string>.Empty(this);

    public IListFeed<Result> Movies =>
        ListFeed.AsyncPaginated<Result>(
            async (pageRequest, ct) => 
            (await _client.GetMovies(await SearchText ?? string.Empty, (int)pageRequest.Index + 1, ct)).Results);
}

In the origianl post we simplified the user interface by using the SearchText property to create the feed (eg SearchText.SelectAsync ). The SelectAsync extension doesn’t currently support incremental loading, so we’ve kept the two properties separate (which will require the Search button to exist in the XAML to force the Movies feed to refresh).

As you can see there is significantly less work to be done to extend the MVUX application to support incremental loading.

4 thoughts on “Incremental Loading (Pagination) with MVVM and MVUX”

Leave a comment