Mobile Application Development Tutorial - MAUI
About This Tutorial
You must have an ABP Team or a higher license to be able to create a mobile application.
This tutorial assumes that you have completed the Web Application Development tutorial and built an ABP based application named Acme.BookStore
with MAUI as the mobile option. Therefore, if you haven't completed the Web Application Development tutorial, you either need to complete it or download the source code from down below and follow this tutorial.
In this tutorial, we will only focus on the UI side of the Acme.BookStore
application and we will implement the CRUD operations for a MAUI mobile application. This tutorial follows the MVVM (Model-View-ViewModel) Pattern , which separates the UI from the business logic of an application.
Download the Source Code
You can use the following link to download the source code of the application described in this article:
If you encounter the "filename too long" or "unzip" error on Windows, please see this guide.
Create the Authors Page - List & Delete Authors
Create a content page, AuthorsPage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
AuthorsPage.xaml
<?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="Acme.BookStore.Maui.Pages.AuthorsPage"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:author="clr-namespace:Acme.BookStore.Authors;assembly=Acme.BookStore.Application.Contracts"
xmlns:u="http://schemas.enisn-projects.io/dotnet/maui/uraniumui"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Name="page"
x:DataType="viewModels:AuthorPageViewModel"
Title="{ext:Translate Authors}">
<ContentPage.Behaviors>
<toolkit:EventToCommandBehavior EventName="Appearing" Command="{Binding RefreshCommand}" />
</ContentPage.Behaviors>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate NewAuthor,StringFormat='+ {0}'}"
Command="{Binding OpenCreateModalCommand}"
IconImageSource="{OnIdiom Desktop={FontImageSource FontFamily=MaterialRegular, Glyph={x:Static u:MaterialRegular.Add}}}"/>
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*" Padding="16,16,16,0">
<!-- Search -->
<Frame BorderColor="Transparent" Padding="0" HasShadow="False">
<SearchBar Text="{Binding Input.Filter}" SearchCommand="{Binding RefreshCommand}" Placeholder="{ext:Translate Search}"/>
</Frame>
<RefreshView Grid.Row="1"
IsRefreshing="{Binding IsBusy}"
Command="{Binding RefreshCommand}">
<CollectionView
ItemsSource="{Binding Items}"
SelectionMode="None">
<CollectionView.Header>
<BoxView HeightRequest="16" Color="Transparent" />
</CollectionView.Header>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="author:AuthorDto">
<Grid ColumnDefinitions="Auto,*,Auto" Padding="4,0" Margin="0,8" ColumnSpacing="10">
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding ShortBio}" StyleClass="muted" />
</VerticalStackLayout>
<ImageButton Grid.Column="2" VerticalOptions="Center" Margin="0,16"
Command="{Binding BindingContext.ShowActionsCommand, Source={x:Reference page}}"
CommandParameter="{Binding .}" HeightRequest="24">
<ImageButton.Source>
<FontImageSource FontFamily="MaterialRegular"
Glyph="{x:Static u:MaterialRegular.More_vert}"
Color="{AppThemeBinding Light={StaticResource ForegroundDark}, Dark={StaticResource ForegroundLight}}" />
</ImageButton.Source>
</ImageButton>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.Footer>
<VerticalStackLayout>
<ActivityIndicator HorizontalOptions="Center"
IsRunning="{Binding IsLoadingMore}"
Margin="20"/>
<ContentView Margin="0,0,0,8" IsVisible="{OnIdiom Default=False, Desktop=True}" HorizontalOptions="Center">
<Button IsVisible="{Binding CanLoadMore}"
StyleClass="TextButton" Text="{ext:Translate LoadMore}"
Command="{Binding LoadMoreCommand}"/>
</ContentView>
</VerticalStackLayout>
</CollectionView.Footer>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
This is a simple page that lists Authors, allows opening a create modal to create a new author, editing an existing one and deleting one.
AuthorsPage.xaml.cs
Let's create the AuthorsPage.xaml.cs
code-behind class and copy paste the content below:
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class AuthorsPage : ContentPage, ITransientDependency
{
public AuthorsPage(AuthorPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
Here, we register our pages with a Transient
lifetime, so we can use it later on for navigation purposes and indicating the binding source (BindingContext
) as the AuthorPageViewModel
to get full benefit from the MVVM pattern. We haven't created the AuthorPageViewModel
class yet, so let's create it.
AuthorPageViewModel.cs
Create a view model class, AuthorPageViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Authors;
using Acme.BookStore.Maui.Messages;
using Acme.BookStore.Maui.Pages;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System.Collections.ObjectModel;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Http.Client;
using Volo.Abp.Threading;
namespace Acme.BookStore.Maui.ViewModels
{
public partial class AuthorPageViewModel : BookStoreViewModelBase,
IRecipient<AuthorCreateMessage>, //create
IRecipient<AuthorEditMessage>, //edit
ITransientDependency
{
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
bool isLoadingMore;
[ObservableProperty]
bool canLoadMore;
public GetAuthorListDto Input { get; } = new();
public ObservableCollection<AuthorDto> Items { get; } = new();
protected IAuthorAppService AuthorAppService { get; }
protected SemaphoreSlim SemaphoreSlim { get; } = new SemaphoreSlim(1, 1);
public AuthorPageViewModel(IAuthorAppService authorAppService)
{
AuthorAppService = authorAppService;
WeakReferenceMessenger.Default.Register<AuthorCreateMessage>(this);
WeakReferenceMessenger.Default.Register<AuthorEditMessage>(this);
}
[RelayCommand]
async Task OpenCreateModal()
{
await Shell.Current.GoToAsync(nameof(AuthorCreatePage));
}
[RelayCommand]
async Task OpenEditModal(Guid authorId)
{
await Shell.Current.GoToAsync($"{nameof(AuthorEditPage)}?Id={authorId}");
}
[RelayCommand]
async Task Refresh()
{
await GetAuthorsAsync();
}
[RelayCommand]
async Task ShowActions(AuthorDto author)
{
var result = await App.Current!.MainPage!.DisplayActionSheet(
L["Actions"],
L["Cancel"],
null,
L["Edit"], L["Delete"]);
if (result == L["Edit"])
{
await OpenEditModal(author.Id);
}
if (result == L["Delete"])
{
await Delete(author);
}
}
[RelayCommand]
async Task Delete(AuthorDto author)
{
var confirmed = await Shell.Current.CurrentPage.DisplayAlert(
L["Delete"],
string.Format(L["AuthorDeletionConfirmationMessage"].Value, author.Name),
L["Delete"],
L["Cancel"]);
if (!confirmed)
{
return;
}
try
{
await AuthorAppService.DeleteAsync(author.Id);
}
catch (AbpRemoteCallException remoteException)
{
HandleException(remoteException);
}
await GetAuthorsAsync();
}
private async Task GetAuthorsAsync()
{
IsBusy = true;
try
{
Input.SkipCount = 0;
var result = await AuthorAppService.GetListAsync(Input);
Items.Clear();
foreach (var user in result.Items)
{
Items.Add(user);
}
CanLoadMore = result.Items.Count >= Input.MaxResultCount;
}
catch (AbpRemoteCallException remoteException)
{
HandleException(remoteException);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
async Task LoadMore()
{
if (!CanLoadMore)
{
return;
}
try
{
using (await SemaphoreSlim.LockAsync())
{
IsLoadingMore = true;
Input.SkipCount += Input.MaxResultCount;
var result = await AuthorAppService.GetListAsync(Input);
CanLoadMore = result.Items.Count >= Input.MaxResultCount;
foreach (var tenant in result.Items)
{
Items.Add(tenant);
}
}
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsLoadingMore = false;
}
}
public void Receive(AuthorCreateMessage message)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await GetAuthorsAsync();
});
}
public void Receive(AuthorEditMessage message)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await GetAuthorsAsync();
});
}
}
}
The AuthorPageViewModel
class is where all the logic behind the Authors
page lays. Here, we do the following steps:
- We have fetched all authors from the database and set those records into the
Items
property, which is a type ofObservableCollection<AuthorDto>
, so whenever the authors list changes then the CollectionView will be refreshed. - We have defined the
OpenCreateModal
andOpenEditModal
methods to navigate to the create modal and edit modal pages (we will create them in the following sections). - We have defined the
ShowActions
method to allow editing or deleting a specific author. - We have created the
Delete
method, which deletes a specific author and re-renders the grid. - And finally, we have implemented the
IRecipient<AuthorCreateMessage>
andIRecipient<AuthorEditMessage>
interfaces to refresh the grid after creating a new author or editing an existing one. (We will create theAuthorCreateMessage
andAuthorEditMessage
classes in the following sections)
Registering Author Page Routes
Open the AppShell.xaml.cs
file under the Acme.BookStore.Maui
project and register the create modal and edit modal pages' routes:
using Acme.BookStore.Maui.Pages;
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui;
public partial class AppShell : Shell, ITransientDependency
{
public AppShell(ShellViewModel vm)
{
BindingContext = vm;
InitializeComponent();
//other routes...
//Authors
Routing.RegisterRoute(nameof(AuthorCreatePage), typeof(AuthorCreatePage));
Routing.RegisterRoute(nameof(AuthorEditPage), typeof(AuthorEditPage));
}
}
Since we need to navigate to the create modal and edit modal pages whenever the action buttons are clicked, we need to register those pages with their routes. We can do this in the AppShell.xaml.cs
file, which is responsible for providing the navigation of the application.
Creating a New Author
Create a new content page, AuthorCreatePage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
AuthorCreatePage.xaml
<?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"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
x:Class="Acme.BookStore.Maui.Pages.AuthorCreatePage"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="viewModels:AuthorCreateViewModel"
Title="{ext:Translate NewAuthor}">
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate Cancel}" Command="{Binding CancelCommand}"/>
</ContentPage.ToolbarItems>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Name}" />
<Entry Text="{Binding Author.Name}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate ShortBio}" />
<Entry Text="{Binding Author.ShortBio}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate BirthDate}" />
<DatePicker Date="{Binding Author.BirthDate}"/>
</VerticalStackLayout>
</Border>
<Grid>
<Button Text="{ext:Translate Save}" Command="{Binding CreateCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
</Grid>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
In this page, we have defined the form elements that are needed to create an author such as Name
, ShortBio
and BirthDate
. Whenever a user clicks the Save button, the CreateCommand will be triggered and will create a new author, if the operation goes successfully.
Let's define the AuthorCreateViewModel
as the BindingContext of this page and then define the logic of the CreateCommand.
AuthorCreatePage.xaml.cs
Create the AuthorCreatePage.xaml.cs
code-behind class and copy paste the content below:
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class AuthorCreatePage : ContentPage, ITransientDependency
{
public AuthorCreatePage(AuthorCreateViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
AuthorCreateViewModel.cs
Create a view model class, AuthorCreateViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Authors;
using Acme.BookStore.Maui.Messages;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.ViewModels
{
public partial class AuthorCreateViewModel : BookStoreViewModelBase, ITransientDependency
{
[ObservableProperty]
private bool isBusy;
public CreateAuthorDto Author { get; set; } = new();
protected IAuthorAppService AuthorAppService { get; }
public AuthorCreateViewModel(IAuthorAppService authorAppService)
{
AuthorAppService = authorAppService;
Author.BirthDate = DateTime.Now;
}
[RelayCommand]
async Task Cancel()
{
await Shell.Current.GoToAsync("..");
}
[RelayCommand]
async Task Create()
{
try
{
IsBusy = true;
await AuthorAppService.CreateAsync(Author);
await Shell.Current.GoToAsync("..");
WeakReferenceMessenger.Default.Send(new AuthorCreateMessage(Author));
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsBusy = false;
}
}
}
}
Here, we do the following steps:
- This class simply injects and uses the
IAuthorAppService
to create a new author. - We have created two methods for the actions in the AuthorCreatePage, which are the
Cancel
andCreate
methods. - The
Cancel
method simply returns to the previous page, AuthorPage. - The
Create
method creates a new author whenever the Save button is clicked on the AuthorCreatePage.
AuthorCreateMessage.cs
Create a class, AuthorCreateMessage
under the Messages
folder of the project and change the content as follows:
using Acme.BookStore.Authors;
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Acme.BookStore.Maui.Messages
{
public class AuthorCreateMessage : ValueChangedMessage<CreateAuthorDto>
{
public AuthorCreateMessage(CreateAuthorDto value) : base(value)
{
}
}
}
This class is used to represent a message that we're gonna use to trigger a return result after author creation. Then, we subscribe to this message and update the grid on the AuthorsPage.
Updating an Author
Create a new content page, AuthorEditPage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
AuthorEditPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Acme.BookStore.Maui.Pages.AuthorEditPage"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:identity="clr-namespace:Acme.BookStore.Authors;assembly=Acme.BookStore.Application.Contracts"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="viewModels:AuthorEditViewModel"
Title="{ext:Translate Edit}">
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate Cancel}" Command="{Binding CancelCommand}"/>
</ContentPage.ToolbarItems>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Name}" />
<Entry Text="{Binding Author.Name}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate ShortBio}" />
<Entry Text="{Binding Author.ShortBio}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate BirthDate}" />
<DatePicker Date="{Binding Author.BirthDate}"/>
</VerticalStackLayout>
</Border>
<Grid>
<Button Text="{ext:Translate Save}" Command="{Binding UpdateCommand}" />
<ActivityIndicator IsRunning="{Binding IsSaving}" IsVisible="{Binding IsSaving}" />
</Grid>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
In this page, we have defined the form elements that are needed to edit an author such as Name
, ShortBio
and BirthDate
. Whenever a user clicks the Save button, the UpdateCommand will be triggered and will update an existing author, if the operation goes successfully.
Let's define the AuthorEditViewModel
as the BindingContext of this page and then define the logic of the UpdateCommand.
AuthorEditPage.xaml.cs
Create the AuthorEditPage.xaml.cs
code-behind class and copy paste the content below:
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class AuthorEditPage : ContentPage, ITransientDependency
{
public AuthorEditPage(AuthorEditViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
AuthorEditViewModel.cs
Create a view model class, AuthorEditViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Authors;
using Acme.BookStore.Maui.Messages;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.ViewModels
{
[QueryProperty("Id", "Id")]
public partial class AuthorEditViewModel : BookStoreViewModelBase, ITransientDependency
{
[ObservableProperty]
public string? id;
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isSaving;
[ObservableProperty]
private UpdateAuthorDto? author;
protected IAuthorAppService AuthorAppService { get; }
public AuthorEditViewModel(IAuthorAppService authorAppService)
{
AuthorAppService = authorAppService;
}
async partial void OnIdChanged(string? value)
{
IsBusy = true;
await GetAuthor();
IsBusy = false;
}
[RelayCommand]
async Task GetAuthor()
{
try
{
var authorId = Guid.Parse(Id!);
var authorDto = await AuthorAppService.GetAsync(authorId);
Author = new UpdateAuthorDto
{
BirthDate = authorDto.BirthDate,
Name = authorDto.Name,
ShortBio = authorDto.ShortBio
};
}
catch (Exception ex)
{
HandleException(ex);
}
}
[RelayCommand]
async Task Cancel()
{
await Shell.Current.GoToAsync("..");
}
[RelayCommand]
async Task Update()
{
try
{
IsSaving = true;
await AuthorAppService.UpdateAsync(Guid.Parse(Id!), Author!);
await Shell.Current.GoToAsync("..");
WeakReferenceMessenger.Default.Send(new AuthorEditMessage(Author!));
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsSaving = false;
}
}
}
}
Here, we do the following steps:
- This class simply injects and uses the
IAuthorAppService
to update an existing author. - We have created three methods for the actions in the AuthorEditPage, which are the
GetAuthor
,Cancel
andUpdate
methods. - The
GetAuthor
method is used to get the author from theId
query parameter and set it to theAuthor
property. - The
Cancel
method simply returns to the previous page, AuthorPage. - The
Update
method updates an existing author whenever the Save button is clicked on the AuthorEditPage.
AuthorEditMessage.cs
Create a class, AuthorEditMessage
under the Messages
folder of the project and change the content as follows:
using Acme.BookStore.Authors;
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Acme.BookStore.Maui.Messages
{
public class AuthorEditMessage : ValueChangedMessage<UpdateAuthorDto>
{
public AuthorEditMessage(UpdateAuthorDto value) : base(value)
{
}
}
}
This class is used to represent a message that we're gonna use to trigger a return result after an author is updated. Then, we subscribe to this message and update the grid on the AuthorsPage.
Add Author Menu Item to the Main Menu
Open the AppShell.xaml
file and add the following code under the Settings menu item:
AppShell.xaml
<FlyoutItem Title="{ext:Translate Authors}" IsVisible="{Binding HasAuthorsPermission}"
Icon="{FontImageSource FontFamily=MaterialOutlined, Glyph={x:Static u:MaterialOutlined.Person_add}, Color={AppThemeBinding Light={StaticResource ForegroundDark}, Dark={StaticResource ForegroundLight}}}">
<Tab>
<ShellContent Route="authors"
ContentTemplate="{DataTemplate pages:AuthorsPage}"/>
</Tab>
</FlyoutItem>
This code block adds a new Authors menu item under the Settings menu item. We need to show this menu item only when the required permission is granted. So, let's update the ShellViewModel.cs
class and check if the permission is granted or not.
ShellViewModel.cs
public partial class ShellViewModel : BookStoreViewModelBase, ITransientDependency
{
//Add these two lines below
[ObservableProperty]
bool hasAuthorsPermission = true;
//...
[RelayCommand]
private async Task UpdatePermissions()
{
HasUsersPermission = await AuthorizationService.IsGrantedAsync(IdentityPermissions.Users.Default);
HasTenantsPermission = await AuthorizationService.IsGrantedAsync(SaasHostPermissions.Tenants.Default);
//Add the line below
HasAuthorsPermission = await AuthorizationService.IsGrantedAsync(BookStorePermissions.Authors.Default);
}
//...
}
Create the Books Page - List & Delete Books
Create a new content page, BooksPage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
BooksPage.xaml
<?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="Acme.BookStore.Maui.Pages.BooksPage"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:book="clr-namespace:Acme.BookStore.Books;assembly=Acme.BookStore.Application.Contracts"
xmlns:u="http://schemas.enisn-projects.io/dotnet/maui/uraniumui"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Name="page"
x:DataType="viewModels:BookPageViewModel"
Title="{ext:Translate Books}">
<ContentPage.Behaviors>
<toolkit:EventToCommandBehavior EventName="Appearing" Command="{Binding RefreshCommand}" />
</ContentPage.Behaviors>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate NewBook,StringFormat='+ {0}'}"
Command="{Binding OpenCreateModalCommand}"
IconImageSource="{OnIdiom Desktop={FontImageSource FontFamily=MaterialRegular, Glyph={x:Static u:MaterialRegular.Add}}}"/>
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*"
StyleClass="Max720"
Padding="16,16,16,0">
<!-- List -->
<RefreshView Grid.Row="1"
IsRefreshing="{Binding IsBusy}"
Command="{Binding RefreshCommand}">
<CollectionView
ItemsSource="{Binding Items}"
SelectionMode="None"
RemainingItemsThreshold="2"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
<CollectionView.Header>
<!-- Padding from top -->
<BoxView HeightRequest="16" Color="Transparent" />
</CollectionView.Header>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="book:BookDto">
<Grid ColumnDefinitions="*,Auto" Padding="4,0" Margin="0,8" HeightRequest="36" ColumnSpacing="10">
<VerticalStackLayout Grid.Column="0" VerticalOptions="Center">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Type}" StyleClass="muted" />
</VerticalStackLayout>
<ImageButton Grid.Column="1" VerticalOptions="Center" HeightRequest="24" WidthRequest="24" BackgroundColor="Transparent"
Command="{Binding BindingContext.ShowActionsCommand, Source={x:Reference page}}"
CommandParameter="{Binding .}">
<ImageButton.Source>
<FontImageSource FontFamily="MaterialRegular"
Glyph="{x:Static u:MaterialRegular.More_vert}"
Color="{AppThemeBinding Light={StaticResource ForegroundDark}, Dark={StaticResource ForegroundLight}}" />
</ImageButton.Source>
</ImageButton>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Image Source="empty.png"
MaximumWidthRequest="400"
HorizontalOptions="Center"
Opacity=".5"/>
</CollectionView.EmptyView>
<CollectionView.Footer>
<VerticalStackLayout>
<ActivityIndicator HorizontalOptions="Center"
IsRunning="{Binding IsLoadingMore}" IsVisible="{Binding IsLoadingMore}"
Margin="20"/>
<ContentView Margin="0,0,0,8" IsVisible="{OnIdiom Default=False, Desktop=True}" HorizontalOptions="Center">
<Button IsVisible="{Binding CanLoadMore}" StyleClass="TextButton" Text="{ext:Translate LoadMore}"
Command="{Binding LoadMoreCommand}" />
</ContentView>
</VerticalStackLayout>
</CollectionView.Footer>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
This is a simple page that lists books, allows opening a create modal to create a new book, editing an existing one and deleting one.
BooksPage.xaml.cs
Create the BooksPage.xaml.cs
code-behind class and copy paste content below:
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class BooksPage : ContentPage, ITransientDependency
{
public BooksPage(BookPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
BookPageViewModel.cs
Create a view model class, BookPageViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Books;
using Acme.BookStore.Maui.Messages;
using Acme.BookStore.Maui.Pages;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System.Collections.ObjectModel;
using Volo.Abp.Application.Dtos;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Http.Client;
using Volo.Abp.Threading;
namespace Acme.BookStore.Maui.ViewModels
{
public partial class BookPageViewModel : BookStoreViewModelBase,
IRecipient<BookCreateMessage>, //create
IRecipient<BookEditMessage>, //edit
ITransientDependency
{
[ObservableProperty]
bool isBusy;
[ObservableProperty]
bool isLoadingMore;
[ObservableProperty]
bool canLoadMore = true;
public ObservableCollection<BookDto> Items { get; } = new();
public PagedAndSortedResultRequestDto Input { get; } = new();
protected IBookAppService BookAppService { get; }
protected SemaphoreSlim SemaphoreSlim { get; } = new SemaphoreSlim(1, 1);
public BookPageViewModel(IBookAppService bookAppService)
{
BookAppService = bookAppService;
WeakReferenceMessenger.Default.Register<BookCreateMessage>(this);
WeakReferenceMessenger.Default.Register<BookEditMessage>(this);
}
[RelayCommand]
async Task OpenCreateModal()
{
await Shell.Current.GoToAsync(nameof(BookCreatePage));
}
[RelayCommand]
async Task OpenEditModal(Guid id)
{
await Shell.Current.GoToAsync($"{nameof(BookEditPage)}?Id={id}");
}
[RelayCommand]
async Task Refresh()
{
try
{
IsBusy = true;
using (await SemaphoreSlim.LockAsync())
{
Input.SkipCount = 0;
var books = await BookAppService.GetListAsync(Input);
Items.Clear();
foreach (var book in books.Items)
{
Items.Add(book);
}
CanLoadMore = books.Items.Count >= Input.MaxResultCount;
}
}
catch (AbpRemoteCallException remoteException)
{
HandleException(remoteException);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
async Task LoadMore()
{
if (!CanLoadMore)
{
return;
}
try
{
using (await SemaphoreSlim.LockAsync())
{
IsLoadingMore = true;
Input.SkipCount += Input.MaxResultCount;
var books = await BookAppService.GetListAsync(Input);
CanLoadMore = books.Items.Count >= Input.MaxResultCount;
foreach (var book in books.Items)
{
Items.Add(book);
}
}
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsLoadingMore = false;
}
}
[RelayCommand]
async Task ShowActions(BookDto entity)
{
var result = await App.Current!.MainPage!.DisplayActionSheet(
L["Actions"],
L["Cancel"],
null,
L["Edit"], L["Delete"]);
if (result == L["Edit"])
{
await OpenEditModal(entity.Id);
}
if (result == L["Delete"])
{
await Delete(entity);
}
}
[RelayCommand]
async Task Delete(BookDto entity)
{
if (Application.Current is { MainPage: { } })
{
var confirmed = await Shell.Current.CurrentPage.DisplayAlert(
L["Delete"],
string.Format(L["BookDeletionConfirmationMessage"], entity.Name),
L["Delete"],
L["Cancel"]);
if (!confirmed)
{
return;
}
try
{
await BookAppService.DeleteAsync(entity.Id);
}
catch (AbpRemoteCallException remoteException)
{
HandleException(remoteException);
}
await Refresh();
}
}
public async void Receive(BookCreateMessage message)
{
await Refresh();
}
public async void Receive(BookEditMessage message)
{
await Refresh();
}
}
}
The BookPageViewModel
class is where all the logic behind the Books
page lays. Here, we do the following steps:
- We have fetched all the books from the database and set those records into the
Items
property, which is a type ofObservableCollection<BookDto>
, so whenever the book list changes then the CollectionView will be refreshed. - We have defined the
OpenCreateModal
andOpenEditModal
methods to navigate to the create modal and edit modal pages (we will create them in the following sections). - We have defined the
ShowActions
method to allow editing or deleting a certain book. - We have created the
Delete
method, which deletes a specific book and re-renders the grid. - And finally, we have implemented the
IRecipient<BookCreateMessage>
andIRecipient<BookEditMessage>
interfaces to refresh the grid after creating a new book or editing an existing one. (We will create theBookCreateMessage
andBookEditMessage
classes in the following sections)
Registering Book Page Routes
Open the AppShell.xaml.cs
file under the Acme.BookStore.Maui
project and register the create modal and edit modal page routes:
using Acme.BookStore.Maui.Pages;
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui;
public partial class AppShell : Shell, ITransientDependency
{
public AppShell(ShellViewModel vm)
{
BindingContext = vm;
InitializeComponent();
//other routes...
//Authors
Routing.RegisterRoute(nameof(AuthorCreatePage), typeof(AuthorCreatePage));
Routing.RegisterRoute(nameof(AuthorEditPage), typeof(AuthorEditPage));
//Books - register book page routes
Routing.RegisterRoute(nameof(BookCreatePage), typeof(BookCreatePage));
Routing.RegisterRoute(nameof(BookEditPage), typeof(BookEditPage));
}
}
Since, we need to navigate to the create modal and edit modal pages whenever the action buttons are clicked, we need to register those pages with their routes. We can do this in the AppShell.xaml.cs
file, which is responsible for providing the navigation of the application.
Creating a New Book
Create a new content page, BookCreatePage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
BookCreatePage.xaml
<?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"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
x:Class="Acme.BookStore.Maui.Pages.BookCreatePage"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="viewModels:BookCreateViewModel"
Title="{ext:Translate NewBook}">
<ContentPage.Behaviors>
<toolkit:EventToCommandBehavior EventName="Appearing" Command="{Binding GetAuthorsCommand}"/>
</ContentPage.Behaviors>
<ContentPage.Resources>
<ResourceDictionary>
<toolkit:IsEqualConverter x:Key="IsEqualConverter" />
</ResourceDictionary>
</ContentPage.Resources>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate Cancel}" Command="{Binding CancelCommand}"/>
</ContentPage.ToolbarItems>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Name}" />
<Entry Text="{Binding Book.Name}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Type}" />
<Picker x:Name="bookType" ItemsSource="{Binding Types}" SelectedItem="{Binding Book.Type}"/>
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate AuthorName}" />
<Picker ItemsSource="{Binding Authors}" SelectedItem="{Binding SelectedAuthor}" ItemDisplayBinding="{Binding Name}"/>
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate PublishDate}" />
<DatePicker Date="{Binding Book.PublishDate}"/>
</VerticalStackLayout>
</Border>
<Grid>
<Button Text="{ext:Translate Save}" Command="{Binding CreateCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
</Grid>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
In this page, we have defined the form elements that are needed to create a book such as Name
, Type
, AuthorId
and PublishDate
. Whenever a user clicks the Save button, the CreateCommand will be triggered and will create a new book, if the operation goes successfully.
Let's define the BookCreateViewModel
as the BindingContext of this page and then define the logic of the CreateCommand.
BookCreatePage.xaml.cs
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class BookCreatePage : ContentPage, ITransientDependency
{
public BookCreatePage(BookCreateViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
BookCreateViewModel.cs
Create a view model class, BookCreateViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Books;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System.Collections.ObjectModel;
using Volo.Abp.DependencyInjection;
using Acme.BookStore.Maui.Messages;
namespace Acme.BookStore.Maui.ViewModels
{
public partial class BookCreateViewModel : BookStoreViewModelBase, ITransientDependency
{
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private ObservableCollection<AuthorLookupDto> authors = default!;
[ObservableProperty]
private AuthorLookupDto selectedAuthor = default!;
public CreateUpdateBookDto Book { get; set; } = new();
public BookType[] Types { get; } = new[]
{
BookType.Undefined,
BookType.Adventure,
BookType.Biography,
BookType.Poetry,
BookType.Fantastic,
BookType.ScienceFiction,
BookType.Science,
BookType.Dystopia,
BookType.Horror
};
protected IBookAppService BookAppService { get; }
public BookCreateViewModel(IBookAppService bookAppService)
{
BookAppService = bookAppService;
}
[RelayCommand]
async Task GetAuthors()
{
try
{
Authors = new((await BookAppService.GetAuthorLookupAsync()).Items);
}
catch (Exception ex)
{
HandleException(ex);
}
}
[RelayCommand]
async Task Cancel()
{
await Shell.Current.GoToAsync("..");
}
[RelayCommand]
async Task Create()
{
try
{
IsBusy = true;
Book.AuthorId = SelectedAuthor!.Id;
await BookAppService.CreateAsync(Book);
await Shell.Current.GoToAsync("..");
WeakReferenceMessenger.Default.Send(new BookCreateMessage(Book));
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsBusy = false;
}
}
}
}
Here, we do the following steps:
- This class simply injects and uses the
IBookAppService
to create a new book. - We have created two methods for the actions in the BookCreatePage, which are the
Cancel
andCreate
methods. - The
Cancel
method simply returns to the previous page, BooksPage. - The
Create
method creates a new book whenever the Save button is clicked on the BookCreatePage.
BookCreateMessage.cs
Create a class, BookCreateMessage
under the Messages
folder of the project and change the content as follows:
using Acme.BookStore.Books;
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Acme.BookStore.Maui.Messages
{
public class BookCreateMessage : ValueChangedMessage<CreateUpdateBookDto>
{
public BookCreateMessage(CreateUpdateBookDto value) : base(value)
{
}
}
}
This class is used to represent a message that we're gonna use to trigger a return result after book creation. Then, we subscribe to this message and update the grid on the BooksPage.
Updating a Book
Create a new content page, BookEditPage.xaml
under the Pages
folder of the Acme.BookStore.Maui
project and change the content as given below:
BookEditPage.xaml
<?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"
xmlns:ext="clr-namespace:Acme.BookStore.Maui.Extensions"
x:Class="Acme.BookStore.Maui.Pages.BookEditPage"
xmlns:viewModels="clr-namespace:Acme.BookStore.Maui.ViewModels"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="viewModels:BookEditViewModel"
Title="{ext:Translate Edit}">
<ContentPage.Resources>
<ResourceDictionary>
<toolkit:IsEqualConverter x:Key="IsEqualConverter" />
</ResourceDictionary>
</ContentPage.Resources>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{ext:Translate Cancel}" Command="{Binding CancelCommand}"/>
</ContentPage.ToolbarItems>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Name}" />
<Entry Text="{Binding Book.Name}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate Type}" />
<Picker x:Name="bookType" ItemsSource="{Binding Types}" SelectedItem="{Binding Book.Type}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate AuthorName}" />
<Picker ItemsSource="{Binding Authors}" SelectedItem="{Binding SelectedAuthor}" ItemDisplayBinding="{Binding Name}"/>
</VerticalStackLayout>
</Border>
<Border StyleClass="AbpInputContainer">
<VerticalStackLayout>
<Label Text="{ext:Translate PublishDate}" />
<DatePicker Date="{Binding Book.PublishDate}"/>
</VerticalStackLayout>
</Border>
<Grid>
<Button Text="{ext:Translate Save}" Command="{Binding UpdateCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
</Grid>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
In this page, we have defined the form elements that are needed to edit a book such as Name
, Type
, AuthorId
and PublishDate
. Whenever a user clicks the Save button, the UpdateCommand will be triggered and will update an existing book, if the operation goes successfully.
Let's define the BookEditViewModel
as the BindingContext of this page and then define the logic of the UpdateCommand.
BookEditPage.xaml.cs
using Acme.BookStore.Maui.ViewModels;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.Pages;
public partial class BookEditPage : ContentPage, ITransientDependency
{
public BookEditPage(BookEditViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
BookEditViewModel.cs
Create a view model class, BookEditViewModel
under the ViewModels
folder of the project and change the content as follows:
using Acme.BookStore.Books;
using Acme.BookStore.Maui.Messages;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System.Collections.ObjectModel;
using Volo.Abp.DependencyInjection;
namespace Acme.BookStore.Maui.ViewModels
{
[QueryProperty("Id", "Id")]
public partial class BookEditViewModel : BookStoreViewModelBase, ITransientDependency
{
[ObservableProperty]
public string? id;
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isSaving;
[ObservableProperty]
private ObservableCollection<AuthorLookupDto> authors = new();
[ObservableProperty]
private AuthorLookupDto? selectedAuthor;
[ObservableProperty]
public CreateUpdateBookDto book;
public BookType[] Types { get; } = new[]
{
BookType.Undefined,
BookType.Adventure,
BookType.Biography,
BookType.Poetry,
BookType.Fantastic,
BookType.ScienceFiction,
BookType.Science,
BookType.Dystopia,
BookType.Horror
};
protected IBookAppService BookAppService { get; }
public BookEditViewModel(IBookAppService bookAppService)
{
BookAppService = bookAppService;
}
async partial void OnIdChanged(string? value)
{
IsBusy = true;
await GetBook();
await GetAuthors();
IsBusy = false;
}
[RelayCommand]
async Task GetBook()
{
try
{
var bookId = Guid.Parse(Id!);
var bookDto = await BookAppService.GetAsync(bookId);
Book = new CreateUpdateBookDto
{
AuthorId = bookDto.AuthorId,
Name = bookDto.Name,
Price = bookDto.Price,
PublishDate = bookDto.PublishDate,
Type = bookDto.Type
};
}
catch (Exception ex)
{
HandleException(ex);
}
}
[RelayCommand]
async Task GetAuthors()
{
try
{
Authors = new((await BookAppService.GetAuthorLookupAsync()).Items);
SelectedAuthor = Authors.FirstOrDefault(x => x.Id == Book?.AuthorId);
}
catch (Exception ex)
{
HandleException(ex);
}
}
[RelayCommand]
async Task Cancel()
{
await Shell.Current.GoToAsync("..");
}
[RelayCommand]
async Task Update()
{
try
{
IsSaving = true;
Book!.AuthorId = SelectedAuthor!.Id;
await BookAppService.UpdateAsync(Guid.Parse(Id!), Book);
await Shell.Current.GoToAsync("..");
WeakReferenceMessenger.Default.Send(new BookEditMessage(Book));
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsSaving = false;
}
}
}
}
Here, we do the following steps:
- This class simply injects and uses the
IBookAppService
to updating an existing book. - We have created four methods for the actions in the BookEditPage, which are the
GetBook
,GetAuthors
,Cancel
andUpdate
methods. - The
GetBook
method is used to get the book from theId
query parameter and set it to theBook
property. - The
GetAuthors
method is used to get the author lookup to list the authors in a picker. - The
Cancel
method simply returns to the previous page, BooksPage. - The
Update
method updates an existing book whenever the Save button is clicked on the BookEditPage.
BookEditMessage.cs
Create a class, BookEditMessage
under the Messages
folder of the project and change the content as follows:
using Acme.BookStore.Books;
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Acme.BookStore.Maui.Messages
{
public class BookEditMessage : ValueChangedMessage<CreateUpdateBookDto>
{
public BookEditMessage(CreateUpdateBookDto value) : base(value)
{
}
}
}
This class is used to represent a message that we're gonna use to trigger a return result after a book is updated. Then, we subscribe to this message and update the grid on the BooksPage.
Add Book Menu Item to the Main Menu
Open the AppShell.xaml
file and add the following code before the Authors menu item:
AppShell.xaml
<FlyoutItem Title="{ext:Translate Books}" IsVisible="{Binding HasBooksPermission}"
Icon="{FontImageSource FontFamily=MaterialOutlined, Glyph={x:Static u:MaterialOutlined.Book}, Color={AppThemeBinding Light={StaticResource ForegroundDark}, Dark={StaticResource ForegroundLight}}}">
<Tab>
<ShellContent Route="books"
ContentTemplate="{DataTemplate pages:BooksPage}"/>
</Tab>
</FlyoutItem>
This code block adds a new Books menu item before the Authors menu item. We need to show this menu item only when the required permission is granted. So, let's update the ShellViewModel.cs
class and check if the permission is granted or not.
ShellViewModel.cs
using Acme.BookStore.Permissions;
public partial class ShellViewModel : BookStoreViewModelBase, ITransientDependency
{
//Add these two lines below
[ObservableProperty]
bool hasBooksPermission = true;
//...
[RelayCommand]
private async Task UpdatePermissions()
{
HasUsersPermission = await AuthorizationService.IsGrantedAsync(IdentityPermissions.Users.Default);
HasTenantsPermission = await AuthorizationService.IsGrantedAsync(SaasHostPermissions.Tenants.Default);
HasAuthorsPermission = await AuthorizationService.IsGrantedAsync(BookStorePermissions.Authors.Default);
//Add the line below
HasBooksPermission = await AuthorizationService.IsGrantedAsync(BookStorePermissions.Books.Default);
}
//...
}
Run the Application
That's all! You can run the application and login. Then you can navigate through pages, list, create, update and/or delete authors and books.