Quick start

You need a .NET 10 MAUI application and the workload for your target platform. The repository pins the SDK and workload set in global.json.

The complete example below, running on an iPhone simulator during a drag:

The complete quick-start sample on iPhone: Page 1 of 3 curling from the right edge, with the shaded backside and Previous/Next buttons visible.

Install the library

Install from your MAUI application's directory:

dotnet add package PageCurl.Maui --version 0.1.2

The package includes PageCurl.Core transitively. The 0.x API may change before 1.0.

To develop against the source, use a project reference to your local checkout instead:

dotnet add ReaderApp.csproj reference ../PageCurl/src/PageCurl.Maui/PageCurl.Maui.csproj

Replace the project names and relative path with yours.

Register SkiaSharp

Add the handler registration in MauiProgram.cs:

using SkiaSharp.Views.Maui.Controls.Hosting;

var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>().UseSkiaSharp();

Display a live page

Add the namespace, control and navigation buttons to MainPage.xaml. This example uses an application namespace of ReaderApp:

<ContentPage
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:curl="clr-namespace:PageCurl.Maui;assembly=PageCurl.Maui"
    x:Class="ReaderApp.MainPage">
    <Grid RowDefinitions="*,Auto,Auto" Padding="16" RowSpacing="12">
        <curl:PageCurlView x:Name="Reader" PageAspectRatio="0.75" />
        <HorizontalStackLayout Grid.Row="1" Spacing="12"
                               HorizontalOptions="Center">
            <Button x:Name="PreviousButton" Text="Previous" />
            <Button x:Name="NextButton" Text="Next" />
        </HorizontalStackLayout>
        <VerticalStackLayout x:Name="CaptureError" Grid.Row="2"
                             IsVisible="False" Spacing="6">
            <Label Text="Page capture failed." HorizontalTextAlignment="Center" />
            <Button x:Name="RetryButton" Text="Retry capture" />
        </VerticalStackLayout>
    </Grid>
</ContentPage>

The view stays interactive while flat. The control captures it after layout and uses that image during the turn. Give the control a nonzero layout size.

Connect navigation

Use PageNavigationState for the page index and first/last boundaries. In MainPage.xaml.cs, update the content only after a committed completion:

using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using PageCurl.Core;

namespace ReaderApp;

public partial class MainPage : ContentPage
{
    private readonly PageNavigationState _navigation = new(pageCount: 3);
    private Window? _observedWindow;
    private bool _active;

    public MainPage()
    {
        InitializeComponent();
        Reader.PageTurnCompleted += (_, e) =>
        {
            if (_active && e.Committed && _navigation.TryCommit(e.Direction)) ShowPage();
        };
        PreviousButton.Clicked += (_, _) =>
        {
            if (_active && _navigation.CanTurnBackward) Reader.TryTurn(PageTurnDirection.Backward);
        };
        NextButton.Clicked += (_, _) =>
        {
            if (_active && _navigation.CanTurnForward) Reader.TryTurn(PageTurnDirection.Forward);
        };
        Reader.SnapshotFailed += (_, _) =>
        {
            if (_active) CaptureError.IsVisible = true;
        };
        RetryButton.Clicked += OnRetryClicked;
        ShowPage();
    }

    private void ShowPage()
    {
        Reader.FrontContent = new Grid
        {
            BackgroundColor = Color.FromArgb("#FAF8F1"),
            Padding = new Thickness(24),
            Children =
            {
                new Label
                {
                    Text = $"Page {_navigation.PageIndex + 1} of {_navigation.PageCount}",
                    TextColor = Color.FromArgb("#1C211F"),
                },
            },
        };
        CaptureError.IsVisible = false;
        UpdateButtons();
    }

    private void UpdateButtons()
    {
        PreviousButton.IsEnabled = _active && _navigation.CanTurnBackward;
        NextButton.IsEnabled = _active && _navigation.CanTurnForward;
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();
        ObserveWindow(Window);
        SetActive(true);
    }

    protected override void OnDisappearing()
    {
        ObserveWindow(null);
        SetActive(false);
        base.OnDisappearing();
    }

    private void ObserveWindow(Window? window)
    {
        if (_observedWindow is not null)
        {
            _observedWindow.Stopped -= OnWindowStopped;
            _observedWindow.Resumed -= OnWindowResumed;
        }
        _observedWindow = window;
        if (window is not null)
        {
            window.Stopped += OnWindowStopped;
            window.Resumed += OnWindowResumed;
        }
    }

    private void OnWindowStopped(object? sender, EventArgs e) => SetActive(false);
    private void OnWindowResumed(object? sender, EventArgs e) => SetActive(true);

    private void SetActive(bool active)
    {
        _active = active;
        if (!active) Reader.CancelInteraction();
        Reader.IsVisible = active;
        if (active) CaptureError.IsVisible = false;
        UpdateButtons();
    }

    private async void OnRetryClicked(object? sender, EventArgs e)
    {
        if (!_active || !RetryButton.IsEnabled) return;
        RetryButton.IsEnabled = false;
        CaptureError.IsVisible = false;
        try { await Reader.RefreshSnapshotAsync(); }
        catch (OperationCanceledException) { }
        finally { RetryButton.IsEnabled = true; }
    }
}

Gestures and buttons reach the same completion handler. TryCommit keeps the model in range, including a gesture at either endpoint. TryTurn returns false when the control cannot start a command; leave the model unchanged. Buttons reflect page boundaries; a current snapshot must also be available before a turn starts. Automatic capture follows layout. SnapshotFailed shows the retry control, and retry awaits capture without changing navigation.

The active flag prevents completion callbacks from changing an inactive page. Stopping or leaving the page cancels the turn before hiding the control; showing it again requests a fresh capture. Leaving also detaches window events.

This example replaces the live page after completion. To reveal destination images beneath it during a turn, supply UnderPageImage for forward turns and PreviousPageImage for backward turns. You own those SKImage values: keep them alive while assigned, then cancel interaction and clear the image properties before disposal. Use two-page spreads when the control should capture live neighboring pages.

Read images and live content for backside and in-place refresh behavior, and runtime rules for UI-thread and lifecycle details.