Skip to content

Proposal: Decouple Navigation from Page Using Type-Based Navigation #21957

Description

@BlameTwo

Is your feature request related to a problem? Please describe.

Proposal: Reduce UI Coupling in Avalonia 12 Navigation and Input APIs

Summary

I would like to propose reducing the dependency on concrete UI instances in some Avalonia 12 APIs, especially Navigation and routed pointer input.

The main concern is that several application-level behaviors still require developers to work directly with Page or Control instances.

For example, navigation currently requires an instantiated page:

await Navigation.PushAsync(new SettingsPage());

And in some pointer input scenarios, especially with Button, receiving handled pointer events requires directly calling:

button.AddHandler(
    InputElement.PointerPressedEvent,
    OnPointerPressed,
    RoutingStrategies.Bubble,
    handledEventsToo: true);

These APIs work, but both expose the same architectural problem:

Higher-level application behavior is unnecessarily coupled to concrete UI objects.

I think Avalonia would benefit from stronger framework-level abstractions where controls are the result of resolution/rendering, rather than something application logic must directly provide or manipulate.


1. Navigation should not require a Page instance

Currently, navigation is fundamentally based on passing an already-created Page:

await Navigation.PushAsync(new SettingsPage());

Conceptually, this means:

NavigateTo(Page page);

I believe this abstraction is too UI-centric.

A navigation request should describe where the application wants to navigate, not require the caller to construct the visual object first.

For example:

await Navigation.PushAsync(typeof(SettingsPage));

or preferably:

await Navigation.PushAsync<SettingsPage>();

The framework could then resolve or construct the page internally.

Why this matters

With the current design:

var page = new SettingsPage();
await Navigation.PushAsync(page);

the caller is responsible for both:

  1. Creating the UI
  2. Performing the navigation

In applications using dependency injection, it may become:

var page = serviceProvider.GetRequiredService<SettingsPage>();
await Navigation.PushAsync(page);

This introduces coupling between:

  • Navigation
  • Page
  • Dependency injection
  • View lifetime
  • ViewModel lifetime
  • Application architecture

The navigation layer should not need the caller to resolve a visual object before expressing navigation intent.

A cleaner dependency direction would be:

Application
    ↓
Navigation request
    ↓
Page resolver / factory
    ↓
Page

rather than:

Application
    ↓
Page creation
    ↓
Navigation

Suggested navigation API

A minimal API could be:

Task PushAsync(Type pageType);
Task ReplaceAsync(Type pageType);

with generic overloads:

Task PushAsync<TPage>() where TPage : Page;
Task ReplaceAsync<TPage>() where TPage : Page;

Page creation could be delegated to a framework abstraction:

public interface INavigationPageResolver
{
    Page Resolve(Type pageType);
}

The default implementation could use Activator.CreateInstance, while applications could provide their own resolver.

For example, a DI-based resolver could be:

public sealed class ServiceProviderPageResolver : INavigationPageResolver
{
    private readonly IServiceProvider _services;

    public ServiceProviderPageResolver(IServiceProvider services)
    {
        _services = services;
    }

    public Page Resolve(Type pageType)
    {
        return (Page)_services.GetRequiredService(pageType);
    }
}

This would allow navigation to remain independent of page construction.

The existing API:

PushAsync(Page page);

could remain available as a lower-level overload for cases where passing a specific instance is intentional.


Why Type is better than Page as the navigation input

Using Type instead of an instantiated Page provides several benefits.

Decoupled page construction

The caller only expresses the destination:

await Navigation.PushAsync<SettingsPage>();

It does not need to know how the page is created.

Better DI integration

The navigation framework can delegate resolution to an application-provided resolver without requiring business or ViewModel code to behave like a service locator.

Better lifetime control

The navigation system can decide when pages should be:

  • Created
  • Reused
  • Cached
  • Recreated
  • Released

