Instead of displaying just one recipe, our application now will show a list of them -obtaining the recipe collection from a URL-, the user will be able to select one and see its details. Let's implement the MVVM pattern in our app!
Before that, let's do a small change. Our current view (RecipeListView
) doesn't actually display a list of recipes; instead, it shows the detail of one recipe. So let's rename it to a proper name:
- Rename the
XAML
file (the C# file is renamed automatically)
- Rename the class name in the
XAML
view.
- Rename the class name and constructor in the C# file:
- Rename the class reference in
AppShell.xaml
:
Now we are ready.
CommunityToolkit.MVVM
is a NuGet package that simplifies the implementation of MVVM in our application by auto-generating source code, thus writing less code. Let's incorporate it into our app!
- Right-click on your project name and choose Manage NuGet Packages:
- Click on Browse, search "communitytoolkit.mvvm", select the right package, and install it on your project. Accept the terms and license.
The MVVM pattern starts with Models.
- Add a new folder to the project: Models, then create a new C# class there, Recipe:
- This class represents the information we will have available at some point for a recipe. This is the code:
namespace RefreshingRecipes.Models
{
public class Recipe
{
public int RecipeId { get; set; }
public string RecipeName { get; set; }
public string RecipePhotoUrl { get; set; }
public string RecipeInstructions { get; set; }
}
}
Usually, an application obtains information from another source (a file, a REST API, a local database). A Services layer that incorporates a reusable class can be created for this. Even better, interfaces can be included as well in order to have a clean separation from the actual implementation (for example, we can obtain the list of recipes in our app from either an online resource or a local file depending on the availability of an Internet connection).
- Add a new folder to the project: Services that will include two new elements: an interface (IRecipeService) and a class (RecipeService):
- As previously mentioned, an interface provides a clean separation between the definition of a requirement (functionality) and the actual implementation (sending a request to an online resource). Let's define the "contract" (specifications) in the interface, which basically consists of one method,
GetRecipes
, which must return anIEnumerable
ofRecipe
objects:
using RefreshingRecipes.Models;
namespace RefreshingRecipes.Services
{
public interface IRecipeService
{
Task<IEnumerable<Recipe>> GetRecipes();
}
}
- Now, let's write the code for the class, which implements the above interface. As you can observe, the specifics on sending a request to a public URL are provided. The class meets all requirements defined in the interface, that is, the method
GetRecipes
, which returns anIEnumerable
ofRecipe
objects.
using System.Net.Http.Json;
using RefreshingRecipes.Models;
namespace RefreshingRecipes.Services
{
public class RecipeService : IRecipeService
{
HttpClient httpClient;
public RecipeService()
{
this.httpClient = new HttpClient();
}
public async Task<IEnumerable<Recipe>> GetRecipes()
{
var response = await httpClient.GetAsync("https://gist.githubusercontent.com/icebeam7/a6c1c7523e67272e294204aff0b115cc/raw/84d3d9c1b25d924630b15d901fa38dbbd13f20fc/recipes.json");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<IEnumerable<Recipe>>();
return default;
}
}
}
- Now, we take advantage of the built-in Dependency Injection container to register the interface service and its implementation in
MauiProgram.cs
. Add the namespace forServices
and register both elements withAddSingleton
beforebuilder.Build()
. Singleton means that there will be a single instance of the class, and the container will return a reference to that existing object when it is required. Code goes as follows:
using RefreshingRecipes.Services;
...
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
...
builder.Services.AddSingleton<IRecipeService, RecipeService>();
...
Back to the MVVM implementation, let's write the ViewModels for the app.
- Add a new folder to the project: ViewModels, which has three new C# classes: BaseViewModel, RecipeCollectionViewModel, RecipeDetailViewModel:
- Let's start with BaseViewModel, which is a base class for all our view models, and it includes three properties that can be used by children classes. This class inherits from
ObservableObject
, which implements theINotifyPropertyChanged
interface, meaning that bindings will be notified when there's a change in the value of these properties. Its code is:
using CommunityToolkit.Mvvm.ComponentModel;
namespace RefreshingRecipes.ViewModels
{
public partial class BaseViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
bool isBusy;
[ObservableProperty]
string title;
public bool IsNotBusy => !IsBusy;
}
}
- The RecipeCollectionViewModel class will be the View Model of a page that obtains and displays a list of recipes from the service that was created earlier. It inherits from
BaseViewModel
, defines a read-only property for the recipe collection, and another property for the recipe selected by the user. Moreover, it defines two commands, one that gets the recipe collection, and a second one that navigates to a second page. It is worth mentioning that the interfaceIRecipeService
is injected into the constructor, which is allowed since it was previously registered in the dependency injection container. The code goes as follows:
using System.Collections.ObjectModel;
using RefreshingRecipes.Views;
using RefreshingRecipes.Models;
using RefreshingRecipes.Services;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.ComponentModel;
namespace RefreshingRecipes.ViewModels
{
public partial class RecipeCollectionViewModel : BaseViewModel
{
public ObservableCollection<Recipe> Recipes { get; } = new();
IRecipeService recipeService;
[ObservableProperty]
Recipe selectedRecipe;
public RecipeCollectionViewModel(IRecipeService recipeService)
{
Title = "Recipe List";
this.recipeService = recipeService;
}
[RelayCommand]
async Task GetRecipesAsync()
{
if (IsBusy)
return;
try
{
IsBusy = true;
var recipes = (await recipeService.GetRecipes()).ToList();
if (Recipes.Count != 0)
Recipes.Clear();
foreach (var recipe in recipes)
Recipes.Add(recipe);
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error!", ex.Message, "OK");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
async Task GoToDetails()
{
if (SelectedRecipe == null)
return;
var data = new Dictionary<string, object>
{
{"Recipe", SelectedRecipe }
};
await Shell.Current.GoToAsync(nameof(RecipeDetailView), true, data);
}
}
}
- The last viewmodel to be implemented is RecipeDetailViewModel, which simply defines a property for the recipe that will be displayed. The
QueryProperty
attribute defines an argument that is sent from another page, that is, the selected recipe. This is the code:
using CommunityToolkit.Mvvm.ComponentModel;
using RefreshingRecipes.Models;
namespace RefreshingRecipes.ViewModels
{
[QueryProperty(nameof(Recipe), "Recipe")]
public partial class RecipeDetailViewModel : BaseViewModel
{
public RecipeDetailViewModel()
{
}
[ObservableProperty]
Recipe recipe;
}
}
- The dependency injection container is great for creating view model instances because we want to inject them later in our views, so let's register them in
MauiProgram.cs
. Add the namespace forViewModels
folder and register the view models withAddTransient
(an object is created each time it is required) orAddSingleton
(one single instance during the app lifecycle) beforebuilder.Build()
:
...
using RefreshingRecipes.ViewModels;
...
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
...
builder.Services.AddSingleton<RecipeCollectionViewModel>();
builder.Services.AddTransient<RecipeDetailViewModel>();
...
The last element from MVVM is Views, which stands for the UI.
- We already have the Views folder, let's just add a new
ContentPage
with the name RecipeCollectionView:
- This page obtains and shows a list of recipes. First, let's inject the associated view model in the constructor and set it as the page's
BindingContext
in the C# code:
using RefreshingRecipes.ViewModels;
namespace RefreshingRecipes.Views;
public partial class RecipeCollectionView : ContentPage
{
public RecipeCollectionView(RecipeCollectionViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
- Then, we define the UI with 3 elements: A
CollectionView
that displays the recipe list, aButton
that obtains the collection from a URL, and anActivityIndicator
that shows a loading animation while the data is transferred from the Internet to our app. The corresponding XAML code goes as follows, please notice the different bindings to theRecipeCollectionViewModel
properties and commands in each of the 3 UI elements:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="RefreshingRecipes.Views.RecipeCollectionView"
xmlns:vm="clr-namespace:RefreshingRecipes.ViewModels"
xmlns:model="clr-namespace:RefreshingRecipes.Models"
x:DataType="vm:RecipeCollectionViewModel"
Title="{Binding Title}">
<Grid Margin="5"
RowDefinitions="*,Auto"
RowSpacing="0">
<CollectionView ItemsSource="{Binding Recipes}"
SelectionMode="Single"
SelectedItem="{Binding SelectedRecipe}"
SelectionChangedCommand="{Binding GoToDetailsCommand}"
BackgroundColor="Transparent">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="model:Recipe">
<HorizontalStackLayout Spacing="10">
<Image WidthRequest="300"
HeightRequest="200"
Source="{Binding RecipePhotoUrl}"
Aspect="AspectFill"/>
<Label Text="{Binding RecipeName}"
FontSize="Medium"
VerticalOptions="Center"
TextColor="Black"/>
</HorizontalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button
Grid.Row="1"
Margin="8"
Command="{Binding GetRecipesCommand}"
Text="Get Recipes" />
<ActivityIndicator
Grid.RowSpan="2"
HorizontalOptions="FillAndExpand"
IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
VerticalOptions="CenterAndExpand" />
</Grid>
- Likewise, we are setting the
BindingContext
forRecipeDetailView
page with a constructor injection. Challenge: Can you implement theShareButton
functionality as a command in the view model?
using RefreshingRecipes.ViewModels;
namespace RefreshingRecipes.Views;
public partial class RecipeDetailView : ContentPage
{
public RecipeDetailView(RecipeDetailViewModel vm)
{
InitializeComponent();
BindingContext = vm;
ShareButton.Clicked += async (s, a) =>
{
await Share.Default.RequestAsync(new ShareTextRequest
{
Text = vm.Recipe.RecipeInstructions,
Title = vm.Recipe.RecipeName
});
};
}
}
- We already have the UI for this detail view. However, we still need to bind the UI elements to the
Recipe
property fromRecipeDetailViewModel
and the model properties. So let's set the bindings inXAML
like this:
First, add namespaces and modify the title:
xmlns:vm="clr-namespace:RefreshingRecipes.ViewModels"
xmlns:model="clr-namespace:RefreshingRecipes.Models"
x:DataType="vm:RecipeDetailViewModel"
Title="{Binding Recipe.RecipeName}">
Identify the Source
property of the Image
. Instead of using a static value, we'll use the one from the selected recipe:
<Image
...
Source="{Binding Recipe.RecipePhotoUrl}"
/>
And finally, replace the Text
value for the Label
with the name of the selected recipe:
<Label Text="{Binding Recipe.RecipeName}"
...
/>
- Views can ve registered an resolved in
MauiProgram.cs
the same way we did it before for services and view models. So, add the namespace forViews
folder and register the views withAddTransient
beforebuilder.Build()
:
...
using RefreshingRecipes.Views;
...
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
...
builder.Services.AddTransient<RecipeCollectionView>();
builder.Services.AddTransient<RecipeDetailView>();
...
- We want now to show a list of recipes when the application runs. In order to do that, modify the
ContentTemplate
for the onlyShellContent
inAppShell.xaml
so it displays theRecipeCollectionView
page at the beginning:
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:RecipeCollectionView}"
Route="Recipes" />
- And we also must register a new
Route
inAppShell.xaml.cs
inside the constructor that is used when a recipe is selected from the list, enabling navigation to the details view:
using RefreshingRecipes.Views;
namespace RefreshingRecipes;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(RecipeDetailView), typeof(RecipeDetailView));
}
}
- That's it! We can now test the application. Here are the results:
First, this is the list of recipes:
When you tap on one, the app navigates to a second view with the recipe details:
Congratulations! You have finished Part 3! Let's continue and learn about local storage in Part 4.
Thanks to Bryan Oroxon for the following implementation.
- Add two new Color definitions in
Resources/Styles/Colors.xaml
:
<Color x:Key="Violet900">#4b05ad</Color>
<Color x:Key="BlueGem900">#2b0b98</Color>
- Modify the
ActivityIndicator
style inResources/Styles/Styles.xaml
, replacing the light and dark resource colors:
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Blue300Accent}, Dark={StaticResource Cyan100Accent}}" />
</Style>
You can also modify the Shell
style:
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource BlueGem900}, Dark={StaticResource BlueGem900}}" />
<Setter Property="Shell.ForegroundColor" Value="{OnPlatform WinUI={StaticResource Primary}, Default={StaticResource White}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Blue300Accent}, Dark={StaticResource Cyan100Accent}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray600}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
-
Take a look at the improved
RecipeCollectionView.xaml
, whereAppThemeBinding
is implemented for theDataTemplate
part of theCollectionView
and for the button. -
Run the app. This is how it should look: