.NET MAUI: Cross-Platform Mobile and Desktop Apps with C#
Build iOS, Android, Windows, and macOS apps with .NET MAUI. Covers MVVM, Shell navigation, data binding, platform-specific code, HTTP integration, local storage, and when to choose MAUI vs native.
What is .NET MAUI?
.NET Multi-platform App UI (MAUI) is the successor to Xamarin.Forms. Write C# and XAML once, deploy to iOS, Android, Windows, and macOS. The same .NET skills, the same NuGet packages, a shared codebase.
.NET MAUI App
├── Shared Code (C# + XAML)
│ ├── Pages / Views
│ ├── ViewModels
│ ├── Services
│ └── Models
└── Platform-Specific
├── Platforms/iOS/
├── Platforms/Android/
├── Platforms/Windows/
└── Platforms/MacCatalyst/When to choose MAUI:
- Shared business logic with a .NET backend (share models, validation, services)
- Team knows C# and doesn't want to learn Swift/Kotlin/TypeScript
- Internal enterprise apps with feature parity requirements across platforms
- Desktop (Windows/macOS) app alongside mobile
When to choose native:
- Performance-critical games or advanced UI animations
- Cutting-edge platform features needed on day one
- Large platform-specific teams already in place
Project Setup
dotnet new maui -n OrderFlow.Mobile
cd OrderFlow.Mobile
dotnet build -t:Run -f net9.0-androidProject structure:
OrderFlow.Mobile/
├── MauiProgram.cs ← DI setup
├── App.xaml / App.xaml.cs ← app lifecycle
├── AppShell.xaml ← navigation
├── Pages/ ← UI pages
├── ViewModels/ ← MVVM view models
├── Services/ ← HTTP, local storage
└── Platforms/ ← platform-specific codeMVVM: The Architecture Pattern
// ViewModels/OrdersViewModel.cs
public partial class OrdersViewModel : ObservableObject
{
private readonly IOrderService _orderService;
[ObservableProperty]
private ObservableCollection<OrderDto> _orders = new();
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _errorMessage = "";
public OrdersViewModel(IOrderService orderService)
{
_orderService = orderService;
}
[RelayCommand]
private async Task LoadOrdersAsync()
{
IsLoading = true;
ErrorMessage = "";
try
{
var result = await _orderService.GetOrdersAsync();
Orders.Clear();
foreach (var order in result) Orders.Add(order);
}
catch (Exception ex)
{
ErrorMessage = "Failed to load orders. Please check your connection.";
}
finally
{
IsLoading = false;
}
}
}The [ObservableProperty] and [RelayCommand] attributes from CommunityToolkit.Mvvm generate boilerplate (INotifyPropertyChanged, command implementations).
Pages (UI)
<!-- Pages/OrdersPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage x:Class="OrderFlow.Mobile.Pages.OrdersPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:OrderFlow.Mobile.ViewModels"
Title="Orders">
<ContentPage.BindingContext>
<vm:OrdersViewModel />
</ContentPage.BindingContext>
<Grid>
<!-- Loading overlay -->
<ActivityIndicator IsRunning="{Binding IsLoading}"
IsVisible="{Binding IsLoading}"
HorizontalOptions="Center"
VerticalOptions="Center" />
<!-- Error message -->
<Label Text="{Binding ErrorMessage}"
IsVisible="{Binding ErrorMessage, Converter={StaticResource NotEmptyConverter}}"
TextColor="Red"
HorizontalOptions="Center" />
<!-- Orders list -->
<CollectionView ItemsSource="{Binding Orders}"
IsVisible="{Binding IsLoading, Converter={StaticResource InvertBoolConverter}}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="16,8" ColumnDefinitions="*,Auto">
<VerticalStackLayout>
<Label Text="{Binding Id}" FontAttributes="Bold" />
<Label Text="{Binding CustomerName}" TextColor="Gray" />
</VerticalStackLayout>
<Label Grid.Column="1"
Text="{Binding Total, StringFormat='{0:C}'}"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>Shell Navigation
Shell provides a navigation hierarchy, flyout menu, and tab bar with minimal code:
<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:pages="clr-namespace:OrderFlow.Mobile.Pages">
<TabBar>
<ShellContent Title="Orders" Icon="orders.svg" ContentTemplate="{DataTemplate pages:OrdersPage}" />
<ShellContent Title="Products" Icon="products.svg" ContentTemplate="{DataTemplate pages:ProductsPage}" />
<ShellContent Title="Profile" Icon="profile.svg" ContentTemplate="{DataTemplate pages:ProfilePage}" />
</TabBar>
</Shell>// Navigate to a page with parameters
await Shell.Current.GoToAsync($"//orders/detail?id={orderId}");
// With typed parameters (query property)
[QueryProperty(nameof(OrderId), "id")]
public partial class OrderDetailPage : ContentPage
{
public string OrderId { get; set; } = "";
}HTTP Service with Authentication
// Services/OrderService.cs
public class OrderService : IOrderService
{
private readonly HttpClient _http;
private readonly ISecureStorageService _storage;
public OrderService(HttpClient http, ISecureStorageService storage)
{
_http = http;
_storage = storage;
}
public async Task<List<OrderDto>> GetOrdersAsync(CancellationToken ct = default)
{
var token = await _storage.GetTokenAsync();
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await _http.GetAsync("api/orders", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<OrderDto>>(ct) ?? new();
}
}
// MauiProgram.cs
builder.Services.AddHttpClient<IOrderService, OrderService>(client =>
{
client.BaseAddress = new Uri("https://api.orderflow.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});Local Storage
// Secure storage — for tokens and sensitive data
await SecureStorage.Default.SetAsync("auth_token", token);
var token = await SecureStorage.Default.GetAsync("auth_token");
SecureStorage.Default.Remove("auth_token");
// Preferences — for user settings
Preferences.Default.Set("theme", "dark");
Preferences.Default.Set("pageSize", 20);
var theme = Preferences.Default.Get("theme", "light");
// File system — for larger data
var path = Path.Combine(FileSystem.AppDataDirectory, "cache", "orders.json");
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(orders));Platform-Specific Code
// Use preprocessor directives or partial classes
public partial class LocationService
{
public partial Task<Location?> GetCurrentLocationAsync();
}
// Platforms/iOS/LocationService.cs
public partial class LocationService
{
public partial async Task<Location?> GetCurrentLocationAsync()
{
var request = new GeolocationRequest(GeolocationAccuracy.High, TimeSpan.FromSeconds(10));
return await Geolocation.Default.GetLocationAsync(request);
}
}
// Or conditional compilation
#if ANDROID
// Android-specific code
#elif IOS
// iOS-specific code
#endifDI Setup (MauiProgram.cs)
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Register services
builder.Services.AddSingleton<IOrderService, OrderService>();
builder.Services.AddSingleton<ISecureStorageService, SecureStorageService>();
// Register view models as transient (new instance per page)
builder.Services.AddTransient<OrdersViewModel>();
builder.Services.AddTransient<OrderDetailViewModel>();
// Register pages
builder.Services.AddTransient<OrdersPage>();
builder.Services.AddTransient<OrderDetailPage>();
return builder.Build();
}
}Interview Questions
Q: What is the difference between .NET MAUI and Xamarin.Forms? MAUI is the successor to Xamarin.Forms. It targets .NET 6+ (single project, unified API), adds desktop support (Windows, macOS), improves performance with .NET 8/9, and has better tooling. Xamarin.Forms is end-of-life. New cross-platform apps should use MAUI.
Q: What is MVVM and why is it the standard pattern for MAUI? Model-View-ViewModel separates UI (View/XAML) from logic (ViewModel) and data (Model). The ViewModel exposes properties and commands; the View binds to them via data binding. This makes ViewModels testable without a UI, and keeps XAML files focused on layout. CommunityToolkit.Mvvm reduces boilerplate with source generators.
Q: How do you share code between a .NET MAUI app and an ASP.NET Core backend?
Create a shared library project (.NET Standard 2.1 or net9.0) containing DTOs, validation logic (FluentValidation), domain models, and interfaces. Reference this library from both MAUI and the API. This gives you compile-time type safety across client and server with no duplication.
Q: What is Shell navigation in MAUI?
A navigation system that provides flyout menus, tab bars, and a route-based navigation stack. You define the navigation hierarchy in AppShell.xaml. Navigate by URI: Shell.Current.GoToAsync("//orders/detail?id=123"). Routes can be registered for detail pages that aren't in the main hierarchy.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.