In this tutorial, we will build a Recipe Book application that allows users to add and edit recipes. You will learn how to implement features for editing cooking instructions and managing ingredient lists, complete with Undo/Redo support for text editing.
The goal of this guide is to demonstrate the core capabilities of the Asv.Avalonia framework through a practical example, covering:
Pages (Application entry point and navigation).
Historical Properties (Undo/Redo mechanics).
Dialogs (Modal windows for data input).
Notifications (Toast messages).
Layout Service (Persisting and restoring UI state).
Project Setup
We will skip the initial application initialization steps by using the boilerplate code from the Project setup guide. For a more detailed explanation of Pages, please refer to the Adding pages documentation.
Running the Application
Let's launch the project to verify the setup.
Adding a Page
Create the RecipePageViewModel and RecipePageView in Pages/Recipes directory.
// RecipePageViewModel.cs
using System.Collections.Generic;
using Asv.Avalonia;
using Material.Icons;
using Microsoft.Extensions.Logging;
namespace Asv.Avalonia.Samples.RecipeBook;
public interface IRecipePageViewModel : IPage;
public class RecipePageViewModel : PageViewModel<IRecipePageViewModel>, IRecipePageViewModel
{
public const string PageId = "recipe_page";
public const MaterialIconKind PageIcon = MaterialIconKind.ViewGallery;
private readonly ILoggerFactory _loggerFactory;
public RecipePageViewModel(ICommandService cmd, ILoggerFactory loggerFactory, IDialogService dialogService, IExtensionService ext)
: base(PageId, cmd, loggerFactory, dialogService, ext)
{
_loggerFactory = loggerFactory;
}
public override IEnumerable<IRoutable> GetChildren()
{
return [];
}
protected override void AfterLoadExtensions() { }
}
Define the XAML layout for RecipePageView, dividing it into two main sections: a left sidebar for the recipe list and a right editor area for the recipe description and ingredients.
Create the code-behind file RecipePageView.axaml.cs.
// RecipePageView.axaml.cs
using Avalonia.Controls;
namespace Asv.Avalonia.Samples.RecipeBook;
public partial class RecipePageView : UserControl
{
public RecipePageView()
{
InitializeComponent();
}
}
Register the components using an extension method and add a command to open the page.
// HomePageRecipeExtension.cs
using Asv.Avalonia;
using Asv.Common;
using Microsoft.Extensions.Logging;
using R3;
namespace Asv.Avalonia.Samples.RecipeBook;
public class HomePageRecipeExtension(ILoggerFactory loggerFactory)
: AsyncDisposableOnce,
IExtensionFor<IHomePage>
{
public void Extend(IHomePage context, CompositeDisposable contextDispose)
{
context.Tools.Add(
OpenRecipePageCommand
.StaticInfo.CreateAction(
loggerFactory,
"Recipe Book",
"Open recipes"
)
.DisposeItWith(contextDispose)
);
}
}
Implement the command to open the Recipe Book page. Put OpenRecipePageCommand.cs into Commands/Recipes folder.
// OpenRecipePageCommand.cs
using Asv.Avalonia;
namespace Asv.Avalonia.Samples.RecipeBook;
public class OpenRecipePageCommand(INavigationService nav)
: OpenPageCommandBase(RecipePageViewModel.PageId, nav)
{
public override ICommandInfo Info => StaticInfo;
public const string Id = $"{BaseId}.open.{RecipePageViewModel.PageId}";
public static readonly ICommandInfo StaticInfo = new CommandInfo
{
Id = Id,
Name = "Recipe Book",
Description = "Open recipes",
Icon = RecipePageViewModel.PageIcon,
DefaultHotKey = null,
};
}
A corresponding menu item has appeared on the Home Page.
The application currently looks like this:
Application Core
Let's create the fundamental components of the Recipe Book: the recipe and its ingredient list. We will utilize Historical properties to enable built-in Undo/Redo support.
Create the IngredientViewModel, which supports Undo/Redo operations for editing the ingredient name and amount. Place this file in the Pages/Ingredients directory.
// IngredientViewModel.cs
using System.Collections.Generic;
using Asv.Avalonia;
using Asv.Common;
using Microsoft.Extensions.Logging;
using R3;
namespace Asv.Avalonia.Samples.RecipeBook;
public class IngredientViewModel : RoutableViewModel
{
public const string BaseId = "ingredient";
private ReactiveProperty<string?> _name;
private ReactiveProperty<string?> _amount;
public IngredientViewModel(string id, string name, string amount, ILoggerFactory loggerFactory)
: base(new NavigationId(BaseId, id), loggerFactory)
{
_name = new ReactiveProperty<string?>(name).DisposeItWith(Disposable);
Name = new HistoricalStringProperty(
nameof(Name),
_name,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_amount = new ReactiveProperty<string?>(amount).DisposeItWith(Disposable);
Amount = new HistoricalStringProperty(
nameof(Amount),
_amount,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
}
public HistoricalStringProperty Name { get; }
public HistoricalStringProperty Amount { get; }
public override IEnumerable<IRoutable> GetChildren()
{
yield return Name;
yield return Amount;
}
}
Define the RecipeViewModel in the Pages/Recipes directory to represent a recipe, including its title, category, and ingredient list.
// RecipeViewModel.cs
using System.Collections.Generic;
using Asv.Avalonia;
using Asv.Common;
using Asv.IO;
using Microsoft.Extensions.Logging;
using ObservableCollections;
using R3;
namespace Asv.Avalonia.Samples.RecipeBook;
public class RecipeViewModel : RoutableViewModel
{
public const string BaseId = "recipe";
private readonly ILoggerFactory _loggerFactory;
private readonly ReactiveProperty<string?> _title;
private readonly ReactiveProperty<string?> _category;
private readonly ReactiveProperty<string?> _instruction;
private readonly ObservableList<IngredientViewModel> _ingredients = [];
public RecipeViewModel(string id, string title, string? category, string? instruction, IEnumerable<IngredientViewModel> ingredients,
ILoggerFactory loggerFactory)
: base(new NavigationId(BaseId, id), loggerFactory)
{
_loggerFactory = loggerFactory;
_title = new ReactiveProperty<string?>(title).DisposeItWith(Disposable);
Title = new HistoricalStringProperty(
nameof(Title),
_title,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_category = new ReactiveProperty<string?>(category).DisposeItWith(Disposable);
Category = new HistoricalStringProperty(
nameof(Category),
_category,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_instruction = new ReactiveProperty<string?>(instruction).DisposeItWith(Disposable);
Instruction = new HistoricalStringProperty(
nameof(Instruction),
_instruction,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_ingredients.AddRange(ingredients);
_ingredients.SetRoutableParent(this).DisposeItWith(Disposable);
_ingredients.DisposeRemovedItems().DisposeItWith(Disposable);
Ingredients = _ingredients.ToNotifyCollectionChangedSlim(SynchronizationContextCollectionEventDispatcher.Current)
.DisposeItWith(Disposable);
}
public HistoricalStringProperty Title { get; }
public HistoricalStringProperty Category { get; }
public HistoricalStringProperty Instruction { get; }
public NotifyCollectionChangedSynchronizedViewList<IngredientViewModel> Ingredients { get; }
public override IEnumerable<IRoutable> GetChildren()
{
foreach (var ingredient in _ingredients)
{
yield return ingredient;
}
yield return Title;
yield return Category;
yield return Instruction;
}
}
Update RecipePageViewModel to manage the recipe list.
Add a new field in RecipePageViewModel to store the collection of recipes.
Initialize the observable list in the RecipePageViewModel constructor and wrap it for XAML data binding. We also need a property to track the currently selected recipe, which will display its cooking instructions and ingredient list.