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 directory.
// RecipePageViewModel.cs
using System.Collections.Generic;
using System.Composition;
using Asv.Avalonia;
using Material.Icons;
using Microsoft.Extensions.Logging;
namespace RecipeBook.ViewModels;
public interface IRecipePageViewModel : IPage;
[ExportPage(PageId)]
public class RecipePageViewModel : PageViewModel<IRecipePageViewModel>, IRecipePageViewModel
{
private readonly ILoggerFactory _loggerFactory;
public const string PageId = "recipe_page";
public const MaterialIconKind PageIcon = MaterialIconKind.ViewGallery;
[ImportingConstructor]
public RecipePageViewModel(ICommandService cmd, ILoggerFactory loggerFactory, IDialogService dialogService)
: base(PageId, cmd, loggerFactory, dialogService)
{
_loggerFactory = loggerFactory;
}
public override IEnumerable<IRoutable> GetChildren()
{
return [];
}
protected override void AfterLoadExtensions() { }
public override IExportInfo Source => SystemModule.Instance;
}
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.
Add the necessary attributes for MEF2 registration in the code-behind file RecipePageView.axaml.cs.
// RecipePageView.axaml.cs
using Asv.Avalonia;
using Avalonia.Controls;
using RecipeBook.ViewModels;
namespace RecipeBook.Views;
[ExportViewFor(typeof(RecipePageViewModel))]
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 System.Composition;
using Asv.Avalonia;
using Asv.Common;
using Microsoft.Extensions.Logging;
using R3;
using RecipeBook.ViewModels.Commands;
namespace RecipeBook.ViewModels;
[ExportExtensionFor<IHomePage>]
[method: ImportingConstructor]
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 folder.
// OpenRecipePageCommand.cs
using System.Composition;
using Asv.Avalonia;
namespace RecipeBook.ViewModels.Commands;
[ExportCommand]
[method: ImportingConstructor]
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,
Source = SystemModule.Instance,
};
}
Project structure
Run App
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 directory, alongside the other source files.
// IngredientViewModel.cs
using System.Collections.Generic;
using Asv.Avalonia;
using Asv.Common;
using Microsoft.Extensions.Logging;
using R3;
namespace RecipeBook.ViewModels;
public class IngredientViewModel : RoutableViewModel
{
public const string BaseId = "ingredient";
private ReactiveProperty<string?> _name;
private ReactiveProperty<string?> _amount;
public IngredientViewModel(string id, string amount, ILoggerFactory loggerFactory)
: base(new NavigationId(BaseId, id), loggerFactory)
{
_name = new ReactiveProperty<string?>(name);
Name = new HistoricalStringProperty(
nameof(Name),
_name,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_amount = new ReactiveProperty<string?>(amount);
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 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 RecipeBook.ViewModels;
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.