Navigation Intents
Intents provide strongly-typed data passing during navigation, replacing Shell's string-based query parameters.
Basic Intent Usage
Defining an Intent
Use record types for automatic value equality:
public record ContactIntent(int ContactId);
public record ProductIntent(string Sku, string? Variant = null);
public record SearchIntent(string Query, int PageSize = 20);
Passing an Intent
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<ContactDetailPageModel>()
.WithIntent(new ContactIntent(42))
);
Receiving an Intent
Implement IEnteringAware<TIntent> or IAppearingAware<TIntent>:
public class ContactDetailPageModel : ObservableObject, IEnteringAware<ContactIntent>
{
public async ValueTask OnEnteringAsync(ContactIntent intent)
{
// Use the intent data
ContactId = intent.ContactId;
await LoadContactAsync(intent.ContactId);
}
}
Intent-Aware Lifecycle Events
IEnteringAware<TIntent>
Receive intent before the navigation animation starts:
public class ProductDetailPageModel : IEnteringAware<ProductIntent>
{
public async ValueTask OnEnteringAsync(ProductIntent intent)
{
// Fast initialization with intent data
ProductSku = intent.Sku;
SelectedVariant = intent.Variant;
await LoadFromCacheAsync(intent.Sku);
}
}
IAppearingAware<TIntent>
Receive intent after the navigation animation completes:
public class SearchPageModel : IAppearingAware<SearchIntent>
{
public async ValueTask OnAppearingAsync(SearchIntent intent)
{
// Slow operation with intent data
Query = intent.Query;
PageSize = intent.PageSize;
await PerformSearchAsync(intent.Query, intent.PageSize);
}
}
Both Generic and Non-Generic
You can implement both:
public class ContactDetailPageModel :
IEnteringAware, // Called when no intent
IEnteringAware<ContactIntent> // Called when intent provided
{
public ValueTask OnEnteringAsync()
{
// Handle navigation without intent
return LoadDefaultContactAsync();
}
public ValueTask OnEnteringAsync(ContactIntent intent)
{
// Handle navigation with intent
return LoadContactAsync(intent.ContactId);
}
}
Intent Behavior Modes
Strict Mode (Default)
Only intent-aware methods are called when an intent is provided:
// With this configuration (default):
.UseNaluNavigation<App>(nav => nav
.AddPages()
.WithNavigationIntentBehavior(NavigationIntentBehavior.Strict)
)
// If you navigate WITH intent:
// - IEnteringAware<TIntent>.OnEnteringAsync(intent) is called
// - IEnteringAware.OnEnteringAsync() is NOT called
// If you navigate WITHOUT intent:
// - IEnteringAware.OnEnteringAsync() is called
// - IEnteringAware<TIntent>.OnEnteringAsync(intent) is NOT called
Fallthrough Mode
Both methods are called:
// With this configuration:
.UseNaluNavigation<App>(nav => nav
.AddPages()
.WithNavigationIntentBehavior(NavigationIntentBehavior.Fallthrough)
)
// If you navigate WITH intent:
// - IEnteringAware<TIntent>.OnEnteringAsync(intent) is called first
// - IEnteringAware.OnEnteringAsync() is also called
// If you navigate WITHOUT intent:
// - IEnteringAware.OnEnteringAsync() is called
Use case for Fallthrough: When you have common initialization that should always run, plus specific handling for intents.
public class PageModel : IEnteringAware, IEnteringAware<SearchIntent>
{
public ValueTask OnEnteringAsync()
{
// Common initialization - always runs with Fallthrough
InitializeDefaults();
return ValueTask.CompletedTask;
}
public ValueTask OnEnteringAsync(SearchIntent intent)
{
// Specific handling for search
Query = intent.Query;
return PerformSearchAsync(intent.Query);
}
}
Returning Results (Pop with Intent)
Pass data back to the previous page when popping:
Returning Result on Pop
// In the detail page, pop with result
await _navigationService.GoToAsync(
Navigation.Relative()
.Pop()
.WithIntent(new ContactSelectedResult(selectedContact))
);
Receiving Result in Previous Page
public class ContactListPageModel : IAppearingAware<ContactSelectedResult>
{
public ValueTask OnAppearingAsync(ContactSelectedResult result)
{
// Handle the returned result
SelectedContact = result.Contact;
return ValueTask.CompletedTask;
}
}
Complete Round-Trip Example
// Define intents
public record SelectContactIntent();
public record ContactSelectedResult(Contact Contact);
// Page 1: Navigate to selection page
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<ContactSelectionPageModel>()
.WithIntent(new SelectContactIntent())
);
// Page 2: User selects contact, return result
[RelayCommand]
private Task SelectContactAsync(Contact contact)
{
return _navigationService.GoToAsync(
Navigation.Relative()
.Pop()
.WithIntent(new ContactSelectedResult(contact))
);
}
// Page 1: Receive result
public class MainPageModel : IAppearingAware<ContactSelectedResult>
{
public ValueTask OnAppearingAsync(ContactSelectedResult result)
{
SelectedContact = result.Contact;
return ValueTask.CompletedTask;
}
}
Advanced Intent Patterns
Multiple Intent Types
A page can handle different intent types:
public class DetailPageModel :
IEnteringAware<CreateIntent>,
IEnteringAware<EditIntent>,
IEnteringAware<ViewIntent>
{
public ValueTask OnEnteringAsync(CreateIntent intent)
{
Mode = PageMode.Create;
return InitializeForCreateAsync();
}
public ValueTask OnEnteringAsync(EditIntent intent)
{
Mode = PageMode.Edit;
return LoadForEditAsync(intent.ItemId);
}
public ValueTask OnEnteringAsync(ViewIntent intent)
{
Mode = PageMode.View;
return LoadForViewAsync(intent.ItemId);
}
}
Complex Intent Data
Intents can contain any data:
public record CheckoutIntent(
List<CartItem> Items,
ShippingAddress Address,
PaymentMethod Payment,
string? PromoCode = null
);
public class CheckoutPageModel : IEnteringAware<CheckoutIntent>
{
public ValueTask OnEnteringAsync(CheckoutIntent intent)
{
Items = intent.Items;
ShippingAddress = intent.Address;
PaymentMethod = intent.Payment;
PromoCode = intent.PromoCode;
CalculateTotals();
return ValueTask.CompletedTask;
}
}
Delegate-Based Configuration Intent
For complex setup, use a configuration delegate:
public record EditorIntent(Action<EditorViewModel> Configure);
// Navigate
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<EditorPageModel>()
.WithIntent(new EditorIntent(editor =>
{
editor.Title = "Edit Contact";
editor.Data = contact;
editor.Mode = EditMode.Update;
editor.ValidationRules = contactValidationRules;
}))
);
// Receive
public class EditorPageModel : IEnteringAware<EditorIntent>
{
public ValueTask OnEnteringAsync(EditorIntent intent)
{
intent.Configure(this);
return ValueTask.CompletedTask;
}
}
Awaitable Intents
Awaitable intents allow you to navigate to a page and wait for a result in one flow.
Step 1: Define an awaitable intent
// Intent that returns a result
public class SelectContactIntent : AwaitableIntent<Contact?>
{
// Optional: Add properties for configuration
public bool AllowMultiple { get; init; }
}
// Intent without result (just completion notification)
public class PerformActionIntent : AwaitableIntent
{
public string ActionType { get; init; }
}
Step 2: Navigate using ResolveIntentAsync
// Create and navigate with intent - waits for result
var intent = new SelectContactIntent { AllowMultiple = false };
var selectedContact = await _navigationService.ResolveIntentAsync<ContactSelectionPageModel, Contact?>(intent);
if (selectedContact != null)
{
SelectedContact = selectedContact;
}
// Without result type (just completion)
var actionIntent = new PerformActionIntent { ActionType = "delete" };
await _navigationService.ResolveIntentAsync<ConfirmPageModel>(actionIntent);
Step 3: Receive intent and set result in the target page
The target page must:
- Store the intent when received (via
IEnteringAware<TIntent>) - Set the result on the intent using
SetResult(value) - Pop back to complete the awaitable intent
public class ContactSelectionPageModel : IEnteringAware<SelectContactIntent>
{
private readonly INavigationService _navigationService;
private SelectContactIntent _intent = null!;
public ContactSelectionPageModel(INavigationService navigationService)
{
_navigationService = navigationService;
}
public ValueTask OnEnteringAsync(SelectContactIntent intent)
{
// Step 1: Store the intent
_intent = intent;
return ValueTask.CompletedTask;
}
[RelayCommand]
private async Task SelectContactAsync(Contact contact)
{
// Step 2: Set the result on the intent
_intent.SetResult(contact);
// Step 3: Pop back (this completes the awaitable intent)
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
[RelayCommand]
private async Task CancelAsync()
{
// Return null to indicate cancellation
_intent.SetResult(null);
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
}
Key points:
- The intent must be stored in a field when received in
OnEnteringAsync - Call
_intent.SetResult(value)to set the result - Pop the page to complete the awaitable intent and return control to the caller
- The caller's
awaitwill complete with the result you set
Handling errors:
public class ContactSelectionPageModel : IEnteringAware<SelectContactIntent>
{
[RelayCommand]
private async Task SelectContactAsync(Contact contact)
{
try
{
var validated = await ValidateContactAsync(contact);
_intent.SetResult(validated);
}
catch (Exception ex)
{
// Set exception - will throw when awaited
_intent.SetException(ex);
}
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
}
// Consumer handles exception
try
{
var result = await intent;
SelectedContact = result;
}
catch (ValidationException ex)
{
await ShowErrorAsync(ex.Message);
}
Complete example:
// 1. Define awaitable intent
public class EditItemIntent : AwaitableIntent<Item?>
{
public Item Item { get; init; }
public bool AllowDelete { get; init; }
}
// 2. Navigate and await result
[RelayCommand]
private async Task EditItemAsync(Item item)
{
var intent = new EditItemIntent
{
Item = item.Clone(),
AllowDelete = true
};
// ResolveIntentAsync handles navigation and awaiting the result
var editedItem = await _navigationService.ResolveIntentAsync<ItemEditorPageModel, Item?>(intent);
if (editedItem != null)
{
// Update with edited item
await _repository.UpdateAsync(editedItem);
RefreshList();
}
else
{
// User deleted or cancelled
await RefreshAsync();
}
}
// 3. Editor page receives intent and sets result
public class ItemEditorPageModel : IEnteringAware<EditItemIntent>
{
private readonly INavigationService _navigationService;
private EditItemIntent _intent = null!;
public ItemEditorPageModel(INavigationService navigationService)
{
_navigationService = navigationService;
}
public ValueTask OnEnteringAsync(EditItemIntent intent)
{
// Store the intent and use its data
_intent = intent;
Item = intent.Item;
CanDelete = intent.AllowDelete;
return ValueTask.CompletedTask;
}
[RelayCommand]
private async Task SaveAsync()
{
// Set the edited item as result
_intent.SetResult(Item);
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
[RelayCommand]
private async Task DeleteAsync()
{
await _repository.DeleteAsync(Item.Id);
// Return null to indicate deletion
_intent.SetResult(null);
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
[RelayCommand]
private async Task CancelAsync()
{
// Return null to indicate cancellation
_intent.SetResult(null);
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
}
How ResolveIntentAsync works:
ResolveIntentAsync is a convenience method that:
- Navigates to the page with the intent using
GoToAsync - Automatically awaits the intent completion
- Returns the result when the page is popped
Reusable Base Class Pattern
For scenarios like popups or similar pages that always return results, create a base class to reduce boilerplate:
public abstract class ResultPageModelBase<TIntent, TResult> : ObservableObject, IEnteringAware<TIntent>
where TIntent : AwaitableIntent<TResult>
{
private readonly INavigationService _navigationService;
protected TIntent Intent { get; private set; } = null!;
protected ResultPageModelBase(INavigationService navigationService)
{
_navigationService = navigationService;
}
public virtual ValueTask OnEnteringAsync(TIntent intent)
{
Intent = intent;
return ValueTask.CompletedTask;
}
protected async Task CloseAsync()
{
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
protected Task CloseAsync(TResult result)
{
Intent.SetResult(result);
return CloseAsync();
}
protected Task CloseWithErrorAsync(Exception exception)
{
Intent.SetException(exception);
return CloseAsync();
}
}
Now your page models become much simpler:
// Define intent
public class YesNoIntent : AwaitableIntent<bool?>;
// Simple page model using base class
public partial class YesNoPageModel : ResultPageModelBase<YesNoIntent, bool?>
{
public YesNoPageModel(INavigationService navigationService)
: base(navigationService) { }
[RelayCommand]
public Task YesAsync() => CloseAsync(true);
[RelayCommand]
public Task NoAsync() => CloseAsync(false);
}
// Usage
var result = await _navigationService.ResolveIntentAsync<YesNoPageModel, bool?>(new YesNoIntent());
if (result == true)
{
// User clicked Yes
}
This pattern is especially useful for:
- Confirmation dialogs
- Selection pages
- Form popups
- Any page that always returns a result
Benefits of AwaitableIntent:
- Clean async flow: One line to navigate and get result
- Type safety: Intent and result are strongly typed
- Reusability: Intent classes can be reused across the app
- Error handling: Built-in exception propagation via
SetException() - Explicit API: Clear contract between pages
- Testability: Easy to mock and test
Intent Best Practices
1. Use Record Types
Records provide value equality, making testing easier:
// ✅ Good - automatic value equality
public record ProductIntent(string Sku);
// ❌ Avoid - requires custom equality
public class ProductIntent
{
public string Sku { get; set; }
}
2. Keep Intents Immutable
Use record or readonly properties:
// ✅ Good - immutable
public record SearchIntent(string Query, int Page);
// ❌ Avoid - mutable
public record SearchIntent
{
public string Query { get; set; }
public int Page { get; set; }
}
3. Use Optional Parameters for Flexibility
// ✅ Good - flexible with defaults
public record FilterIntent(
string Category,
decimal? MinPrice = null,
decimal? MaxPrice = null,
string? Brand = null
);
4. Name Intent Classes Descriptively
// ✅ Good - clear intent
public record EditContactIntent(int ContactId);
public record CreateContactIntent();
public record ContactSavedResult(Contact Contact);
// ❌ Avoid - ambiguous
public record ContactIntent(int Id);
public record DataIntent(object Data);
5. Group Related Intents
// In ContactIntents.cs
namespace MyApp.Navigation.Intents;
public record EditContactIntent(int ContactId);
public record ViewContactIntent(int ContactId);
public record DeleteContactIntent(int ContactId);
public record ContactModifiedResult(Contact Contact, ModificationType Type);
Intent vs Navigation-Scoped Services
Choose the right approach for sharing data:
| Use Intent when... | Use Navigation-Scoped Service when... |
|---|---|
| Passing data to a single page | Sharing data with nested pages |
| One-time initialization data | Data needs to be mutable and shared |
| Simple parameter passing | Complex context spanning multiple pages |
| Returning results | Parent-child data relationship |
Example: When to use each
// Use Intent: Simple parameter passing
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<ProductDetailPageModel>()
.WithIntent(new ProductIntent(productId))
);
// Use Navigation-Scoped Service: Complex shared context
public class OrderPageModel : IEnteringAware<OrderIntent>
{
private readonly INavigationServiceProvider _navProvider;
public ValueTask OnEnteringAsync(OrderIntent intent)
{
// Create context shared with all nested pages
var orderContext = new OrderContext(intent.OrderId);
_navProvider.AddNavigationScoped<IOrderContext>(orderContext);
return ValueTask.CompletedTask;
}
}
See Advanced Navigation - Navigation-Scoped Services for more details.
Testing with Intents
[Fact]
public async Task NavigateToDetail_WithProductId_PassesCorrectIntent()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new ProductListViewModel(navigationService);
// Act
await viewModel.ViewProductAsync(42);
// Assert
var expectedNav = Navigation.Relative()
.Push<ProductDetailPageModel>()
.WithIntent(new ProductIntent(42));
await navigationService.Received().GoToAsync(
Arg.Is<Navigation>(n => n.Matches(expectedNav))
);
}
[Fact]
public async Task OnEnteringAsync_WithIntent_LoadsCorrectProduct()
{
// Arrange
var productService = Substitute.For<IProductService>();
var viewModel = new ProductDetailPageModel(productService);
var intent = new ProductIntent(42);
// Act
await viewModel.OnEnteringAsync(intent);
// Assert
await productService.Received().LoadProductAsync(42);
}
Common Patterns
Edit/Create Pattern
public record EditItemIntent(int ItemId);
public record CreateItemIntent();
public record ItemSavedResult(Item Item, bool IsNew);
public class ItemEditorPageModel :
IEnteringAware<EditItemIntent>,
IEnteringAware<CreateItemIntent>
{
private bool _isNew;
public async ValueTask OnEnteringAsync(EditItemIntent intent)
{
_isNew = false;
Item = await _repository.GetAsync(intent.ItemId);
}
public ValueTask OnEnteringAsync(CreateItemIntent intent)
{
_isNew = true;
Item = new Item();
return ValueTask.CompletedTask;
}
[RelayCommand]
private async Task SaveAsync()
{
await _repository.SaveAsync(Item);
await _navigationService.GoToAsync(
Navigation.Relative()
.Pop()
.WithIntent(new ItemSavedResult(Item, _isNew))
);
}
}
Wizard Pattern
// Step 1
public record StartWizardIntent();
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<WizardStep1PageModel>()
.WithIntent(new StartWizardIntent())
);
// Step 2
public record WizardStep2Intent(WizardData DataFromStep1);
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<WizardStep2PageModel>()
.WithIntent(new WizardStep2Intent(wizardData))
);
// Complete
public record WizardCompletedResult(WizardData FinalData);
await _navigationService.GoToAsync(
Navigation.Relative()
.Pop()
.Pop() // Go back to start
.WithIntent(new WizardCompletedResult(wizardData))
);
Master-Detail Pattern
// List page
public class ContactListPageModel : IAppearingAware<ContactUpdatedResult>
{
[RelayCommand]
private Task ViewContactAsync(int contactId)
{
return _navigationService.GoToAsync(
Navigation.Relative()
.Push<ContactDetailPageModel>()
.WithIntent(new ViewContactIntent(contactId))
);
}
public ValueTask OnAppearingAsync(ContactUpdatedResult result)
{
// Refresh the list with updated contact
UpdateContactInList(result.Contact);
return ValueTask.CompletedTask;
}
}
// Detail page
public class ContactDetailPageModel : IEnteringAware<ViewContactIntent>
{
public async ValueTask OnEnteringAsync(ViewContactIntent intent)
{
Contact = await _repository.GetContactAsync(intent.ContactId);
}
[RelayCommand]
private async Task SaveAsync()
{
await _repository.UpdateContactAsync(Contact);
await _navigationService.GoToAsync(
Navigation.Relative()
.Pop()
.WithIntent(new ContactUpdatedResult(Contact))
);
}
}