Skip to content

Sextant - ReactiveUI Navigation Library

Sextant is a view model first navigation library for ReactiveUI applications that provides a simple, reactive, and testable approach to navigation across multiple platforms.

Overview

Sextant focuses on:

  • ViewModel-First Navigation: Navigate using view models, not views
  • Reactive APIs: All navigation operations return IObservable<Unit>
  • Lifecycle Hooks: Parameterized navigation with INavigable interface
  • Cross-Platform: Uniform abstractions across MAUI and Avalonia
  • Testable: Full unit testing support for navigation logic

Packages

Sextant is modular with platform-specific packages:

PackageDescriptionNuGet
SextantCore navigation abstractions (required)NuGet
Sextant.Maui.NET MAUI implementationNuGet
Sextant.AvaloniaAvalonia implementationNuGet
Sextant.Plugins.PopupMopups-based modal plugin (MAUI)NuGet

Installation

# Core (always required)
dotnet add package Sextant

# Platform-specific
dotnet add package Sextant.Maui        # For .NET MAUI
dotnet add package Sextant.Avalonia    # For Avalonia

# Optional: Popup support for MAUI
dotnet add package Sextant.Plugins.Popup

Platform Support

Sextant follows ReactiveUI platform minimums:

  • .NET MAUI: .NET 8.0+
  • Avalonia: .NET 8.0+

> Migration Note: Coming from a legacy Xamarin.Forms codebase? See the Xamarin to MAUI Migration Guide.

Getting Started

.NET MAUI Setup

1. Register Navigation Services

In your App.xaml.cs or during DI setup:

using ReactiveUI;
using ReactiveUI.Maui;
using Sextant;
using Sextant.Maui;
using Splat;

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
        
        // Register navigation components
        AppLocator.CurrentMutable
            // Register view locator
            .RegisterConstant(ViewLocator.Current, typeof(IViewLocator))
            // Register Sextant navigation view (MAUI)
            .RegisterNavigationView()
            // Register view model factory
            .RegisterViewModelFactory(() => new DefaultViewModelFactory())
            // Register navigation service
            .RegisterParameterViewStackService()
            // Register views and view models
            .RegisterViewForNavigation<HomeView, HomeViewModel>(
                () => new HomeView(), 
                () => new HomeViewModel())
            .RegisterViewForNavigation<DetailsView, DetailsViewModel>(
                () => new DetailsView(), 
                () => new DetailsViewModel());
        
        // Set MainPage to NavigationView
        MainPage = AppLocator.Current.GetNavigationView();
        
        // Push initial page
        AppLocator.Current
            .GetService<IParameterViewStackService>()
            .PushPage<HomeViewModel>(resetStack: true, animate: false)
            .Subscribe();
    }
}

2. Create a View Model

using ReactiveUI;
using Sextant;
using System.Reactive;

public class HomeViewModel : ReactiveObject, IViewModel
{
    private readonly IViewStackService _viewStack;
    
    public HomeViewModel(IViewStackService viewStack = null)
    {
        _viewStack = viewStack ?? AppLocator.Current.GetService<IViewStackService>();
        
        OpenDetails = ReactiveCommand.CreateFromObservable(
            () => _viewStack.PushPage<DetailsViewModel>(),
            outputScheduler: RxSchedulers.MainThreadScheduler);
        
        OpenModal = ReactiveCommand.CreateFromObservable(
            () => _viewStack.PushModal<AboutViewModel>(),
            outputScheduler: RxSchedulers.MainThreadScheduler);
    }
    
    public string Id => nameof(HomeViewModel);
    
    public ReactiveCommand<Unit, Unit> OpenDetails { get; }
    public ReactiveCommand<Unit, Unit> OpenModal { get; }
}

3. Create a View

<?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:rxui="clr-namespace:ReactiveUI.Maui;assembly=ReactiveUI.Maui"
    xmlns:vm="clr-namespace:MyApp.ViewModels"
    x:Class="MyApp.Views.HomeView"
    x:TypeArguments="vm:HomeViewModel"
    x:DataType="vm:HomeViewModel">
    
    <VerticalStackLayout Spacing="10" Padding="20">
        <Button x:Name="DetailsButton" Text="Open Details" />
        <Button x:Name="ModalButton" Text="Open Modal" />
    </VerticalStackLayout>
</ContentPage>
using ReactiveUI;
using ReactiveUI.Maui;

public partial class HomeView : ReactiveContentPage<HomeViewModel>
{
    public HomeView()
    {
        InitializeComponent();
        
        this.WhenActivated(disposables =>
        {
            this.BindCommand(ViewModel, vm => vm.OpenDetails, v => v.DetailsButton)
                .DisposeWith(disposables);
            
            this.BindCommand(ViewModel, vm => vm.OpenModal, v => v.ModalButton)
                .DisposeWith(disposables);
        });
    }
}

Avalonia Setup

1. Register Navigation Services

using Avalonia;
using Avalonia.Controls;
using ReactiveUI;
using Sextant;
using Sextant.Avalonia;
using Splat;

public class App : Application
{
    public override void OnFrameworkInitializationCompleted()
    {
        // Register navigation components
        AppLocator.CurrentMutable
            .RegisterConstant(ViewLocator.Current, typeof(IViewLocator))
            .RegisterNavigationView(() => new Sextant.Avalonia.NavigationView())
            .RegisterViewModelFactory(() => new DefaultViewModelFactory())
            .RegisterViewForNavigation<HomeView, HomeViewModel>(
                () => new HomeView(), 
                () => new HomeViewModel());
        
        // Get navigation service
        var viewStack = AppLocator.Current.GetService<IViewStackService>();
        viewStack.PushPage<HomeViewModel>(resetStack: true).Subscribe();
        
        // Set main window
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            desktop.MainWindow = new Window 
            { 
                Content = AppLocator.Current.GetNavigationView() 
            };
        }
        
        base.OnFrameworkInitializationCompleted();
    }
}

Sextant provides two main navigation services:

IViewStackService

Basic view model first navigation without parameters:

public interface IViewStackService
{
    // Push pages
    IObservable<Unit> PushPage<TViewModel>(
        string contract = null, 
        bool resetStack = false, 
        bool animate = true) where TViewModel : IViewModel;
    
    IObservable<Unit> PushPage(
        IViewModel viewModel, 
        string contract = null, 
        bool resetStack = false, 
        bool animate = true);
    
    // Push modals
    IObservable<Unit> PushModal<TViewModel>(
        string contract = null, 
        bool withNavigationPage = true) where TViewModel : IViewModel;
    
    IObservable<Unit> PushModal(
        IViewModel modal, 
        string contract = null, 
        bool withNavigationPage = true);
    
    // Pop operations
    IObservable<Unit> PopPage(bool animate = true);
    IObservable<Unit> PopModal(bool animate = true);
    IObservable<Unit> PopToRootPage(bool animate = true);
    
    // Stack observables
    IObservable<IImmutableList<IViewModel>> PageStack { get; }
    IObservable<IImmutableList<IViewModel>> ModalStack { get; }
    
    // Top view models
    IObservable<IViewModel> TopPage();
    IObservable<IViewModel> TopModal();
}

IParameterViewStackService

Navigation with parameter passing and lifecycle hooks:

public interface IParameterViewStackService : IViewStackService
{
    // Push with parameters
    IObservable<Unit> PushPage<TViewModel>(
        INavigationParameter parameter, 
        string contract = null, 
        bool resetStack = false, 
        bool animate = true) where TViewModel : INavigable;
    
    IObservable<Unit> PushPage(
        INavigable viewModel, 
        INavigationParameter parameter, 
        string contract = null, 
        bool resetStack = false, 
        bool animate = true);
    
    IObservable<Unit> PushModal<TViewModel>(
        INavigationParameter parameter, 
        string contract = null, 
        bool withNavigationPage = true) where TViewModel : INavigable;
    
    IObservable<Unit> PushModal(
        INavigable modal, 
        INavigationParameter parameter, 
        string contract = null, 
        bool withNavigationPage = true);
    
    // Pop with parameters
    IObservable<Unit> PopPage(
        INavigationParameter parameter, 
        bool animate = true);
}

Parameter Passing and Lifecycle

INavigable Interface

Implement INavigable to receive navigation parameters and lifecycle notifications:

using Sextant;
using System.Reactive;

public class DetailsViewModel : ReactiveObject, INavigable
{
    public string Id => nameof(DetailsViewModel);
    
    public int ItemId { get; private set; }
    public string ItemName { get; private set; }
    
    // Called before navigation begins
    public IObservable<Unit> WhenNavigatingTo(INavigationParameter parameter)
    {
        // Validate or prepare for navigation
        return Observable.Return(Unit.Default);
    }
    
    // Called after navigation completes
    public IObservable<Unit> WhenNavigatedTo(INavigationParameter parameter)
    {
        // Read parameters
        if (parameter.TryGetValue("ItemId", out int itemId))
        {
            ItemId = itemId;
        }
        
        if (parameter.TryGetValue("ItemName", out string itemName))
        {
            ItemName = itemName;
        }
        
        // Load data based on parameters
        return LoadDataAsync();
    }
    
    // Called when navigating away
    public IObservable<Unit> WhenNavigatedFrom(INavigationParameter parameter)
    {
        // Save state or cleanup
        return SaveStateAsync();
    }
    
    private IObservable<Unit> LoadDataAsync() => 
        Observable.Return(Unit.Default);
    
    private IObservable<Unit> SaveStateAsync() => 
        Observable.Return(Unit.Default);
}

Passing Parameters

public class HomeViewModel : ReactiveObject, IViewModel
{
    private readonly IParameterViewStackService _viewStack;
    
    public HomeViewModel(IParameterViewStackService viewStack)
    {
        _viewStack = viewStack;
        
        OpenDetails = ReactiveCommand.CreateFromObservable(
            () =>
            {
                var parameters = new NavigationParameter
                {
                    { "ItemId", 123 },
                    { "ItemName", "Sample Item" },
                    { "IsEditMode", true }
                };
                
                return _viewStack.PushPage<DetailsViewModel>(parameters);
            },
            outputScheduler: RxSchedulers.MainThreadScheduler);
    }
    
    public string Id => nameof(HomeViewModel);
    public ReactiveCommand<Unit, Unit> OpenDetails { get; }
}

Advanced Patterns

public class SelectItemViewModel : ReactiveObject, INavigable
{
    private readonly Subject<Item> _selectedItemSubject = new();
    private readonly IViewStackService _viewStack;
    
    public IObservable<Item> SelectedItem => _selectedItemSubject.AsObservable();
    
    public ReactiveCommand<Item, Unit> SelectItem { get; }
    
    public SelectItemViewModel(IViewStackService viewStack)
    {
        _viewStack = viewStack;
        
        SelectItem = ReactiveCommand.CreateFromObservable<Item>(item =>
        {
            _selectedItemSubject.OnNext(item);
            _selectedItemSubject.OnCompleted();
            return _viewStack.PopPage();
        });
    }
    
    public string Id => nameof(SelectItemViewModel);
}

// Usage
var selectVm = new SelectItemViewModel(viewStack);
selectVm.SelectedItem
    .Take(1)
    .Subscribe(item => ProcessSelectedItem(item));

await viewStack.PushPage(selectVm);
public class EditViewModel : ReactiveObject, INavigable
{
    [Reactive]
    public bool HasUnsavedChanges { get; set; }
    
    public string Id => nameof(EditViewModel);
    
    public IObservable<Unit> WhenNavigatedFrom(INavigationParameter parameter)
    {
        if (!HasUnsavedChanges)
        {
            return Observable.Return(Unit.Default);
        }
        
        // Show confirmation dialog
        return Observable.FromAsync(async () =>
        {
            var confirmed = await ShowDiscardConfirmationAsync();
            if (!confirmed)
            {
                throw new OperationCanceledException("Navigation canceled by user");
            }
        });
    }
    
    private async Task<bool> ShowDiscardConfirmationAsync()
    {
        // Show dialog implementation
        return await Task.FromResult(true);
    }
}

Conditional Navigation

public class MainViewModel : ReactiveObject, IViewModel
{
    public ReactiveCommand<Unit, Unit> NavigateCommand { get; }
    
    public MainViewModel(IViewStackService viewStack)
    {
        var canNavigate = this.WhenAnyValue(
            x => x.IsValid, 
            x => x.IsConnected,
            (valid, connected) => valid && connected);
        
        NavigateCommand = ReactiveCommand.CreateFromObservable(
            () => viewStack.PushPage<NextViewModel>(),
            canNavigate,
            RxSchedulers.MainThreadScheduler);
    }
    
    public string Id => nameof(MainViewModel);
    
    [Reactive]
    public bool IsValid { get; set; }
    
    [Reactive]
    public bool IsConnected { get; set; }
}
// Present modal with its own navigation stack
await viewStack.PushModal<ModalRootViewModel>(withNavigationPage: true);

// Present modal without navigation stack
await viewStack.PushModal<SimpleModalViewModel>(withNavigationPage: false);

Stack Management

// Reset stack to single page
await viewStack.PushPage<HomeViewModel>(resetStack: true);

// Pop to root (clear all except first page)
await viewStack.PopToRootPage();

// Observe stack changes
viewStack.PageStack
    .Subscribe(stack => 
    {
        Console.WriteLine($"Page count: {stack.Count}");
    });

For MAUI applications, use Sextant.Plugins.Popup for Mopups-based popups:

dotnet add package Sextant.Plugins.Popup

Setup

using Sextant.Plugins.Popup;

// In MauiProgram.cs
builder.ConfigureMopups();

// Register popup service
AppLocator.CurrentMutable.RegisterPopupViewStackService();

Usage

public class HomeViewModel : ReactiveObject, IViewModel
{
    private readonly IPopupViewStackService _popupService;
    
    public HomeViewModel(IPopupViewStackService popupService)
    {
        _popupService = popupService;
        
        ShowPopup = ReactiveCommand.CreateFromObservable(
            () => _popupService.PushPopup<PopupViewModel>(),
            outputScheduler: RxSchedulers.MainThreadScheduler);
    }
    
    public string Id => nameof(HomeViewModel);
    public ReactiveCommand<Unit, Unit> ShowPopup { get; }
}

Contracts

Contracts allow registering multiple views for the same view model:

// Register multiple views
AppLocator.CurrentMutable
    .RegisterViewForNavigation<DetailView, DetailViewModel>(
        () => new DetailView(), 
        () => new DetailViewModel(),
        contract: "Phone")
    .RegisterViewForNavigation<DetailTabletView, DetailViewModel>(
        () => new DetailTabletView(), 
        () => new DetailViewModel(),
        contract: "Tablet");

// Navigate with contract
await viewStack.PushPage<DetailViewModel>(contract: "Tablet");

Testing Navigation

using Xunit;
using NSubstitute;
using Sextant;

public class HomeViewModelTests
{
    [Fact]
    public async Task OpenDetails_ShouldPushDetailsViewModel()
    {
        // Arrange
        var viewStack = Substitute.For<IViewStackService>();
        viewStack.PushPage<DetailsViewModel>(null, false, true)
            .Returns(Observable.Return(Unit.Default));
        
        var viewModel = new HomeViewModel(viewStack);
        
        // Act
        await viewModel.OpenDetails.Execute();
        
        // Assert
        await viewStack.Received(1).PushPage<DetailsViewModel>(
            Arg.Any<string>(), 
            Arg.Any<bool>(), 
            Arg.Any<bool>());
    }
    
    [Fact]
    public async Task SelectItem_ShouldPassParameters()
    {
        // Arrange
        var parameterViewStack = Substitute.For<IParameterViewStackService>();
        parameterViewStack
            .PushPage<DetailsViewModel>(
                Arg.Any<INavigationParameter>(), 
                null, 
                false, 
                true)
            .Returns(Observable.Return(Unit.Default));
        
        var viewModel = new HomeViewModel(parameterViewStack);
        viewModel.SelectedItemId = 123;
        
        // Act
        await viewModel.OpenDetails.Execute();
        
        // Assert
        await parameterViewStack.Received(1).PushPage<DetailsViewModel>(
            Arg.Is<INavigationParameter>(p => 
                p.ContainsKey("ItemId") && 
                (int)p["ItemId"] == 123),
            Arg.Any<string>(),
            Arg.Any<bool>(),
            Arg.Any<bool>());
    }
}

Best Practices

  • 1. Use IParameterViewStackService: When passing data or using lifecycle hooks
  • 2. Inject Navigation Services: Pass via constructor for testability
  • 3. Observe on Main Thread: Use RxSchedulers.MainThreadScheduler for UI operations
  • 4. Dispose Subscriptions: Always use DisposeWith(disposables) in WhenActivated
  • 5. Implement INavigable: For view models that need lifecycle notifications
  • 6. Use Contracts Sparingly: Only when truly need multiple views per view model
  • 7. Test Navigation Logic: Write unit tests for all navigation scenarios

Common Patterns

Master-Detail Flow

public class MasterViewModel : ReactiveObject, IViewModel
{
    public ReactiveCommand<Item, Unit> NavigateToDetail { get; }
    
    public MasterViewModel(IParameterViewStackService viewStack)
    {
        NavigateToDetail = ReactiveCommand.CreateFromObservable<Item>(item =>
        {
            var parameters = new NavigationParameter
            {
                { "Item", item }
            };
            return viewStack.PushPage<DetailViewModel>(parameters);
        });
    }
    
    public string Id => nameof(MasterViewModel);
}

Wizard/Multi-Step

public class WizardCoordinator
{
    private readonly IViewStackService _viewStack;
    private readonly Type[] _steps;
    private int _currentStep;
    
    public WizardCoordinator(IViewStackService viewStack)
    {
        _viewStack = viewStack;
        _steps = new[]
        {
            typeof(Step1ViewModel),
            typeof(Step2ViewModel),
            typeof(Step3ViewModel)
        };
    }
    
    public async Task StartWizard()
    {
        _currentStep = 0;
        await _viewStack.PushPage((IViewModel)Activator.CreateInstance(_steps[0]), 
            resetStack: true);
    }
    
    public async Task NextStep()
    {
        if (_currentStep < _steps.Length - 1)
        {
            _currentStep++;
            await _viewStack.PushPage(
                (IViewModel)Activator.CreateInstance(_steps[_currentStep]));
        }
    }
    
    public async Task PreviousStep()
    {
        if (_currentStep > 0)
        {
            _currentStep--;
            await _viewStack.PopPage();
        }
    }
}

Troubleshooting

Problem: Navigation command executes but nothing happens

Solution: Ensure you subscribe to the navigation observable:

// Wrong ?
viewStack.PushPage<DetailsViewModel>();

// Correct ?
viewStack.PushPage<DetailsViewModel>().Subscribe();

// Best ? - In ReactiveCommand
ReactiveCommand.CreateFromObservable(
    () => viewStack.PushPage<DetailsViewModel>());

View Not Found

Problem: "View not found for ViewModel" exception

Solution: Register view-viewmodel pair:

AppLocator.CurrentMutable.RegisterViewForNavigation<MyView, MyViewModel>(
    () => new MyView(),
    () => new MyViewModel());

Parameters Not Received

Problem: Parameters are null in WhenNavigatedTo

Solution: Implement INavigable and use IParameterViewStackService:

// ViewModel must implement INavigable
public class MyViewModel : ReactiveObject, INavigable

// Use IParameterViewStackService
var paramService = AppLocator.Current.GetService<IParameterViewStackService>();

Additional Resources