Custom layouts

Browse sample. Browse the sample

.NET Multi-platform App UI (.NET MAUI) defines multiple layouts classes that each arrange their children in a different way. A layout can be thought of as list of views with rules and properties that define how to arrange those views within the layout. Examples of layouts include Grid, AbsoluteLayout, and VerticalStackLayout.

.NET MAUI layout classes derive from the abstract Layout class. This class delegates cross-platform layout and measurement to a layout manager class. The Layout class also contains an overridable CreateLayoutManager() method that derived layouts can use to specify the layout manager.

Each layout manager class implements the ILayoutManager interface, which specifies that Measure and ArrangeChildren implementations must be provided:

  • The Measure implementation calls IView.Measure on each view in the layout, and returns the total size of the layout given the constraints.
  • The ArrangeChildren implementation determines where each view should be placed within the bounds of the layout, and calls Arrange on each view with its appropriate bounds. The return value is the actual size of the layout.

.NET MAUI's layouts have pre-defined layout managers to handle their layout. However, sometimes it's necessary to organize page content using a layout that isn't provided by .NET MAUI. This can be achieved by producing your own custom layout, which requires you to have an understanding of how .NET MAUI's cross-platform layout process works.

Layout process

.NET MAUI's cross-platform layout process builds on top of the native layout process on each platform. Generally, the layout process is initiated by the native layout system. The cross-platform process runs when a layout or content control initiates it as a result of being measured or arranged by the native layout system.

Note

Each platform handles layout slightly differently. However, .NET MAUI's cross-platform layout process aims to be as platform-agnostic as possible.

The following diagram shows the process when a native layout system initiates layout measurement:

The process for layout measurement in .NET MAUI

All .NET MAUI layouts have a single backing view on each platform:

  • On Android, this backing view is LayoutViewGroup.
  • On iOS and Mac Catalyst, this backing view is LayoutView.
  • On Windows, this backing view is LayoutPanel.

When the native layout system for a platform requests the measurement of one of these backing views, the backing view calls the Layout.CrossPlatformMeasure method. This is the point at which control is passed from the native layout system to .NET MAUI's layout system. Layout.CrossPlatformMeasure calls the layout managers' Measure method. This method is responsible for measuring child views by calling IView.Measure on each view in the layout. The view measures its native control, and updates its DesiredSize property based on that measurement. This value is returned to the backing view as the result of the CrossPlatformMeasure method. The backing view performs whatever internal processing it needs to do, and returns its measured size to the platform.

The following diagram shows the process when a native layout system initiates layout arrangement:

The process for layout arrangement in .NET MAUI

When the native layout system for a platform requests the arrangement, or layout, of one of these backing views, the backing view calls the Layout.CrossPlatformArrange method. This is the point at which control is passed from the native layout system to .NET MAUI's layout system. Layout.CrossPlatformArrange calls the layout managers' ArrangeChildren method. This method is responsible for determining where each view should be placed within the bounds of the layout, and calls Arrange on each view to set its location. The size of the layout is returned to the backing view as the result of the CrossPlatformArrange method. The backing view performs whatever internal processing it needs to do, and returns the actual size to the platform.

Note

ILayoutManager.Measure may be called multiple times before ArrangeChildren is called, because a platform may need to perform some speculative measurements before arranging views.

Custom layout approaches

There are two main approaches to creating a custom layout:

  1. Create a custom layout type, which is usually a subclass of an existing layout type or of Layout, and override CreateLayoutManager() in your custom layout type. Then, provide an ILayoutManager implementation that contains your custom layout logic. For more information, see Create a custom layout type.
  2. Modify the behavior of an existing layout type by creating a type that implements ILayoutManagerFactory. Then, use this layout manager factory to replace .NET MAUI's default layout manager for the existing layout with your own ILayoutManager implementation that contains your custom layout logic. For more information, see Modify the behavior of an existing layout.

Create a custom layout type

The process for creating a custom layout type is to:

  1. Create a class that subclasses an existing layout type or the Layout class, and override CreateLayoutManager() in your custom layout type. For more information, see Subclass a layout.

  2. Create a layout manager class that derives from an existing layout manager, or that implements the ILayoutManager interface directly. In your layout manager class, you should:

    1. Override, or implement, the Measure method to calculate the total size of the layout given its constraints.
    2. Override, or implement, the ArrangeChildren method to size and position all the children within the layout.

    For more information, see Create a layout manager.

  3. Consume your custom layout type by adding it to a Page, and by adding children to the layout. For more information, see Consume the layout type.

An orientation-sensitive HorizontalWrapLayout is used to demonstrate this process. HorizontalWrapLayout is similar to a HorizontalStackLayout in that it arranges its children horizontally across the page. However, it wraps the display of children to a new row when it encounters the right edge of its container

Note

The sample defines additional custom layouts that can be used to understand how to produce a custom layout.

Subclass a layout

To create a custom layout type you must first subclass an existing layout type, or the Layout class. Then, override CreateLayoutManager() in your layout type and return a new instance of the layout manager for your layout type:

using Microsoft.Maui.Layouts;

public class HorizontalWrapLayout : HorizontalStackLayout
{
    protected override ILayoutManager CreateLayoutManager()
    {
        return new HorizontalWrapLayoutManager(this);
    }
}

HorizontalWrapLayout derives from HorizontalStackLayout to use its layout functionality. .NET MAUI layouts delegate cross-platform layout and measurement to a layout manager class. Therefore, the CreateLayoutManager() override returns a new instance of the HorizontalWrapLayoutManager class, which is the layout manager that's discussed in the next section.

Create a layout manager

A layout manager class is used to perform cross-platform layout and measurement for your custom layout type. It should derive from an existing layout manager, or it should directly implement the ILayoutManager interface. HorizontalWrapLayoutManager derives from HorizontalStackLayoutManager so that it can use its underlying functionality and access members in its inheritance hierarchy:

using Microsoft.Maui.Layouts;
using HorizontalStackLayoutManager = Microsoft.Maui.Layouts.HorizontalStackLayoutManager;

public class HorizontalWrapLayoutManager : HorizontalStackLayoutManager
{
    HorizontalWrapLayout _layout;

    public HorizontalWrapLayoutManager(HorizontalWrapLayout horizontalWrapLayout) : base(horizontalWrapLayout)
    {
        _layout = horizontalWrapLayout;
    }

    public override Size Measure(double widthConstraint, double heightConstraint)
    {
    }

    public override Size ArrangeChildren(Rect bounds)
    {
    }
}

The HorizontalWrapLayoutManager constructor stores an instance of the HorizontalWrapLayout type in a field, so that it can be accessed throughout the layout manager. The layout manager also overrides the Measure and ArrangeChildren methods from the HorizontalStackLayoutManager class. These methods are where you'll define the logic to implement your custom layout.

Measure the layout size

The purpose of the ILayoutManager.Measure implementation is to calculate the total size of the layout. It should do this by calling IView.Measure on each child in the layout. It should then use this data to calculate and return the total size of the layout given its constraints.

The following example shows the Measure implementation for the HorizontalWrapLayoutManager class:

public override Size Measure(double widthConstraint, double heightConstraint)
{
    var padding = _layout.Padding;

    widthConstraint -= padding.HorizontalThickness;

    double currentRowWidth = 0;
    double currentRowHeight = 0;
    double totalWidth = 0;
    double totalHeight = 0;

    for (int n = 0; n < _layout.Count; n++)
    {
        var child = _layout[n];
        if (child.Visibility == Visibility.Collapsed)
        {
            continue;
        }

        var measure = child.Measure(double.PositiveInfinity, heightConstraint);

        // Will adding this IView put us past the edge?
        if (currentRowWidth + measure.Width > widthConstraint)
        {
            // Keep track of the width so far
            totalWidth = Math.Max(totalWidth, currentRowWidth);
            totalHeight += currentRowHeight;

            // Account for spacing
            totalHeight += _layout.Spacing;

            // Start over at 0
            currentRowWidth = 0;
            currentRowHeight = measure.Height;
        }
        currentRowWidth += measure.Width;
        currentRowHeight = Math.Max(currentRowHeight, measure.Height);

        if (n < _layout.Count - 1)
        {
            currentRowWidth += _layout.Spacing;
        }
    }

    // Account for the last row
    totalWidth = Math.Max(totalWidth, currentRowWidth);
    totalHeight += currentRowHeight;

    // Account for padding
    totalWidth += padding.HorizontalThickness;
    totalHeight += padding.VerticalThickness;

    // Ensure that the total size of the layout fits within its constraints
    var finalWidth = ResolveConstraints(widthConstraint, Stack.Width, totalWidth, Stack.MinimumWidth, Stack.MaximumWidth);
    var finalHeight = ResolveConstraints(heightConstraint, Stack.Height, totalHeight, Stack.MinimumHeight, Stack.MaximumHeight);

    return new Size(finalWidth, finalHeight);
}

The Measure method enumerates through all of the visible children in the layout, invoking the IView.Measure method on each child. It then returns the total size of the layout, taking into account the constraints and the values of the Padding and Spacing properties. The ResolveConstraints method is called to ensure that the total size of the layout fits within its constraints.

Important

When enumerating children in the ILayoutManager.Measure implementation, skip any child whose Visibility property is set to Collapsed. This ensures that the custom layout won't leave space for invisible children.

Arrange children in the layout

The purpose of the ArrangeChildren implementation is to size and position all of the children within the layout. To determine where each child should be placed within the bounds of the layout, it should call Arrange on each child with its appropriate bounds. It should then return a value that represents the actual size of the layout.

Warning

Failure to invoke the ArrangeChildren method on each child in the layout will result in the child never receiving a correct size or position, and hence the child won't become visible on the page.

The following example shows the ArrangeChildren implementation for the HorizontalWrapLayoutManager class:

public override Size ArrangeChildren(Rect bounds)
{
    var padding = Stack.Padding;
    double top = padding.Top + bounds.Top;
    double left = padding.Left + bounds.Left;

    double currentRowTop = top;
    double currentX = left;
    double currentRowHeight = 0;

    double maxStackWidth = currentX;

    for (int n = 0; n < _layout.Count; n++)
    {
        var child = _layout[n];
        if (child.Visibility == Visibility.Collapsed)
        {
            continue;
        }

        if (currentX + child.DesiredSize.Width > bounds.Right)
        {
            // Keep track of our maximum width so far
            maxStackWidth = Math.Max(maxStackWidth, currentX);

            // Move down to the next row
            currentX = left;
            currentRowTop += currentRowHeight + _layout.Spacing;
            currentRowHeight = 0;
        }

        var destination = new Rect(currentX, currentRowTop, child.DesiredSize.Width, child.DesiredSize.Height);
        child.Arrange(destination);

        currentX += destination.Width + _layout.Spacing;
        currentRowHeight = Math.Max(currentRowHeight, destination.Height);
    }

    var actual = new Size(maxStackWidth, currentRowTop + currentRowHeight);

    // Adjust the size if the layout is set to fill its container
    return actual.AdjustForFill(bounds, Stack);
}

The ArrangeChildren method enumerates through all the visible children in the layout to size and position them within the layout. It does this by invoking Arrange on each child with appropriate bounds, that take into account the Padding and Spacing of the underlying layout. It then returns the actual size of the layout. The AdjustForFill method is called to ensure that the size takes into account whether the the layout has its HorizontalLayoutAlignment and VerticalLayoutAlignment properties set to LayoutOptions.Fill.

Important

When enumerating children in the ArrangeChildren implementation, skip any child whose Visibility property is set to Collapsed. This ensures that the custom layout won't leave space for invisible children.

Consume the layout type

The HorizontalWrapLayout class can be consumed by placing it in a Page derived type:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:layouts="clr-namespace:CustomLayoutDemos.Layouts"
             x:Class="CustomLayoutDemos.Views.HorizontalWrapLayoutPage"
             Title="Horizontal wrap layout">
    <ScrollView Margin="20">
        <layouts:HorizontalWrapLayout Spacing="20">
            <Image Source="img_0074.jpg"
                   WidthRequest="150" />
            <Image Source="img_0078.jpg"
                   WidthRequest="150" />
            <Image Source="img_0308.jpg"
                   WidthRequest="150" />
            <Image Source="img_0437.jpg"
                   WidthRequest="150" />
            <Image Source="img_0475.jpg"
                   WidthRequest="150" />
            <Image Source="img_0613.jpg"
                   WidthRequest="150" />
            <!-- More images go here -->
        </layouts:HorizontalWrapLayout>
    </ScrollView>
</ContentPage>

Controls can be added to the HorizontalWrapLayout as required. In this example, when the page containing the HorizontalWrapLayout appears, the Image controls are displayed:

Screenshot of the horizontal wrap layout on a Mac with two columns.

The number of columns in each row depends on the image size, the width of the page, and the number of pixels per device-independent unit:

Screenshot of the horizontal wrap layout on a Mac with five columns.

Note

Scrolling is supported by wrapping the HorizontalWrapLayout in a ScrollView.

Modify the behavior of an existing layout

In some scenarios you may want to change the behavior of an existing layout type without having to create a custom layout type. For these scenarios you can create a type that implements ILayoutManagerFactory and use it to replace .NET MAUI's default layout manager for the existing layout with your own ILayoutManager implementation. This enables you to define a new layout manager for an existing layout, such as providing a custom layout manager for Grid. This can be useful for scenarios where you want to add a new behavior to a layout but don't want to update the type of an existing widely-used layout in your app.

The process for modifying the behavior of an existing layout, with a layout manager factory, is to:

  1. Create a layout manager that derives from one of .NET MAUI's layout manager types. For more information, see Create a custom layout manager.
  2. Create a type that implements ILayoutManagerFactory. For more information, see Create a layout manager factory.
  3. Register your layout manager factory with the app's service provider. For more information, see Register the layout manager factory.

Create a custom layout manager

A layout manager is used to perform cross-platform layout and measurement for a layout. To change the behavior of an existing layout you should create a custom layout manager that derives from the layout manager for the layout:

using Microsoft.Maui.Layouts;

public class CustomGridLayoutManager : GridLayoutManager
{
    public CustomGridLayoutManager(IGridLayout layout) : base(layout)
    {
    }

    public override Size Measure(double widthConstraint, double heightConstraint)
    {
        EnsureRows();
        return base.Measure(widthConstraint, heightConstraint);
    }

    void EnsureRows()
    {
        if (Grid is not Grid grid)
        {
            return;
        }

        // Find the maximum row value from the child views
        int maxRow = 0;
        foreach (var child in grid)
        {
            maxRow = Math.Max(grid.GetRow(child), maxRow);
        }

        // Add more rows if we need them
        for (int n = grid.RowDefinitions.Count; n <= maxRow; n++)
        {
            grid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
        }
    }
}

In this example, CustomGridLayoutManager derives from .NET MAUI's GridLayoutManager class, and overrides its Measure method. This custom layout manager ensures that at runtime the RowDefinitions for the Grid includes enough rows to account for each Grid.Row attached property set in a child view. Without this modification, the RowDefinitions for the Grid would need to be specified at design time.

Important

When modifying the behavior of an existing layout manager, don't forget to ensure that you call the base.Measure method from your Measure implementation.

Create a layout manager factory

The custom layout manager should be created in a layout manager factory. This is achieved by creating a type that implements the ILayoutManagerFactory interface:

using Microsoft.Maui.Layouts;

public class CustomLayoutManagerFactory : ILayoutManagerFactory
{
    public ILayoutManager CreateLayoutManager(Layout layout)
    {
        if (layout is Grid)
        {
            return new CustomGridLayoutManager(layout as IGridLayout);
        }
        return null;
    }
}

In this example, a CustomGridLayoutManager instance is returned if the layout is a Grid.

Register the layout manager factory

The layout manager factory should be registered with your app's service provider in your MauiProgram class:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        // Setup a custom layout manager so the default manager for the Grid can be replaced.
        builder.Services.Add(new ServiceDescriptor(typeof(ILayoutManagerFactory), new CustomLayoutManagerFactory()));

        return builder.Build();
    }
}

Then, when the app renders a Grid it will use the custom layout manager to ensure that at runtime the RowDefinitions for the Grid includes enough rows to account for each Grid.Row attached property set in child views.

The following example shows a Grid that sets the Grid.Row attached property in child views, but doesn't set the RowDefinitions property:

<Grid>
    <Label Text="This Grid demonstrates replacing the LayoutManager for an existing layout type." />
    <Label Grid.Row="1"
           Text="In this case, it's a LayoutManager for Grid which automatically adds enough rows to accommodate the rows specified in the child views' attached properties." />
    <Label Grid.Row="2"
           Text="Notice that the Grid doesn't explicitly specify a RowDefinitions collection." />
    <Label Grid.Row="3"
           Text="In MauiProgram.cs, an instance of an ILayoutManagerFactory has been added that replaces the default GridLayoutManager. The custom manager will automatically add the necessary RowDefinitions at runtime." />
    <Label Grid.Row="5"
           Text="We can even skip some rows, and it will add the intervening ones for us (notice the gap between the previous label and this one)." />
</Grid>

The layout manager factory uses the custom layout manager to ensure that the Grid in this example displays correctly, despite the RowDefinitions property not being set:

Screenshot of a Grid customized by using a layout manager factory.