When the caller passes an existing instance, part of that lifetime decision has already been made outside the navigation framework.

Better testability

A navigation request can be represented using metadata rather than requiring a real control tree:

await navigation.PushAsync<SettingsPage>();

Assert.Equal(typeof(SettingsPage), navigation.CurrentPageType);

Better foundation for future abstractions

Once navigation is no longer fundamentally based on page instances, Avalonia could later support higher-level concepts such as:

NavigateAsync<SettingsViewModel>();

or:

NavigateAsync("settings");

without redesigning the entire navigation model.


2. Pointer input has a similar UI coupling problem

I encountered a similar issue when using pointer events on Button.

Avalonia exposes useful low-level events such as:

PointerPressed
PointerReleased

These are important when an application needs to distinguish between:

  • The moment the left mouse button is pressed
  • The moment the left mouse button is released

This is not equivalent to Click.

For example, an application may need to begin an operation on press and finish it on release.

That is why I used PointerPressed and PointerReleased instead of Click.

However, on Button, these events can already be handled internally by the control.

As a result, a normal routed event subscription may not receive them.

To receive the event, the application has to use:

button.AddHandler(
    InputElement.PointerPressedEvent,
    OnPointerPressed,
    RoutingStrategies.Bubble,
    handledEventsToo: true);

and similarly for PointerReleased.

This was surprising.

Avalonia exposes PointerPressed and PointerReleased, but using them on one of the most common controls may require directly accessing the Button instance and manually registering a handler from C#.


The problem is not that Button handles pointer events

It is completely reasonable for Button to internally handle pointer input.

A button needs to implement behavior such as:

PointerPressed
    ↓
Pressed state
    ↓
Pointer capture
    ↓
PointerReleased
    ↓
Click

It also needs to unify interaction from:

  • Mouse
  • Touch
  • Pen
  • Keyboard
  • Accessibility input

So internally marking pointer events as handled is understandable.

The problem is what happens after that.

If application code still needs to observe the press/release phases, there should ideally be a convenient declarative or MVVM-friendly mechanism for receiving handled routed events.

Instead, the application currently has to return to:

Control.AddHandler(...)

with:

handledEventsToo: true

That forces application interaction logic back onto a concrete UI instance.


MVVM impact

Without this issue, the expected architecture would be:

Pointer interaction
        ↓
Command / Binding
        ↓
ViewModel

But when AddHandler is required, it becomes:

Pointer interaction
        ↓
Control instance
        ↓
AddHandler
        ↓
Event callback
        ↓
Command / ViewModel

The application now needs an additional View-specific bridge.

If a project wants to preserve MVVM, developers may have to implement custom infrastructure such as:

PointerPressedCommand
PointerReleasedCommand

using attached properties or behaviors.

Internally those abstractions still need to call:

AddHandler(..., handledEventsToo: true);

For larger projects, this can eventually lead to developers writing repetitive event-to-command infrastructure or even source generators simply to expose framework input events cleanly to ViewModels.

I do not think application developers should need to rebuild this layer themselves.


A possible input abstraction

Avalonia could provide a declarative mechanism for routed events that supports receiving already-handled events.

Conceptually, something like:

<Button>
    <Button.PointerPressed>
        <EventBinding
            Command="{Binding PressCommand}"
            HandledEventsToo="True" />
    </Button.PointerPressed>
</Button>

or an equivalent behavior/attached API.

The exact syntax is less important than the architectural goal:

Receiving handled routed events should not require application code to manually obtain the Control instance and call AddHandler.

This would preserve Avalonia's routed event implementation while providing a cleaner MVVM-facing abstraction.


The common architectural issue

Navigation and pointer input may look unrelated, but I believe they expose the same underlying design problem.

Navigation currently becomes:

Application intent
        ↓
Page instance
        ↓
Navigation

Pointer interaction can become:

Application interaction
        ↓
Control instance
        ↓
AddHandler

In both cases, higher-level application behavior eventually depends directly on a concrete UI object.

This makes the Visual Tree more central to application architecture than necessary.

For a modern application architecture using DI and MVVM, I would expect something closer to:

Application / ViewModel
        ↓
Framework abstraction
        ↓
UI resolution / adapter
        ↓
Control

The concrete UI object should ideally exist at the edge of the architecture.


Why this matters especially for DI-based applications

A DI-oriented application usually wants dependencies and lifetimes to remain explicit:

IServiceProvider
├── Application services
├── Navigation
├── State
├── ViewModels
└── UI

The UI layer should consume application services.

However, APIs that require Page, Control, TopLevel, or other concrete visual objects can reverse that dependency:

Application service
        ↓
Needs UI object
        ↓
Needs Visual Tree / UI lifetime

This makes it harder to keep:

  • ViewModels testable
  • Services independent from the UI
  • Views transient
  • UI lifetime separate from application lifetime
  • Navigation state independent from rendered controls

This becomes increasingly important for applications that have:

  • Background services
  • Tray functionality
  • Multiple windows
  • Long-running tasks
  • IPC
  • Networking
  • State persistence
  • UI recreation

In those applications, the UI is not necessarily the application itself. It is one frontend of the application.


Closing thoughts

Avalonia already provides strong support for:

  • Data binding
  • MVVM
  • DataTemplates
  • DI-based application architectures
  • Cross-platform UI

For that reason, I think it would be valuable for framework APIs to continue moving away from requiring concrete UI instances for application-level behavior.

For navigation:

A Page should be the result of navigation resolution, not the input of a navigation request.

For pointer input:

Receiving a handled routed event should not require application code to manually access the control instance and call AddHandler.

More broadly:

Application behavior should depend on framework-level abstractions, while concrete controls should remain at the UI boundary.

Type-based navigation combined with an extensible page resolver, together with a declarative way to observe handled routed events, would significantly improve the experience of building DI- and MVVM-oriented Avalonia applications.

Describe the solution you'd like

My overall impression is that Avalonia is not particularly well suited to applications that rely heavily on dependency injection and strict separation between application architecture and the UI layer.

In several areas, I feel that the abstraction level of the framework APIs is too low. Many APIs expose or require direct interaction with concrete controls. Of course, direct control manipulation is powerful and flexible, and it can solve many problems, but I do not think it should be the primary abstraction for common application-level scenarios.

For me, the main concern is not whether these APIs are capable enough. They clearly are. The concern is that application architecture is often forced to depend on UI objects earlier than necessary.

Comparison with other frameworks

My main focus is Windows desktop development. The frameworks I use most frequently are WPF and WinUI.

WPF is an older framework with many APIs that reflect the design philosophy of its time, while WinUI represents a more modern API design in many areas. Despite their differences, I can usually switch between them without significantly changing the overall architecture of my application.

With Avalonia, however, I frequently find myself having to adapt the architecture around the UI framework itself.

For example, instead of keeping navigation, interaction logic, and application services independent from concrete controls, I often end up needing access to objects such as:

Page
Control
TopLevel
Window

This makes the UI layer feel more central to the application architecture than I would expect.

My preferred architecture is closer to:

Application / Services
        ↓
ViewModels / State
        ↓
UI abstraction
        ↓
Views / Controls

rather than:

Application logic
        ↓
Concrete Control / Page
        ↓
Framework API

This distinction is particularly important in applications that make extensive use of dependency injection, background services, multiple windows, persistent application state, or independently recreatable views.

I am not suggesting that direct control-based APIs should be removed. They are useful as low-level escape hatches.

However, I would prefer Avalonia to provide higher-level framework abstractions for common scenarios, while keeping direct control manipulation as the lower-level option.

Describe alternatives you've considered

The above description of the problem has been revised by GPT. We hope this version can make it easier for you to understand the issue.

Additional context

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions