Advanced Navigation Features
This guide covers advanced navigation features: guards, behaviors, navigation-scoped services, leak detection, and more.
Navigation Guards
Guards allow you to prevent navigation based on conditions (e.g., unsaved changes).
ILeavingGuard
Implement ILeavingGuard to control when a page can be left:
public class EditFormPageModel : ObservableObject, ILeavingGuard
{
private bool _hasUnsavedChanges;
public async ValueTask<bool> CanLeaveAsync()
{
if (!_hasUnsavedChanges)
return true;
return await Application.Current.MainPage.DisplayAlert(
"Unsaved Changes",
"You have unsaved changes. Are you sure you want to leave?",
"Leave",
"Stay"
);
}
}
Important: ILeavingGuard is evaluated before any lifecycle events fire. Returning false completely prevents the navigation.
Guard Evaluation Timing
Navigation Request
↓
ILeavingGuard.CanLeaveAsync() on all affected pages
↓ (if all return true)
IEnteringAware/IAppearingAware on target page
If any guard returns false, the navigation is cancelled and no lifecycle events fire.
Bypassing Guards
Use NavigationBehavior.IgnoreGuards when you need to force navigation:
// User explicitly cancels - bypass guard
await _navigationService.GoToAsync(
Navigation.Relative(NavigationBehavior.IgnoreGuards).Pop()
);
Multiple Guards
If multiple pages have guards (e.g., popping multiple pages), all are evaluated:
// Both Page B and Page C guards are checked
// Stack: [A, B, C] → Pop → Pop → [A]
await _navigationService.GoToAsync(
Navigation.Relative().Pop().Pop()
);
If any guard returns false, the entire navigation is cancelled.
Guard Best Practices
- Keep prompts simple: Don't show multiple dialogs - combine into one
- Use async dialogs: Always use async display methods
- Consider UX: Don't block every navigation - only when data could be lost
- Track dirty state: Use property change tracking to detect modifications
- Test guards: Verify both allow and block scenarios
Example: Dirty tracking
public class EditPageModel : ObservableObject, ILeavingGuard, IEnteringAware
{
private string _originalData;
private bool _saveInProgress;
[ObservableProperty]
private string _data;
public ValueTask OnEnteringAsync()
{
_originalData = Data;
return ValueTask.CompletedTask;
}
public async ValueTask<bool> CanLeaveAsync()
{
// Don't block if saving
if (_saveInProgress)
return true;
// Check if modified
var hasChanges = _data != _originalData;
if (!hasChanges)
return true;
return await ConfirmLeaveAsync();
}
[RelayCommand]
private async Task SaveAsync()
{
_saveInProgress = true;
try
{
await _repository.SaveAsync(Data);
_originalData = Data;
await _navigationService.GoToAsync(Navigation.Relative().Pop());
}
finally
{
_saveInProgress = false;
}
}
}
Navigation Behaviors
Control navigation behavior using NavigationBehavior flags.
Available Behaviors
public enum NavigationBehavior
{
None = 0,
PopAllPagesOnSectionChange = 0x01,
PopAllPagesOnItemChange = 0x02,
IgnoreGuards = 0x04,
Immediate = 0x08,
// Convenience combinations
DefaultIgnoreGuards = IgnoreGuards | PopAllPagesOnItemChange,
DefaultImmediate = Immediate | PopAllPagesOnItemChange,
DefaultImmediateIgnoreGuards = Immediate | IgnoreGuards | PopAllPagesOnItemChange
}
PopAllPagesOnSectionChange
When switching to a different ShellSection within the same ShellItem, clear the navigation stack:
Navigation.Absolute(NavigationBehavior.PopAllPagesOnSectionChange)
.Root<OtherSectionPageModel>()
Default behavior: Navigation stacks are preserved when switching sections.
PopAllPagesOnItemChange
When switching to a different ShellItem, clear all navigation stacks:
Navigation.Absolute(NavigationBehavior.PopAllPagesOnItemChange)
.Root<OtherItemPageModel>()
Default behavior: This is the default for absolute navigation.
IgnoreGuards
Skip ILeavingGuard checks:
// Force navigation even if guards say no
Navigation.Relative(NavigationBehavior.IgnoreGuards).Pop()
Use cases:
- User explicitly cancels an operation
- Session timeout/forced logout
- Error states requiring immediate navigation
Immediate
Skip the 60ms delay before navigation:
Navigation.Absolute(NavigationBehavior.Immediate)
.Root<HomePageModel>()
Default behavior: Nalu waits 60ms to let touch feedback display before starting navigation.
Use cases:
- Programmatic navigation (no user interaction)
- Background updates
- Startup navigation
Combining Behaviors
Use bitwise OR to combine behaviors:
// Immediate + ignore guards
Navigation.Relative(NavigationBehavior.Immediate | NavigationBehavior.IgnoreGuards)
.Pop()
// Or use convenience constant
Navigation.Relative(NavigationBehavior.DefaultImmediateIgnoreGuards)
.Pop()
Behavior Examples
Startup navigation:
// Fast, no guards, clear stacks
await _navigationService.GoToAsync(
Navigation.Absolute(NavigationBehavior.DefaultImmediateIgnoreGuards)
.Root<HomePageModel>()
);
Force logout:
// Ignore guards (unsaved changes), immediate
await _navigationService.GoToAsync(
Navigation.Absolute(NavigationBehavior.DefaultImmediateIgnoreGuards)
.Root<LoginPageModel>()
);
Tab switching with clean state:
// Clear navigation stack when switching tabs
await _navigationService.GoToAsync(
Navigation.Absolute(NavigationBehavior.PopAllPagesOnSectionChange)
.Root<TabTwoPageModel>()
);
Threading and Concurrency
INavigationService.GoToAsync has a precise contract: UI thread only, serialized, non re-entrant.
Call it on the UI thread
Navigation creates pages, sets binding contexts and drives the shell directly — nothing is marshalled for you.
Calling GoToAsync from a background thread is undefined behavior and usually crashes on iOS/Android.
// ❌ Wrong - continuation may resume on a thread pool thread
await _repository.SaveAsync().ConfigureAwait(false);
await _navigationService.GoToAsync(Navigation.Pop());
// ✅ Correct - hop back to the UI thread first
await _repository.SaveAsync().ConfigureAwait(false);
await MainThread.InvokeOnMainThreadAsync(
() => _navigationService.GoToAsync(Navigation.Pop())
);
Inside a page model you normally already are on the UI thread (lifecycle events and commands are invoked there),
so an explicit hop is only needed after ConfigureAwait(false), Task.Run, timers or event callbacks raised by
background services.
Concurrent calls are serialized — and the loser is dropped
Two overlapping GoToAsync calls never interleave: the second one waits for the first to complete.
When it resumes, it is re-validated against the shell location it was computed on. If the shell moved in the
meantime, the queued navigation is not applied to the new state — it is ignored:
GoToAsyncreturnsfalse- a
NavigationIgnoredlifecycle event is raised (see Monitoring Navigation Events)
That is what protects you from double-taps pushing the same page twice, or from a "pop" queued behind a "push" landing somewhere unexpected. The consequence: honor the return value, don't assume the navigation happened.
if (!await _navigationService.GoToAsync(Navigation.Push<DetailPageModel>()))
{
// Blocked by a guard, or superseded by a concurrent navigation
return;
}
💡 A navigation that was canceled by a guard leaves the location untouched, so a call waiting behind it still runs normally. Only an effective state change invalidates the queued navigation.
Navigating from within a navigation throws
Triggering a navigation while one is in progress on the same asynchronous flow — typically from OnEnteringAsync,
OnAppearingAsync or an ILeavingGuard — throws InvalidNavigationException:
Cannot trigger a navigation from within a navigation, try to use IDispatcher.DispatchDelayed.
The guard travels with the asynchronous flow, so work started during the navigation (including Task.Run)
inherits it too. Dispatch the call instead of awaiting it inline:
public async ValueTask OnAppearingAsync(StartupIntent intent)
{
await LoadDataAsync();
// ✅ Dispatched navigation - runs outside the current one
_ = _dispatcher.DispatchAsync(RedirectToHomeAsync);
}
private Task RedirectToHomeAsync()
=> _navigationService.GoToAsync(
Navigation.Absolute(NavigationBehavior.Immediate).Root<HomePageModel>()
);
Why redirecting from OnAppearingAsync is never dropped
The two rules above could look like they conflict: if a dispatched navigation may run while the outer one is still unwinding, couldn't it be ignored as "superseded"?
No — and the reason is the order in which the engine works. The navigation is committed before the appearing
event is sent: the location is already the final one when OnAppearingAsync runs, so a navigation dispatched
from there starts from that same location and there is nothing left to invalidate it.
This holds for both engines, since they share the same navigation engine and only differ in the proxy that applies the commit:
- Shell: the commit calls
Shell.GoToAsync, which movesShell.CurrentState.Location - Scaffold: the commit applies the pending pushes/pops to the stack model and updates the location before running the presenter animation
Pushes and pops are batched until that commit, which is why OnEnteringAsync is different: it runs before the
commit, on the still-current location. A navigation dispatched from OnEnteringAsync may or may not observe the
commit depending on timing, and if it doesn't it will be ignored. Prefer redirecting from OnAppearingAsync.
See Navigation Within Lifecycle Events for the full picture.
Navigation-Scoped Services
Share data between a page and all its nested child pages.
Concept
Navigation-scoped services create a "context" that:
- Is provided by a parent page
- Lives as long as that page is in the navigation stack
- Is accessible to all child pages navigated from it
- Is automatically disposed when the parent page is popped
Providing a Service
public class PersonPageModel : ObservableObject, IEnteringAware<PersonIntent>
{
private readonly INavigationServiceProvider _navProvider;
public PersonPageModel(INavigationServiceProvider navProvider)
{
_navProvider = navProvider;
}
public ValueTask OnEnteringAsync(PersonIntent intent)
{
// Create and provide a service for child pages
var personContext = new PersonContext(intent.PersonId);
_navProvider.AddNavigationScoped<IPersonContext>(personContext);
return ValueTask.CompletedTask;
}
}
Consuming a Service
public class PersonDetailsPageModel : ObservableObject
{
private readonly IPersonContext _personContext;
public PersonDetailsPageModel(INavigationServiceProvider navProvider)
{
// Get the service from the navigation scope
_personContext = navProvider.GetRequiredService<IPersonContext>();
}
// Use _personContext throughout the page
}
Complete Example
// 1. Define the context interface and implementation
public interface IOrderContext
{
int OrderId { get; }
Order Order { get; set; }
decimal TotalAmount { get; }
void RecalculateTotal();
}
public class OrderContext : IOrderContext
{
public int OrderId { get; }
public Order Order { get; set; }
public decimal TotalAmount => Order.Items.Sum(i => i.Price * i.Quantity);
public OrderContext(int orderId)
{
OrderId = orderId;
}
public void RecalculateTotal()
{
// Notify all pages using this context
OnPropertyChanged(nameof(TotalAmount));
}
}
// 2. Parent page provides the context
public class OrderPageModel : IEnteringAware<OrderIntent>
{
private readonly INavigationServiceProvider _navProvider;
public async ValueTask OnEnteringAsync(OrderIntent intent)
{
var order = await _orderService.GetOrderAsync(intent.OrderId);
var context = new OrderContext(intent.OrderId) { Order = order };
_navProvider.AddNavigationScoped<IOrderContext>(context);
}
}
// 3. Child pages consume the context
public class OrderItemsPageModel : ObservableObject
{
private readonly IOrderContext _orderContext;
public OrderItemsPageModel(INavigationServiceProvider navProvider)
{
_orderContext = navProvider.GetRequiredService<IOrderContext>();
}
[RelayCommand]
private Task AddItemAsync()
{
_orderContext.Order.Items.Add(newItem);
_orderContext.RecalculateTotal();
return Task.CompletedTask;
}
}
public class OrderSummaryPageModel : ObservableObject
{
private readonly IOrderContext _orderContext;
public OrderSummaryPageModel(INavigationServiceProvider navProvider)
{
_orderContext = navProvider.GetRequiredService<IOrderContext>();
}
public decimal TotalAmount => _orderContext.TotalAmount;
}
Accessing the Context Page
Get a reference to the page that created the scope:
public class ChildPageModel : ObservableObject
{
public ChildPageModel(INavigationServiceProvider navProvider)
{
// Get the parent page
Page contextPage = navProvider.ContextPage;
// Can access parent page properties/methods if needed
if (contextPage.BindingContext is IParentContext parent)
{
// Interact with parent
}
}
}
Nested Scopes
Child pages can create their own scopes:
// Parent: Order scope
_navProvider.AddNavigationScoped<IOrderContext>(orderContext);
// Child: Payment scope (nested under order scope)
_navProvider.AddNavigationScoped<IPaymentContext>(paymentContext);
// Grandchild: Has access to both
var orderContext = _navProvider.GetRequiredService<IOrderContext>();
var paymentContext = _navProvider.GetRequiredService<IPaymentContext>();
When to Use
| Use Navigation-Scoped Services | Use Intents |
|---|---|
| Data shared across multiple nested pages | One-time parameter passing |
| Mutable shared state | Immutable initialization data |
| Complex context (shopping cart, wizard) | Simple values |
| Parent-child relationships | Peer-to-peer passing |
Leak Detection
Nalu can automatically detect memory leaks during development.
Enabling Leak Detection
.UseNaluNavigation<App>(nav => nav
.AddPage<MainPageModel, MainPage>()
.WithLeakDetectorState(NavigationLeakDetectorState.EnabledWithDebugger)
)
Options:
Disabled- No leak detectionEnabledWithDebugger- Only when debugger is attached (recommended)Enabled- Always enabled
How It Works
When a page is popped, Nalu monitors if it's garbage collected within a reasonable time. If not, you'll see an alert:
Memory Leak Detected!
Page 'ContactDetailPage' was not collected after navigation.
Check for event subscriptions or static references.
Common Causes of Leaks
- Event subscriptions
// ❌ Bad - never unsubscribes
public class PageModel
{
public PageModel(IEventAggregator events)
{
events.GetEvent<DataChanged>().Subscribe(OnDataChanged);
}
}
// ✅ Good - unsubscribes
public class PageModel : ILeavingAware
{
private readonly IEventAggregator _events;
public PageModel(IEventAggregator events)
{
_events = events;
_events.GetEvent<DataChanged>().Subscribe(OnDataChanged);
}
public ValueTask OnLeavingAsync()
{
_events.GetEvent<DataChanged>().Unsubscribe(OnDataChanged);
return ValueTask.CompletedTask;
}
}
- Static references
// ❌ Bad - static reference prevents GC
public static class Cache
{
public static Page CurrentPage { get; set; }
}
// ✅ Good - use weak references if needed
public static class Cache
{
public static WeakReference<Page> CurrentPage { get; set; }
}
- Timers not disposed
// ❌ Bad - timer keeps page alive
public class PageModel
{
private Timer _timer = new Timer(Callback, null, 0, 1000);
}
// ✅ Good - dispose timer
public class PageModel : IDisposable
{
private Timer _timer;
public PageModel()
{
_timer = new Timer(Callback, null, 0, 1000);
}
public void Dispose()
{
_timer?.Dispose();
}
}
- Long-running tasks
// ❌ Bad - task holds reference
public async ValueTask OnAppearingAsync()
{
await Task.Run(async () =>
{
while (true)
{
await Task.Delay(1000);
UpdateUI(); // Holds reference to page
}
});
}
// ✅ Good - use cancellation
public class PageModel : IAppearingAware, ILeavingAware, IDisposable
{
private CancellationTokenSource _cts;
public async ValueTask OnAppearingAsync()
{
_cts = new CancellationTokenSource();
try
{
await Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
await Task.Delay(1000, _cts.Token);
UpdateUI();
}
}, _cts.Token);
}
catch (OperationCanceledException) { }
}
public ValueTask OnLeavingAsync()
{
_cts?.Cancel();
return ValueTask.CompletedTask;
}
public void Dispose()
{
_cts?.Dispose();
}
}
Debugging Leaks
- Enable leak detection
- Navigate to a page and then away from it
- If a leak is detected, check:
- Event subscriptions (most common)
- Timers and periodic tasks
- Static references
- Long-running operations
- Add
IDisposableand cleanup inDispose()orOnLeavingAsync()
Customizing Navigation Bar
Globally customize navigation icons:
.UseNaluNavigation<App>(nav => nav
.AddPage<MainPageModel, MainPage>()
.WithMenuIcon(ImageSource.FromFile("menu.png"))
.WithBackIcon(ImageSource.FromFile("back.png"))
)
Monitoring Navigation Events
Subscribe to all navigation events at the Shell level:
public partial class AppShell : NaluShell
{
public AppShell(INavigationService navigationService)
: base(navigationService, typeof(MainPage))
{
InitializeComponent();
NavigationEvent += OnNavigationEvent;
}
private void OnNavigationEvent(object? sender, NavigationLifecycleEventArgs e)
{
Debug.WriteLine($"Navigation Event: {e.EventType}");
switch (e.EventType)
{
case NavigationLifecycleEventType.NavigationRequested:
var info = (NavigationLifecycleInfo)e.Target;
Debug.WriteLine($" Requested: {info.RequestedNavigation}");
Debug.WriteLine($" From: {info.CurrentState}");
Debug.WriteLine($" To: {info.TargetState}");
break;
case NavigationLifecycleEventType.NavigationCompleted:
// Track navigation for analytics
LogNavigationToAnalytics(e);
break;
case NavigationLifecycleEventType.NavigationCanceled:
// User guard blocked navigation
Debug.WriteLine(" Navigation was blocked by guard");
break;
case NavigationLifecycleEventType.Entering:
case NavigationLifecycleEventType.Appearing:
case NavigationLifecycleEventType.Disappearing:
case NavigationLifecycleEventType.Leaving:
Debug.WriteLine($" Page: {e.Target.GetType().Name}");
if (e.Data != null)
Debug.WriteLine($" Intent: {e.Data.GetType().Name}");
break;
}
}
}
Event Types
NavigationRequested- Navigation startsNavigationCompleted- Navigation finished successfullyNavigationCanceled- Blocked by guardNavigationFailed- Exception occurredNavigationIgnored- Navigation triggered on wrong stateEntering/Appearing/Disappearing/Leaving- Page lifecycleLeavingGuard- Guard evaluation
Use Cases
- Analytics: Track user navigation patterns
- Logging: Debug navigation issues
- Performance: Measure navigation timing
- State management: Update global state on navigation