在 Xamarin.Forms 中创建自定义布局

Xamarin.Forms 定义 StackLayout、AbsoluteLayout、RelativeLayout、Grid 和 FlexLayout 的五个布局类,每个布局类都以不同的方式排列其子级。 但是,有时必须使用并非由 Xamarin.Forms 提供的布局来组织页面内容。 本文介绍如何编写自定义布局类,并演示一个区分方向的 WrapLayout 类,该类跨页面水平排列其子级,然后将后续子级的显示包装到其他行。

在 Xamarin.Forms 中,所有布局类都派生自 Layout<T> 类,并将泛型类型限制为 View 及其派生类型。 反过来,Layout<T> 类派生自 Layout 类,该类提供定位和调整子元素大小的机制。

每个视觉元素都负责确定其自己的首选大小,这称为请求的大小。 PageLayoutLayout<View> 派生类型负责确定其子级或子级相对于自身的位置和大小。 因此,布局涉及父子关系,其中父级确定其子级的大小,但会尝试容纳子级的请求大小。

需要全面了解 Xamarin.Forms 布局和失效周期才能创建自定义布局。 现在将讨论这些周期。

Layout

布局以页面开头的可视化树顶部,它继续浏览可视化树的所有分支,以包含页面上的每个视觉元素。 其他元素的父元素负责调整其子级,并定位其子元素相对于自身。

VisualElement 类定义一个 Measure 方法,该方法度量布局操作的元素,以及一个 Layout 方法,用于指定元素将在其中呈现的矩形区域。 当应用程序启动并显示第一页时,布局周期首先由 Measure 调用组成,然后 Layout 调用,在 Page 对象上启动:

  1. 在布局周期中,每个父元素都负责对其子元素调用 Measure 方法。
  2. 测量子级后,每个父元素都负责对其子元素调用 Layout 方法。

此周期可确保页面上的每个视觉元素都接收对 MeasureLayout 方法的调用。 下图显示了该过程:

Xamarin.Forms 布局周期

注意

请注意,如果某些更改会影响布局,布局周期也可能发生在可视化树的子集上。 这包括从集合中添加或删除的项,例如在 StackLayout、元素的 IsVisible 属性更改或元素大小的变化。

具有 ContentChildren 属性的每个 Xamarin.Forms 类都有可重写的 LayoutChildren 方法。 派生自 Layout<View> 的自定义布局类必须替代此方法,并确保对所有元素的子元素调用 MeasureLayout 方法,以提供所需的自定义布局。

此外,派生自 LayoutLayout<View> 的每个类都必须替代 OnMeasure 方法,即布局类通过调用其子级的 Measure 方法来确定它需要的大小。

注意

元素根据约束确定其大小,该约束指示元素父元素的可用空间量。 传递给 MeasureOnMeasure 方法的约束的范围可以是 0 到 Double.PositiveInfinity。 当一个元素接收到对其 Measure 方法的调用,且具有非无限参数时,它就是受限的,或者说是完全受限的 - 该元素被限制在一个特定的大小范围内。 当一个元素接收到对其 Measure 方法的调用,且至少有一个参数等于 Double.PositiveInfinity 时,该元素就是无约束的,或者是部分受约束的 - 无限约束可以视作表示自动调整。

失效

无效是页面上元素发生更改触发新布局周期的过程。 当元素不再具有正确的大小或位置时,这些元素被视为无效。 例如,如果 ButtonFontSize 属性发生更改,则表示 Button 无效,因为它将不再具有正确的大小。 然后,调整 Button 的大小可能会对页面其余部分的布局更改产生连锁反应。

元素通过调用 InvalidateMeasure 方法自行失效,通常当元素的属性发生更改时,可能会导致元素的新大小。 此方法触发 MeasureInvalidated 事件,元素的父句柄触发新的布局周期。

Layout 类为添加到其 Content 属性或 Children 集合的每个子级设置 MeasureInvalidated 事件的处理程序,并在删除子级时分离处理程序。 因此,每当其中一个子元素更改大小时,都会提醒具有子元素的可视化树中的每个元素。 下图演示了可视化树中元素大小的更改如何导致树出现连锁变化:

可视化树中的无效

但是,Layout 类会尝试限制子级大小更改对页面布局的影响。 如果布局受限制,则子大小更改不会影响比可视化树中的父布局更高的任何内容。 但是,布局大小的更改通常会影响布局排列其子级的方式。 因此,布局大小的任何更改都将启动布局的布局周期,布局将接收对其 OnMeasureLayoutChildren 方法的调用。

Layout 类还定义了一个与 InvalidateMeasure 方法类似的 InvalidateLayout 方法。 每当发生影响布局位置及其子级大小的更改时,都应调用 InvalidateLayout 方法。 例如,每当向布局添加或删除子级时,Layout 类都会调用 InvalidateLayout 方法。

可以重写 InvalidateLayout 来实现缓存,以最大程度地减少对布局子级的 Measure 方法的重复调用。 重写 InvalidateLayout 方法将提供在布局中添加或删除子级时通知。 同样,当其中一个布局的子级更改大小时,可以重写 OnChildMeasureInvalidated 方法以提供通知。 对于这两种方法替代,自定义布局应通过清除缓存做出响应。 有关详细信息,请参阅计算和缓存布局数据

创建自定义布局

创建自定义布局的过程如下所示:

  1. 创建一个从 Layout<View> 类派生的类。 有关详细信息,请参阅创建 WrapLayout

  2. [可选] 为应在布局类上设置的任何参数添加受可绑定属性支持的属性。 有关详细信息,请参阅添加受可绑定属性支持的属性

  3. 替代 OnMeasure 方法以在所有布局的子级上调用 Measure 方法,并返回所请求的布局大小。 有关详细信息,请参阅替代 OnMeasure 方法

  4. 替代 LayoutChildren 方法,以在所有布局的子元素上调用 Layout 方法。 未能对布局中的每个子级调用 Layout 方法将导致子级永远不会收到正确的大小或位置,因此子级不会在页面上可见。 有关详细信息,请参阅替代 LayoutChildren 方法

    注意

    枚举 OnMeasureLayoutChildren 替代中的子级时,请跳过 IsVisible 属性设置为 false 的任何子级。 这将确保自定义布局不会为不可见的子级留出空间。

  5. [可选] 替代在向布局中添加或删除子级时要通知的 InvalidateLayout 方法。 有关详细信息,请参阅替代 InvalidateLayout 方法

  6. [可选] 替代 OnChildMeasureInvalidated 方法,以在布局的子级更改大小时通知。 有关详细信息,请参阅替代 OnChildMeasureInvalidated 方法

注意

请注意,如果布局的大小由其父级而不是其子级控制,则不会调用 OnMeasure 替代。 但是,如果一个或两个约束是无限的,或者布局类具有非默认 HorizontalOptionsVerticalOptions 属性值,将调用替代。 因此,LayoutChildren 替代不能依赖于在 OnMeasure 方法调用期间获取的子大小。 相反,在调用 Layout 方法之前,LayoutChildren 必须在布局的子级上调用 Measure 方法。 或者,可以缓存在 OnMeasure 替代中获取的子级的大小,以避免以后在 LayoutChildren 替代中调用 Measure,但布局类需要知道何时需要再次获取大小。 有关详细信息,请参阅计算和缓存布局数据

然后,可以通过将布局类添加到 Page以及将子级添加到布局来使用。 有关详细信息,请参阅使用 WrapLayout

创建 WrapLayout

示例应用程序演示了一个方向敏感的 WrapLayout 类,该类在页面中水平排列其子级,然后将后续子级的显示包装到其他行。

WrapLayout 类根据子级的最大大小为每个子级分配相同的空间量,称为单元格大小。 小于单元格大小的子级可以根据单元格 HorizontalOptionsVerticalOptions 属性值放置在单元格中。

以下代码示例显示了 WrapLayout 类定义:

public class WrapLayout : Layout<View>
{
  Dictionary<Size, LayoutData> layoutDataCache = new Dictionary<Size, LayoutData>();
  ...
}

计算和缓存布局数据

LayoutData 结构在多个属性中存储有关子级集合的数据:

  • VisibleChildCount – 布局中可见的子级数。
  • CellSize – 所有子级的最大大小,调整为布局的大小。
  • Rows – 行数。
  • Columns – 列数。

layoutDataCache 字段用于存储多个 LayoutData 值。 应用程序启动时,会将两个 LayoutData 对象缓存到当前方向–的 layoutDataCache 字典中,该字典用于 OnMeasure 替代的约束参数,一个用于 LayoutChildren 替代 widthheight 参数。 将设备旋转为横向方向时,将再次调用 OnMeasure 替代和 LayoutChildren 替代,这将导致另外两个 LayoutData 对象缓存到字典中。 但是,将设备返回到纵向时,无需进一步计算,因为 layoutDataCache 已具有所需的数据。

下面的代码示例演示 GetLayoutData 方法,该方法根据特定大小计算结构化 LayoutData 的属性:

LayoutData GetLayoutData(double width, double height)
{
  Size size = new Size(width, height);

  // Check if cached information is available.
  if (layoutDataCache.ContainsKey(size))
  {
    return layoutDataCache[size];
  }

  int visibleChildCount = 0;
  Size maxChildSize = new Size();
  int rows = 0;
  int columns = 0;
  LayoutData layoutData = new LayoutData();

  // Enumerate through all the children.
  foreach (View child in Children)
  {
    // Skip invisible children.
    if (!child.IsVisible)
      continue;

    // Count the visible children.
    visibleChildCount++;

    // Get the child's requested size.
    SizeRequest childSizeRequest = child.Measure(Double.PositiveInfinity, Double.PositiveInfinity);

    // Accumulate the maximum child size.
    maxChildSize.Width = Math.Max(maxChildSize.Width, childSizeRequest.Request.Width);
    maxChildSize.Height = Math.Max(maxChildSize.Height, childSizeRequest.Request.Height);
  }

  if (visibleChildCount != 0)
  {
    // Calculate the number of rows and columns.
    if (Double.IsPositiveInfinity(width))
    {
      columns = visibleChildCount;
      rows = 1;
    }
    else
    {
      columns = (int)((width + ColumnSpacing) / (maxChildSize.Width + ColumnSpacing));
      columns = Math.Max(1, columns);
      rows = (visibleChildCount + columns - 1) / columns;
    }

    // Now maximize the cell size based on the layout size.
    Size cellSize = new Size();

    if (Double.IsPositiveInfinity(width))
      cellSize.Width = maxChildSize.Width;
    else
      cellSize.Width = (width - ColumnSpacing * (columns - 1)) / columns;

    if (Double.IsPositiveInfinity(height))
      cellSize.Height = maxChildSize.Height;
    else
      cellSize.Height = (height - RowSpacing * (rows - 1)) / rows;

    layoutData = new LayoutData(visibleChildCount, cellSize, rows, columns);
  }

  layoutDataCache.Add(size, layoutData);
  return layoutData;
}

GetLayoutData 方法执行以下操作:

  • 它确定计算 LayoutData 值是否已在缓存中,并在可用时返回该值。
  • 否则,它会枚举所有子级,在具有无限宽度和高度的每个子级上调用 Measure 方法,并确定最大子大小。
  • 如果至少有一个可见子级,它将计算所需的行数和列数,然后根据 WrapLayout 的维度计算子级的单元格大小。 请注意,单元格大小通常略宽于最大子大小,但如果 WrapLayout 不够宽,则单元格大小通常比最大子大小略宽,或者对于最高的孩子来说也可能更小。
  • 它将新的 LayoutData 值存储在缓存中。

添加可绑定属性支持的属性

WrapLayout 类定义 ColumnSpacingRowSpacing 属性,这些属性的值用于分隔布局中的行和列,并由可绑定属性提供支持。 可绑定属性显示在以下代码示例中:

public static readonly BindableProperty ColumnSpacingProperty = BindableProperty.Create(
  "ColumnSpacing",
  typeof(double),
  typeof(WrapLayout),
  5.0,
  propertyChanged: (bindable, oldvalue, newvalue) =>
  {
    ((WrapLayout)bindable).InvalidateLayout();
  });

public static readonly BindableProperty RowSpacingProperty = BindableProperty.Create(
  "RowSpacing",
  typeof(double),
  typeof(WrapLayout),
  5.0,
  propertyChanged: (bindable, oldvalue, newvalue) =>
  {
    ((WrapLayout)bindable).InvalidateLayout();
  });

每个可绑定属性的属性更改处理程序调用 InvalidateLayout 方法替代,以触发 WrapLayout 上的新布局传递。 有关详细信息,请参阅替代 InvalidateLayout 方法替代 OnChildMeasureInvalidated 方法

替代 OnMeasure 方法

以下代码示例显示了 OnMeasure 替代:

protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
{
  LayoutData layoutData = GetLayoutData(widthConstraint, heightConstraint);
  if (layoutData.VisibleChildCount == 0)
  {
    return new SizeRequest();
  }

  Size totalSize = new Size(layoutData.CellSize.Width * layoutData.Columns + ColumnSpacing * (layoutData.Columns - 1),
                layoutData.CellSize.Height * layoutData.Rows + RowSpacing * (layoutData.Rows - 1));
  return new SizeRequest(totalSize);
}

替代调用 GetLayoutData 方法,并从返回的数据构造 SizeRequest 对象,同时考虑 RowSpacingColumnSpacing 属性值。 有关 GetLayoutData 方法的详细信息,请参阅计算和缓存布局数据

重要

MeasureOnMeasure 方法不应通过返回属性设置为 Double.PositiveInfinitySizeRequest值来请求无限维度。 但是,OnMeasure 的至少一个约束参数可以 Double.PositiveInfinity

替代 LayoutChildren 方法

以下代码示例显示了 LayoutChildren 替代:

protected override void LayoutChildren(double x, double y, double width, double height)
{
  LayoutData layoutData = GetLayoutData(width, height);

  if (layoutData.VisibleChildCount == 0)
  {
    return;
  }

  double xChild = x;
  double yChild = y;
  int row = 0;
  int column = 0;

  foreach (View child in Children)
  {
    if (!child.IsVisible)
    {
      continue;
    }

    LayoutChildIntoBoundingRegion(child, new Rectangle(new Point(xChild, yChild), layoutData.CellSize));
    if (++column == layoutData.Columns)
    {
      column = 0;
      row++;
      xChild = x;
      yChild += RowSpacing + layoutData.CellSize.Height;
    }
    else
    {
      xChild += ColumnSpacing + layoutData.CellSize.Width;
    }
  }
}

替代从调用 GetLayoutData 方法开始,然后枚举所有子级的大小并将其放置在每个子单元中。 这是通过调用 LayoutChildIntoBoundingRegion 方法来实现的,该方法用于基于其 HorizontalOptionsVerticalOptions 属性值在矩形中定位子级。 这相当于调用子项的 Layout 方法。

注意

请注意,传递给 LayoutChildIntoBoundingRegion 方法的矩形包括子级可以驻留的整个区域。

有关 GetLayoutData 方法的详细信息,请参阅计算和缓存布局数据

替代 InvalidateLayout 方法

当向布局中添加或删除子级,或者其中一个 WrapLayout 属性更改值时,将调用 InvalidateLayout 替代,如以下代码示例所示:

protected override void InvalidateLayout()
{
  base.InvalidateLayout();
  layoutInfoCache.Clear();
}

替代会使布局失效,并丢弃所有缓存的布局信息。

注意

若要在向布局中添加或删除子级时停止调用 InvalidateLayout 方法的 Layout 类,请替代 ShouldInvalidateOnChildAddedShouldInvalidateOnChildRemoved 方法,并返回 false。 然后,当添加或删除子级时,布局类可以实现自定义过程。

替代 OnChildMeasureInvalidated 方法

当其中一个布局的子级更改大小时,将调用 OnChildMeasureInvalidated 替代,并显示在以下代码示例中:

protected override void OnChildMeasureInvalidated()
{
  base.OnChildMeasureInvalidated();
  layoutInfoCache.Clear();
}

替代会使子布局失效,并放弃所有缓存的布局信息。

使用 WrapLayout

WrapLayout 类可以通过将其放置在 Page 派生类型上来使用,如以下 XAML 代码示例所示:

<ContentPage ... xmlns:local="clr-namespace:ImageWrapLayout">
    <ScrollView Margin="0,20,0,20">
        <local:WrapLayout x:Name="wrapLayout" />
    </ScrollView>
</ContentPage>

等效 C# 代码如下所示:

public class ImageWrapLayoutPageCS : ContentPage
{
  WrapLayout wrapLayout;

  public ImageWrapLayoutPageCS()
  {
    wrapLayout = new WrapLayout();

    Content = new ScrollView
    {
      Margin = new Thickness(0, 20, 0, 20),
      Content = wrapLayout
    };
  }
  ...
}

然后,可以根据需要将子级添加到 WrapLayout。 以下代码示例演示要添加到 WrapLayoutImage 元素:

protected override async void OnAppearing()
{
    base.OnAppearing();

    var images = await GetImageListAsync();
    if (images != null)
    {
        foreach (var photo in images.Photos)
        {
            var image = new Image
            {
                Source = ImageSource.FromUri(new Uri(photo))
            };
            wrapLayout.Children.Add(image);
        }
    }
}

async Task<ImageList> GetImageListAsync()
{
    try
    {
        string requestUri = "https://raw.githubusercontent.com/xamarin/docs-archive/master/Images/stock/small/stock.json";
        string result = await _client.GetStringAsync(requestUri);
        return JsonConvert.DeserializeObject<ImageList>(result);
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"\tERROR: {ex.Message}");
    }

    return null;
}

显示包含 WrapLayout 的页面时,示例应用程序将异步访问包含照片列表的远程 JSON 文件,为每个照片创建一个 Image 元素,并将其添加到 WrapLayout。 这会导致如以下屏幕截图中所示的外观:

示例应用程序纵向屏幕截图

以下屏幕截图显示了旋转到横向后的 WrapLayout

示例 iOS 应用程序横向屏幕截图示例 Android 应用程序横向屏幕截图示例 UWP 应用程序横向屏幕截图

每行中的列数取决于照片大小、屏幕宽度以及每个独立于设备的单位的像素数。 Image 元素异步加载照片,因此 WrapLayout 类会频繁调用其 LayoutChildren 方法,因为每个 Image 元素都根据加载的照片接收新大小。