自定义布局

浏览示例。 浏览示例

.NET 多平台应用 UI (.NET MAUI) 定义了多个布局类,每个类以不同的方式排列其子级。 布局可以被视为视图列表,其中包含定义如何在布局中排列这些视图的规则和属性。 布局示例包括 GridAbsoluteLayoutVerticalStackLayout

.NET MAUI 布局类派生自抽象 Layout 类。 此类将跨平台布局和度量委托给布局管理器类。 Layout 类还包含派生布局可用于指定布局管理器的可替代 CreateLayoutManager() 方法。

每个布局管理器类都会实现 ILayoutManager 接口,用于指定必须提供 MeasureArrangeChildren 实现:

  • Measure 实现会对布局中的每个视图调用 IView.Measure,并根据给定约束条件返回布局的总大小。
  • ArrangeChildren 实现会确定每个视图应放置在布局边界内的位置,并对每个视图调用 Arrange,传递其适当的边界。 返回值是布局的实际大小。

.NET MAUI 的布局具有预定义的布局管理器来处理其布局。 但是,有时必须使用非 .NET MAUI 提供的布局来组织页面内容。 这可以通过生成你自己的自定义布局来实现,这要求你了解 .NET MAUI 的跨平台布局过程的工作原理。

布局过程

.NET MAUI 的跨平台布局过程基于每个平台上的本机布局过程构建。 通常,布局过程由本机布局系统启动。 跨平台进程在布局或内容控件由于被本机布局系统度量或排列而启动它时运行。

注意

每个平台处理布局的方式略有不同。 但是,.NET MAUI 的跨平台布局过程旨在尽可能与平台无关。

下图显示了本机布局系统启动布局度量时的过程:

.NET MAUI 中的布局度量过程

所有 .NET MAUI 布局在每个平台上都有一个后备视图:

  • 在 Android 上,此后备视图为 LayoutViewGroup
  • 在 iOS 和 Mac Catalyst 上,此后备视图为 LayoutView
  • 在 Windows 上,此后备视图为 LayoutPanel

当平台的本机布局系统请求度量其中一个后备视图时,后备视图将调用 Layout.CrossPlatformMeasure 方法。 这是控件从本机布局系统传递到 .NET MAUI 布局系统的时间点。 Layout.CrossPlatformMeasure 会调用布局管理器的 Measure 方法。 此方法负责通过对布局中的每个视图调用 IView.Measure 来度量子视图。 该视图会度量其本机控件,并根据该度量情况更新其 DesiredSize 属性。 此值会作为 CrossPlatformMeasure 方法的结果返回到后备视图。 后备视图会执行它需要执行的任何内部处理,并将其度量大小返回到平台。

下图显示了本机布局系统启动布局排列的过程:

.NET MAUI 中的布局排列过程

当平台的本机布局系统请求其中一个后备视图的排列或布局时,后备视图将调用 Layout.CrossPlatformArrange 方法。 这是控件从本机布局系统传递到 .NET MAUI 布局系统的时间点。 Layout.CrossPlatformArrange 会调用布局管理器的 ArrangeChildren 方法。 此方法负责确定应在布局边界内放置每个视图的位置,并对每个视图调用 Arrange 来设置其位置。 布局的大小作为 CrossPlatformArrange 方法的结果返回到后备视图。 后备视图会执行它需要执行的任何内部处理,并将实际大小返回到平台。

注意

可以在 ArrangeChildren 被调用之前多次调用 ILayoutManager.Measure,因为平台可能需要在排列视图之前执行一些推测性度量。

自定义布局方法

创建自定义布局有两种主要方法:

  1. 创建自定义布局类型,它通常是现有布局类型或 Layout 的子类,并在你的自定义布局类型中替代 CreateLayoutManager()。 然后,提供包含自定义布局逻辑的 ILayoutManager 实现。 有关详细信息,请参阅创建自定义布局类型
  2. 通过创建实现 ILayoutManagerFactory 的类型来修改现有布局类型的行为。 然后,使用此布局管理器工厂为现有布局将 .NET MAUI 的默认布局管理器替换为包含你的自定义布局逻辑的你自己的 ILayoutManager 实现。 有关详细信息,请参阅修改现有布局的行为

创建自定义布局类型

创建自定义布局类型的过程是:

  1. 创建一个类,该类对现有布局类型或 Layout 类进行子类化,并在你的自定义布局类型中替代 CreateLayoutManager()。 有关详细信息,请参阅子类化布局

  2. 创建派生自现有布局管理器或直接实现 ILayoutManager 接口的布局管理器类。 在布局管理器类中,你应:

    1. 根据给定约束条件替代或实现 Measure 方法以计算布局的总大小。
    2. 替代或实现 ArrangeChildren 方法以调整布局中所有子级的大小和位置。

    有关详细信息,请参阅创建布局管理器

  3. 通过将自定义布局类型添加到 Page,以及将子级添加到布局来使用你的自定义布局类型。 有关详细信息,请参阅使用布局类型

方向敏感的 HorizontalWrapLayout 用于演示此过程。 HorizontalWrapLayout 类似于 HorizontalStackLayout,因为它会在页面中水平排列其子级。 但是,当它遇到其容器的右边缘时,它会将子级的显示包装到新行

注意

示例定义了可用于了解如何生成自定义布局的额外自定义布局。

子类化布局

若要创建自定义布局类型,必须先将现有布局类型或 Layout 类子类化。 然后,在布局类型中替代 CreateLayoutManager(),并为布局类型返回布局管理器的新实例:

using Microsoft.Maui.Layouts;

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

HorizontalWrapLayout 派生自 HorizontalStackLayout 以使用其布局功能。 .NET MAUI 布局将跨平台布局和度量委托给布局管理器类。 因此,CreateLayoutManager() 重写会返回 HorizontalWrapLayoutManager 类的新实例,这是下一部分中讨论的布局管理器。

创建布局管理器

布局管理器类用于为你的自定义布局类型执行跨平台布局和度量。 它应派生自现有布局管理器,或者应直接实现 ILayoutManager 接口。 HorizontalWrapLayoutManager 派生自 HorizontalStackLayoutManager,因此它可以使用其基础功能并访问其继承层次结构中的成员:

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)
    {
    }
}

HorizontalWrapLayoutManager 构造函数将 HorizontalWrapLayout 类型的实例存储在字段中,以便能够在整个布局管理器中访问它。 布局管理器还会替代 HorizontalStackLayoutManager 类中的 MeasureArrangeChildren 方法。 这些方法用于定义实现你的自定义布局的逻辑。

度量布局大小

ILayoutManager.Measure 实现的目的是计算布局的总大小。 它应通过对布局中的每个子级调用 IView.Measure 来执行此操作。 然后,它应根据约束条件使用此数据来计算并返回布局的总大小。

以下示例演示了 HorizontalWrapLayoutManager 类的 Measure 实现:

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);
}

Measure(Double, Double) 方法会枚举布局中的所有可见子级,并对每个子级调用 IView.Measure 方法。 然后,它会返回布局的总大小,并考虑约束以及 PaddingSpacing 属性的值。 ResolveConstraints 方法被调用以确保布局的总大小符合其约束。

重要

枚举 ILayoutManager.Measure 实现中的子级时,请跳过 Visibility 属性设置为 Collapsed 的任何子级。 这可确保自定义布局不会为不可见的子级留出空间。

在布局中排列子级

ArrangeChildren 实现的目的是调整布局中所有子级的大小和位置。 若要确定每个子级应放置在布局边界内的位置,它应对每个子级调用 Arrange,传递其适当的边界。 然后,它应返回一个值,该值表示布局的实际大小。

警告

未能对布局中的每个子级调用 ArrangeChildren 方法将导致子级永远不会收到正确的大小或位置,进而导致子级不会在页面上可见。

以下示例演示了 HorizontalWrapLayoutManager 类的 ArrangeChildren 实现:

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);
}

ArrangeChildren 方法会枚举布局中的所有可见子级,以调整它们的大小并将它们放置在布局中。 它通过对每个子级调用 Arrange 以传递其适当的边界(这会考虑到基础布局的 PaddingSpacing)来执行此操作。 然后它会返回布局的实际大小。 AdjustForFill 方法被调用以确保大小考虑到了布局是否将其 HorizontalLayoutAlignmentVerticalLayoutAlignment 属性设为 LayoutOptions.Fill

重要

枚举 ArrangeChildren 实现中的子级时,请跳过 Visibility 属性设置为 Collapsed 的任何子级。 这可确保自定义布局不会为不可见的子级留出空间。

使用布局类型

通过将 HorizontalWrapLayout 类置于 Page 派生类型中来使用该类:

<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>

控件可以根据需要添加到 HorizontalWrapLayout。 在此示例中,当显示包含 HorizontalWrapLayout 的页时,将显示 Image 控件:

Mac 上水平换行布局的屏幕截图,其中包含两列。

每行中的列数取决于图像大小、页面宽度以及每个独立于设备的单位的像素数:

Mac 上具有五列的水平换行布局的屏幕截图。

注意

通过将 HorizontalWrapLayout 包装在 ScrollView 中来支持滚动。

修改现有布局的行为

在某些情况下,你可能需要更改现有布局类型的行为,而不创建自定义布局类型。 对于这些方案,你可以创建一个类型,让它实现 ILayoutManagerFactory 并使用它为现有的布局将 .NET MAUI 的默认布局管理器替换为你自己的 ILayoutManager 实现。 这使你可以为现有布局定义新的布局管理器,例如为 Grid 提供自定义布局管理器。 对于想要将新行为添加到布局但不想更新应用中现有且广泛使用的布局的类型的方案,这会很有用。

使用布局管理器工厂修改现有布局的行为的过程是为了:

  1. 创建派生自 .NET MAUI 布局管理器类型之一的布局管理器。 有关详细信息,请参阅创建自定义布局管理器
  2. 创建实现 ILayoutManagerFactory 的类型。 有关详细信息,请参阅创建布局管理器工厂
  3. 将布局管理器工厂注册到应用的服务提供商。 有关详细信息,请参阅注册布局管理器工厂

创建自定义布局管理器

布局管理器用于为布局执行跨平台布局和度量。 若要更改现有布局的行为,应创建一个派生自该布局的布局管理器的自定义布局管理器:

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));
        }
    }
}

在此示例中,CustomGridLayoutManager 派生自 .NET MAUI 的 GridLayoutManager 类,替代它的 Measure 方法。 此自定义布局管理器可确保 GridRowDefinitions 在运行时包含足够的行来考虑子视图中设置的每个 Grid.Row 附加属性。 如果不进行此修改,则需要在设计时指定 GridRowDefinitions

重要

修改现有布局管理器的行为时,不要忘记确保从 Measure 实现中调用 base.Measure 方法。

创建布局管理器工厂

应在布局管理器工厂中创建自定义布局管理器。 这是通过创建实现 ILayoutManagerFactory 接口的类型来完成的:

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;
    }
}

在此示例中,如果布局是一个 Grid,则会返回一个 CustomGridLayoutManager 实例。

注册布局管理器工厂

布局管理器工厂应在你的 MauiProgram 类中向应用的服务提供商注册:

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();
    }
}

然后,当应用呈现 Grid 时,它将使用自定义布局管理器来确保 GridRowDefinitions 在运行时包含足够的行来考虑子视图中设置的每个 Grid.Row 附加属性。

以下示例演示了一个在子视图中设置 Grid.Row 附加属性,但不设置 RowDefinitions 属性的 Grid

<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>

尽管未设置 RowDefinitions 属性,但布局管理器工厂使用自定义布局管理器来确保此示例中的 Grid 显示正确:

使用布局管理器工厂自定义的网格的屏幕截图。