+1

.Net Maui Movie App Tutorial

Preview App

In this tutorial I will dive into creating a similar Movie application like the one here that uses Xamarin forms but with some improvement and more features. I will however be using .Net Maui as Xamarin will be discontinued in a month or so. it will still be functional but no more fixes or future support will be provided by Microsoft meaning newer Android Os and its counterpart won't be supported for longer. Maui introduces major advantages over Xamarin such as Hot Reloads, support modern patterns and Single development project experience with . NET CLI and more. Without further ado, let's dive right in.

NOTE: This tutorial assumes you are familiar with C#, Xamarin Native, Xamarin Form app development. If you are a beginner and would like to learn the basics checkout the official documentation here for Xamarin Form then follow the tutorial i made on Xamarin Form here.

Create a Maui App on Visual Studio 2022

Open VS and create new Maui project. Can name it MauiMovieApp

Click next then select .Net 8.0 (Long term support) then create. After your enviroment is all loaded and set you can see that the Solution Explorer is different from the typical xamarin's.

There are no longer separate projects for your platforms like Android, iOS and so on. Instead we have a directory called Platforms and in here you will find Android, iOS, Windows, Tizen... directories. Another awesome feature is the ability to simply change your target build and run your application on the devices be it Android, ios or windows.

Goto themoviedb website and register to get your api key.

Populate Movies Screen

Next create a new directory Models and add these classes. One is MovieResponse which is the model class for the response we get from https://api.themoviedb.org/3. MovieCall is the class that formats our query and Movie is the individual model representing a single movie data. If you use the Postman to make a GET request in this format: https://api.themoviedb.org/3/movie/now_playing?page=1&api_key=yourapikey

You should get a response like example below:

"results": [
        {
            "adult": false,
            "backdrop_path": "/bQS43HSLZzMjZkcHJz4fGc7fNdz.jpg",
            "genre_ids": [
                878,
                10749,
                35
            ],
            "id": 792307,
            "original_language": "en",
            "original_title": "Poor Things",
            "overview": "Brought back to life by an unorthodox scientist, a young woman runs off with a debauched lawyer on a whirlwind adventure across the continents. Free from the prejudices of her times, she grows steadfast in her purpose to stand for equality and liberation.",
            "popularity": 425.615,
            "poster_path": "/kCGlIMHnOm8JPXq3rXM6c5wMxcT.jpg",
            "release_date": "2023-12-07",
            "title": "Poor Things",
            "video": false,
            "vote_average": 8.134,
            "vote_count": 1344
        },
        {
            "adult": false,
            "backdrop_path": "/a0GM57AnJtNi7lMOCamniiyV10W.jpg",
            "genre_ids": [
                16,
                12,
                14
            ],......

Movie.cs

namespace MauiMovieApp.Models
{
    public class Movie
    {
        public string? poster_path { get; set; }
        public bool adult { get; set; }
        public string? overview { get; set; }
        public string? release_date { get; set; }
        public List<int>? genre_ids { get; set; }
        public int id { get; set; }
        public string? original_title { get; set; }
        public string? original_language { get; set; }
        public string? title { get; set; }
        public string? backdrop_path { get; set; }
        public double popularity { get; set; }
        public int vote_count { get; set; }
        public bool video { get; set; }
        public double vote_average { get; set; }
    }
}

MovieResponse.cs

using System.Collections.ObjectModel;

namespace MauiMovieApp.Models
{
    class MovieResponse
    {
        public int page { get; set; }
        public ObservableCollection<Movie>? results { get; set; }
        public int total_pages { get; set; }
        public int total_results { get; set; }
        public string? status_message { get; set; }
    }
}

MovieCall.cs

namespace MauiMovieApp.Models
{
    public class MovieCall(string type, int page, string query)
    {
        public string Type { set; get; } = type;
        public int Page { set; get; } = page;
        public string Query { set; get; } = query;
    }
}

Next create a directory Constants and add below classes:

ApiKeys.cs

namespace MauiMovieApp.Constants
{
    class ApiKeys
    {
        public const string BASE = "https://api.themoviedb.org/3";
        public const string BASE_URL = BASE + MOVIE_URL;
        public const string API_KEY = "insert_api_key_here";
        public const string IMAGE_URL = "http://image.tmdb.org/t/p/w500";
        public const string NOW_PLAYING = "now_playing";
        public const string UPCOMING = "upcoming";
        public const string TOP_RATED = "top_rated";
        public const string POPULAR = "popular";
        public const string SEARCH = BASE + SEARCH_URL;
        public const string MOVIE_URL = "/movie/";
        public const string SEARCH_URL = "/search/movie";
    }
}

Constant

namespace MauiMovieApp.Constants
{
    class Constant
    {
        public const string API_FORMAT = "{0}{1}?api_key={2}&page={3}";
        public const string SEARCH_FORMAT = "{0}{1}?api_key={2}&page={3}&query={4}";
        public const string API_MOVIE_DETAILS_FORMAT = "{0}{1}?api_key={2}";
        public const string MOVIE = "MOVIE";
        public const string TITLE_NOW_PLAYING = "Now Playing";
        public const string TITLE_POPULAR = "Popular";
        public const string TITLE_UPCOMING = "Upcoming";
        public const string TITLE_TOP_RATED = "Top Rated";
    }
}

They hold the format and url extensions we will be using later in the project.

Next create a Repository directory and add 2 classes IMovieRepository and IMovieRepository

IMovieRepository

using MauiMovieApp.Models;
using System.Collections.ObjectModel;

namespace MauiMovieApp.Repository
{
    public interface IMovieRepository
    {
        Task<ObservableCollection<Movie>?> GetMovies(MovieCall movieCall);
    }
}

MovieRepository

using MauiMovieApp.Models;
using MauiMovieApp.Constants;
using Newtonsoft.Json;
using System.Collections.ObjectModel;
using System.Diagnostics;

namespace MauiMovieApp.Repository
{
    class MovieRepository : IMovieRepository
    {
        public async Task<ObservableCollection<Movie>?> GetMovies(MovieCall movieCall)
        {
            string url = string.Format(Constant.API_FORMAT, ApiKeys.BASE_URL, movieCall.Type, ApiKeys.API_KEY, movieCall.Page);
            HttpClient httpClient = new HttpClient();
            try
            {
                HttpResponseMessage response = await httpClient.GetAsync(url);
                if (response.IsSuccessStatusCode)
                {
                    string result = await response.Content.ReadAsStringAsync();
                    MovieResponse movieResponse = JsonConvert.DeserializeObject<MovieResponse>(result);
                    if(movieResponse != null)
                    {
                        return movieResponse.results;
                    }
                    
                }
            }
            catch (Exception e){
                Debug.Write(e.Message);
            }
            return null;
        }
    }
}

Note: JsonConvert will throw an error so you need to install the NewtonSoft.Json library from Manage Nugget Package for Solution in the Tools tab or use the IntelliSense suggestion.

Next create a directory ViewModels and add MoviesPageViewModel class.

MoviesPageViewModel.cs

using MauiMovieApp.Models;
using MauiMovieApp.Repository;
using MauiMovieApp.Constants;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace MauiMovieApp.Viewmodels
{
    public class MoviesPageViewModel : INotifyPropertyChanged
    {
        private int page = 1;
        private ObservableCollection<Movie> listMovies = new ObservableCollection<Movie>();
        IMovieRepository repository = DependencyService.Get<IMovieRepository>();

        public Command RefreshMoviesCommand { get; set; }

        private bool _isRefreshing;

        public bool IsRefreshing
        {
            get => _isRefreshing;
            set => SetProperty(ref _isRefreshing, value);
        }

        private GridItemsLayout _gridItemLayout;
        public GridItemsLayout GridItemLayout { 
        get => _gridItemLayout;
            set => SetProperty(ref _gridItemLayout, value);
        }

        public ObservableCollection<Movie> allMovies;
        public ObservableCollection<Movie> AllMovies
        {
            get => allMovies;
            set => SetProperty(ref allMovies, value);
        }

        private bool isLoadingData;

        public bool IsLoadingData
        {
            get => isLoadingData;
            set => SetProperty(ref isLoadingData, value);
        }

        public Movie _selectedMovie;

        public event PropertyChangedEventHandler? PropertyChanged;

        public Movie SelectedMovie
        {
            get { return _selectedMovie; }
            set
            {
                _selectedMovie = value;
                OpenMovieDetail();
            }
        }

        public void OpenMovieDetail()
        {
            //todo
        }

        public MoviesPageViewModel()
        {
            allMovies = [];
            GridItemLayout = new GridItemsLayout(DeviceDisplay.Current.MainDisplayInfo.Orientation == DisplayOrientation.Portrait ? 2 : 4, ItemsLayoutOrientation.Vertical);
            _ = FetchMoviesAsync(ApiKeys.NOW_PLAYING);
            RefreshMoviesCommand = new Command(async () =>
            {
                await FetchMoviesAsync(ApiKeys.NOW_PLAYING);
                IsRefreshing = false;
            });
        }

        bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
        {
            if (Object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public async Task FetchMoviesAsync(string type)
        {
            if (IsLoadingData)
            {
                return;
            }
            IsLoadingData = true;
            try
            {
                page = 1;
                listMovies.Clear();
                allMovies.Clear();
                listMovies = await repository.GetMovies(new MovieCall(type, page, ""));
                if (listMovies != null)
                {
                    foreach (Movie movie in listMovies)
                    {
                        if (movie != null && !allMovies.Contains(movie))
                        {
                            allMovies.Add(movie);
                        }
                    }
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }
            finally
            {
                IsLoadingData = false;
            }
        }

        internal void SetNoOfItems(int no)
        {
            GridItemLayout.Span = no;
        }
    }
}

Let's break down all that is happening in this viewmodel class.

  • Since we aren't using Prism this time the class extends INotifyPropertyChanged to notify any changes to our items such as IsRefreshing, AllMovies and so on. Whenever we set a new value to these items the changes will be reflected on the other end. In this case our xaml where we will bind them to.

  • RefreshMoviesCommand will be used to dispose of all movie lists and refresh the page. This will be binded to the RefreshView element in the xaml layout.

  • GridItemLayout is set based on the orientation of the device. If portrait 2 else 4 items per row. This will also be updated from the xaml.cs class when orientation change is triggered so that when the user changes the orientation the layout adjusts accordingly.

  • FetchMoviesAsync is the function that makes asynchronous calls to the repository to fetch out movies. The parameter type for now is NOW_PLAYING but we will add more later in part 2.

  • Pages will be used for infinite scrolling but for now is set to 1. When we implement OnThreshold we will add this feature.

The rest is pretty straightforward. Now lets create the layout that will bind to this ViewModel. Create a new Directory Views and add a new ContentPage MoviesPage

MoviesPage.xaml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="MauiMovieApp.Views.MoviesPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiMovieApp"
    xmlns:viewmodel="clr-namespace:MauiMovieApp.Viewmodels"
    Shell.NavBarIsVisible="False">
    <ContentPage.BindingContext>
        <viewmodel:MoviesPageViewModel />
    </ContentPage.BindingContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="40" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <SearchBar
            x:Name="searchBar"
            Grid.Row="0"
            BackgroundColor="Blue"
            CancelButtonColor="White"
            FontSize="18.0"
            Placeholder="Search Movies..."
            PlaceholderColor="White"
            TextColor="White" />

        <RefreshView
            Grid.Row="1"
            Margin="0,0,0,0"
            Command="{Binding RefreshMoviesCommand}"
            IsRefreshing="{Binding IsRefreshing}">
            <CollectionView
                ItemsLayout="{Binding GridItemLayout}"
                ItemsSource="{Binding AllMovies}"
                SelectedItem="{Binding SelectedMovie}"
                SelectionMode="Single">
                <CollectionView.ItemTemplate>

                    <DataTemplate>
                        <Grid Margin="4">
                            <Grid.RowDefinitions>
                                <RowDefinition Height="*" />
                                <RowDefinition Height="30" />
                                <RowDefinition Height="1" />
                            </Grid.RowDefinitions>

                            <Image
                                Grid.Row="0"
                                Margin="0"
                                Aspect="AspectFill"
                                HeightRequest="260"
                                WidthRequest="200">
                                <Image.Source>
                                    <UriImageSource CacheValidity="10:00:00:00" Uri="{Binding poster_path, StringFormat='https://image.tmdb.org/t/p/w500/{0}'}" />
                                </Image.Source>
                            </Image>

                            <Label
                                Grid.Row="1"
                                FontSize="14"
                                HorizontalTextAlignment="Center"
                                LineBreakMode="TailTruncation"
                                Text="{Binding title}"
                                TextColor="Black"
                                VerticalTextAlignment="Center" />

                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </RefreshView>

        <ActivityIndicator
            Grid.Row="1"
            HeightRequest="30"
            HorizontalOptions="Center"
            IsRunning="{Binding IsLoadingData}"
            VerticalOptions="End"
            WidthRequest="30" />

    </Grid>
</ContentPage>

Added a SearchBar which will allow for searching movies. This will be completed in part 2. Also added a RefreshView which allows users to swipe down from the top to refresh the movies. Next a CollectionView which is binded to the AllMovies in the ViewModel class and an ActivityIndicator to show a spinner when loading data.

Next open the MoviesPage.xaml.cs and add below code.

MoviesPage.xaml.cs

using MauiMovieApp.Viewmodels;

namespace MauiMovieApp.Views
{
    public partial class MoviesPage : ContentPage
    {
        public MoviesPage()
        {
            InitializeComponent();

            DeviceDisplay.Current.MainDisplayInfoChanged += Current_MainDisplayInfoChanged;
        }

        private void Current_MainDisplayInfoChanged(object? sender, DisplayInfoChangedEventArgs e)
        {
            var parentViewModel = (MoviesPageViewModel)this.BindingContext;
            parentViewModel.SetNoOfItems(DeviceDisplay.Current.MainDisplayInfo.Orientation == DisplayOrientation.Portrait ? 2 : 4);
        }
    }
}

The Current_MainDisplayInfoChanged is triggered when an orientation change occurs. This in turn will update the viewModel SetNoOfItems. 4 items in Landscape mode and 2 in Portrait.

Open AppShell.xaml and set the Route to MoviesPage.

AppShell.xaml

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="MauiMovieApp.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiMovieApp"
    xmlns:views="clr-namespace:MauiMovieApp.Views"
    Title="MauiMovieApp"
    Shell.FlyoutBehavior="Disabled">

    <ShellContent
        Title="Movies"
        ContentTemplate="{DataTemplate views:MoviesPage}"
        Route="MoviesPage" />

</Shell>

Next register the IMovieRepository in the App.xaml.cs file.

App.xaml.cs

using MauiMovieApp.Repository;

namespace MauiMovieApp

{
    public partial class App : Application
    {
        public App()
        {
            InitializeComponent();

            DependencyService.Register<IMovieRepository, MovieRepository>();
            MainPage = new AppShell();
        }
    }
}

Run the project on windows machine and you should see the application displayed as shown below if no error or mistake.

Windows Machine Demo

Next change to Android Local Device build and select your android phone that is connected to the computer. Then click start without debugging.

Android Device Potrait Demo

Android Device Landscape Demo

Search Movies

Open the MoviesPage.xaml and add the SearchCommandParameter and SearchCommand to the SearchBar tag.

MoviesPage.xaml

<SearchBar
    x:Name="searchBar"
    Grid.Row="0"
    BackgroundColor="Blue"
    CancelButtonColor="White"
    FontSize="18.0"
    Placeholder="Search Movies..."
    PlaceholderColor="White"
    SearchCommand="{Binding PerformSearch}"
    SearchCommandParameter="{Binding Text, Source={x:Reference searchBar}}"
    TextColor="White" />

This command will trigger PerformSearch function when user click on the search button on the keyboard while the SearchCommandParameter specifies the parameter that should be passed to the SearchCommand. Next open the MoviesPageViewModel.cs and add the command and handle the search.

MoviesPageViewModel.cs

public ICommand PerformSearch => new Command<string>(async (string query) =>
{
    toQuery = query;
    await FetchMoviesAsync("");
});

Next updated the FetchMovieAsync to use the variable toQuery instead of empty string "".

public async Task FetchMoviesAsync(string type)
{
    if (IsLoadingData)
    {
        return;
    }
    IsLoadingData = true;
    try
    {
        page = 1;
        listMovies.Clear();
        allMovies.Clear();
        listMovies = await repository.GetMovies(new MovieCall(type, page, toQuery));
        if (listMovies != null)
        {
            foreach (Movie movie in listMovies)
            {
                if (movie != null && !allMovies.Contains(movie))
                {
                    allMovies.Add(movie);
                }
            }
        }
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
    finally
    {
        IsLoadingData = false;
    }
}

Next add GetSearchUrl to the MovieCall.cs class. This allows the reuse of the api call function Fetch movie by returning the correct url based on if its a regular movies fetch or search.

MovieCall.cs

public string GetSearchUrl()
{
    return Query.Length < 1 ?
string.Format(Constant.API_FORMAT, ApiKeys.BASE_URL, Type, ApiKeys.API_KEY, Page)
: string.Format(Constant.SEARCH_FORMAT, ApiKeys.SEARCH, "", ApiKeys.API_KEY, Page, Query);
}

This only updates the url as explained previously by passing the search link or movielist link and return as url string.

Next open the MovieRepository and update the GetMovies function.

MovieRepository.cs

public async Task<ObservableCollection<Movie>?> GetMovies(MovieCall movieCall)
{
    String url = movieCall.GetSearchUrl();
    HttpClient httpClient = new HttpClient();
    try
    {
        HttpResponseMessage response = await httpClient.GetAsync(url);
        if (response.IsSuccessStatusCode)
        {
            string result = await response.Content.ReadAsStringAsync();
            MovieResponse movieResponse = JsonConvert.DeserializeObject<MovieResponse>(result);
            if(movieResponse != null)
            {
                return movieResponse.results;
            }

        }
    }
    catch (Exception e){
    Debug.Write(e.Message);
    }
    return null;
}
}

Now the url has been updated and passed to the Get call. Save and run the app. It should work as previously but now enter a search into the SearchBar then press search. Your movies will refresh and show results based on your search.

Search Query Demo

Search Result Demo

Pagination

Next we will add Pagination to the CollectionView. This means whenever the user reaches the end of the list a new movie list is requested and then loaded to the screen. In oder to do this we will add RemainingItemsThreshold and RemainingItemsThresholdReachedCommand .

Open the MoviesPage.xaml and add both properties to the CollectionView and bind to their respectful commands.

MoviesPage.xaml

<CollectionView
    ItemsLayout="{Binding GridItemLayout}"
    ItemsSource="{Binding AllMovies}"
    RemainingItemsThreshold="{Binding MovieTreshold}"
    RemainingItemsThresholdReachedCommand="{Binding MovieTresholdReachedCommand}"
    SelectedItem="{Binding SelectedMovie}"
    SelectionMode="Single">

Next we will create the MovieTreshold and MovieTresholdReachedCommand in the viewModel above the RefreshMovieCommand.

MoviesPageViewModel.cs

public Command MovieTresholdReachedCommand { get; set; }

private int _movieTreshold;
public int MovieTreshold
{
    get => _movieTreshold;
    set => SetProperty(ref _movieTreshold, value);
}

Next in the MoviesPageViewModel constructor set MovieTreshold to 1 and initialize MovieTresholdReachedCommand.

public MoviesPageViewModel()
{
    allMovies = [];
    GridItemLayout = new GridItemsLayout(DeviceDisplay.Current.MainDisplayInfo.Orientation == DisplayOrientation.Portrait ? 2 : 4, ItemsLayoutOrientation.Vertical);
    MovieTreshold = 1;
    MovieTresholdReachedCommand = new Command(async () => await MoviesTresholdReached());
....
}

Also set MovieTreshold to 1 when fetching movies in the FetchMovieAsync right above page = 1

FetchMoviesAsync

 public async Task FetchMoviesAsync(string type)
 {
     if (IsLoadingData)
     {
         return;
     }
     IsLoadingData = true;
     try
     {
         MovieTreshold = 1;
         page = 1;
         ...

The FetchMoviesAsync function and MoviesTresholdReached can be refactored to use SetSortedMovies. So create a new function SetSortedMovies and use in both these functions.

SetSortedMovies

private void SetSortedMovies(bool isThreshold)
{
    if (listMovies != null)
    {
        foreach (Movie movie in listMovies)
        {
            if (movie != null && !allMovies.Contains(movie))
            {
                allMovies.Add(movie);
            }
        }
        if (isThreshold) {
            if (listMovies.Count == 0)
            {
                MovieTreshold = -1;
                return;
            }
        }
    }
}

So as to avoid any confusion here is the full MoviesPage.xaml and MoviesPageViewModel.cs

MoviesPage.xaml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="MauiMovieApp.Views.MoviesPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiMovieApp"
    xmlns:viewmodel="clr-namespace:MauiMovieApp.Viewmodels"
    Shell.NavBarIsVisible="False">
    <ContentPage.BindingContext>
        <viewmodel:MoviesPageViewModel />
    </ContentPage.BindingContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="40" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <SearchBar
            x:Name="searchBar"
            Grid.Row="0"
            BackgroundColor="Blue"
            CancelButtonColor="White"
            FontSize="18.0"
            Placeholder="Search Movies..."
            PlaceholderColor="White"
            SearchCommand="{Binding PerformSearch}"
            SearchCommandParameter="{Binding Text, Source={x:Reference searchBar}}"
            TextColor="White" />

        <RefreshView
            Grid.Row="1"
            Margin="0,0,0,0"
            Command="{Binding RefreshMoviesCommand}"
            IsRefreshing="{Binding IsRefreshing}">
            <CollectionView
                ItemsLayout="{Binding GridItemLayout}"
                ItemsSource="{Binding AllMovies}"
                RemainingItemsThreshold="{Binding MovieTreshold}"
                RemainingItemsThresholdReachedCommand="{Binding MovieTresholdReachedCommand}"
                SelectedItem="{Binding SelectedMovie}"
                SelectionMode="Single">
                <CollectionView.ItemTemplate>

                    <DataTemplate>
                        <Grid Margin="4">
                            <Grid.RowDefinitions>
                                <RowDefinition Height="*" />
                                <RowDefinition Height="30" />
                                <RowDefinition Height="1" />
                            </Grid.RowDefinitions>

                            <Image
                                Grid.Row="0"
                                Margin="0"
                                Aspect="AspectFill"
                                HeightRequest="260"
                                WidthRequest="200">
                                <Image.Source>
                                    <UriImageSource CacheValidity="10:00:00:00" Uri="{Binding poster_path, StringFormat='https://image.tmdb.org/t/p/w500/{0}'}" />
                                </Image.Source>
                            </Image>

                            <Label
                                Grid.Row="1"
                                FontSize="14"
                                HorizontalTextAlignment="Center"
                                LineBreakMode="TailTruncation"
                                Text="{Binding title}"
                                TextColor="Black"
                                VerticalTextAlignment="Center" />

                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </RefreshView>

        <ActivityIndicator
            Grid.Row="1"
            HeightRequest="30"
            HorizontalOptions="Center"
            IsRunning="{Binding IsLoadingData}"
            VerticalOptions="End"
            WidthRequest="30" />

    </Grid>
</ContentPage>

MoviesPageViewModel.cs

using MauiMovieApp.Models;
using MauiMovieApp.Repository;
using MauiMovieApp.Constants;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using System.Diagnostics;

namespace MauiMovieApp.Viewmodels
{
    public class MoviesPageViewModel : INotifyPropertyChanged
    {
        private int page = 1;
        private string toQuery = "";

        private ObservableCollection<Movie> listMovies = new ObservableCollection<Movie>();
        IMovieRepository repository = DependencyService.Get<IMovieRepository>();

        public ICommand PerformSearch => new Command<string>(async (string query) =>
        {
            toQuery = query;
            await FetchMoviesAsync("");
        });

        public Command MovieTresholdReachedCommand { get; set; }

        private int _movieTreshold;
        public int MovieTreshold
        {
            get => _movieTreshold;
            set => SetProperty(ref _movieTreshold, value);
        }

        public Command RefreshMoviesCommand { get; set; }

        private bool _isRefreshing;

        public bool IsRefreshing
        {
            get => _isRefreshing;
            set => SetProperty(ref _isRefreshing, value);
        }

        private GridItemsLayout _gridItemLayout;
        public GridItemsLayout GridItemLayout { 
        get => _gridItemLayout;
            set => SetProperty(ref _gridItemLayout, value);
        }

        public ObservableCollection<Movie> allMovies;
        public ObservableCollection<Movie> AllMovies
        {
            get => allMovies;
            set => SetProperty(ref allMovies, value);
        }

        private bool isLoadingData;

        public bool IsLoadingData
        {
            get => isLoadingData;
            set => SetProperty(ref isLoadingData, value);
        }

        public Movie _selectedMovie;

        public event PropertyChangedEventHandler? PropertyChanged;

        public Movie SelectedMovie
        {
            get { return _selectedMovie; }
            set
            {
                _selectedMovie = value;
                OpenMovieDetail();
            }
        }

        public void OpenMovieDetail()
        {
            //todo
        }

        public MoviesPageViewModel()
        {
            allMovies = [];
            GridItemLayout = new GridItemsLayout(DeviceDisplay.Current.MainDisplayInfo.Orientation == DisplayOrientation.Portrait ? 2 : 4, ItemsLayoutOrientation.Vertical);
            MovieTreshold = 1;
            _ = FetchMoviesAsync(ApiKeys.NOW_PLAYING);
            MovieTresholdReachedCommand = new Command(async () => await MoviesTresholdReached());
            RefreshMoviesCommand = new Command(async () =>
            {
                await FetchMoviesAsync(ApiKeys.NOW_PLAYING);
                IsRefreshing = false;
            });
        }

        bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
        {
            if (Object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public async Task FetchMoviesAsync(string type)
        {
            if (IsLoadingData)
            {
                return;
            }
            IsLoadingData = true;
            try
            {
                MovieTreshold = 1;
                page = 1;
                listMovies.Clear();
                allMovies.Clear();
                listMovies = await repository.GetMovies(new MovieCall(type, page, toQuery.Trim()));
                SetSortedMovies(false);
            }
            catch (Exception e)
            {
                Debug.WriteLine(e.Message);
            }
            finally
            {
                IsLoadingData = false;
            }
        }

        private async Task MoviesTresholdReached()
        {
            if (IsLoadingData)
            {
                return;
            }
            page++;
            IsLoadingData = true;
            try
            {
                listMovies = toQuery.Trim().Length < 1 ? await repository.GetMovies(new MovieCall(ApiKeys.NOW_PLAYING, page, "")) : listMovies = await repository.GetMovies(new MovieCall("", page, toQuery.Trim()));
                SetSortedMovies(true);
                if (listMovies != null && listMovies.Count == 0)
                {
                    MovieTreshold = -1;
                    return;
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e.Message);
            }
            finally
            {
                IsLoadingData = false;
            }
        }

        private void SetSortedMovies(bool isThreshold)
        {
            if (listMovies != null)
            {
                foreach (Movie movie in listMovies)
                {
                    if (movie != null && !allMovies.Contains(movie))
                    {
                        allMovies.Add(movie);
                    }
                }
                if (isThreshold) {
                    if (listMovies.Count == 0)
                    {
                        MovieTreshold = -1;
                        return;
                    }
                }
            }
        }

        internal void SetNoOfItems(int no)
        {
            GridItemLayout.Span = no;
        }
    }
}

Now everything works as it should. However, when run the windows version there is a bug that it doesnt load more when scroll to the bottom of the list couple of times. A workaround i found is to add the Scrolled property to the CollectionView and also set ItemsUpdatingScrollMode like below:

<CollectionView
    ItemsLayout="{Binding GridItemLayout}"
    ItemsSource="{Binding AllMovies}"
    RemainingItemsThreshold="{Binding MovieTreshold}"
    RemainingItemsThresholdReachedCommand="{Binding MovieTresholdReachedCommand}"
    Scrolled="CollectionView_Scrolled"
    ItemsUpdatingScrollMode="KeepScrollOffset"
    ....

Then in the MoviesPage.xaml.cs create the CollectionView_Scrolled

MoviesPage.xaml.cs

private void CollectionView_Scrolled(object sender, ItemsViewScrolledEventArgs e)
{
    if (DeviceInfo.Current.Platform != DevicePlatform.WinUI)
    {
        return;
    }

    //NOTE: workaround on windows to fire collectionview itemthresholdreached command, because it does not work on windows
    if (sender is CollectionView cv && cv is IElementController element)
    {
        var count = element.LogicalChildren.Count;
        if (e.LastVisibleItemIndex + 1 - count + cv.RemainingItemsThreshold >= 0)
        {
            if (cv.RemainingItemsThresholdReachedCommand.CanExecute(null))
            {
                cv.RemainingItemsThresholdReachedCommand.Execute(null);
            }
        }
    }
}

Now it should work as expected. Maui is still being flushed out so minor bugs may occur.

Desktop Demo

https://drive.google.com/file/d/1dsxAn-UCdGaNY41FsSEKq59IJUz7qust/view?usp=sharing

Android Demo

https://drive.google.com/file/d/1IZM7oBvEkTWZhelqI9Dfy8O5lpUDN0km/view?usp=sharing

You can also access the git repository here

In the next part I will add the MovieDetails Screen, extra features like types of movies and perhaps a database. Link will be added <<here>> shortly.

Linked

Git


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí