在 中创建自定义布局 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 指定元素将在其中呈现的矩形区域的方法。 当应用程序启动并显示第一页时,首先由调用和调用Layout组成的Measure布局周期Page 对象上开始:

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

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

Xamarin.Forms 布局周期

注意

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

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

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

注意

元素根据约束确定其大小,这些 约束指示元素父级内元素的可用空间量。 传递给 MeasureOnMeasure 方法的约束范围为 0 到 Double.PositiveInfinity。 当一个元素收到对其方法的调用Measure时,它使用非无限参数受到约束完全约束 - 该元素被约束为特定大小。 当元素收到至少一个等于 的自变量Double.PositiveInfinity对其方法的调用Measure时,它不受约束部分约束 - 无限约束可视为指示自动调整大小。

失效

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

元素通过调用 InvalidateMeasure 方法使自身失效,通常当元素的属性更改可能导致元素的新大小发生更改时。 此方法触发 MeasureInvalidated 事件,元素的父级处理该事件以触发新的布局周期。

Layout 针对 MeasureInvalidated 添加到其 Content 属性或 Children 集合的每个子级设置事件的处理程序,并在删除子级时分离该处理程序。 因此,每当可视化树中具有子元素的某个子元素更改大小时,都会发出警报。 下图演示了可视化树中元素大小的更改如何导致更改波及树:

可视化树中的失效

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

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

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

创建自定义布局

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

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

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

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

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

    注意

    在 和 重写中OnMeasure枚举子级时,跳过其 IsVisible 属性设置为 false的任何子LayoutChildren级。 这将确保自定义布局不会为不可见的子级留出空间。

  5. [可选]重写 方法, InvalidateLayout 以在布局中添加或删除子级时收到通知。 有关详细信息,请参阅 重写 InvalidateLayout 方法

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

注意

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

然后,可以通过将布局类添加到 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,一个用于width替代的 LayoutChildrenheight 参数。 将设备旋转为横向时, 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 方法不应通过返回SizeRequest属性设置为 Double.PositiveInfinity的值来请求无限维度。 但是,至少有一个约束参数 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 方法实现的,该方法用于根据 HorizontalOptions 矩形和 VerticalOptions 属性值在矩形中定位子级。 这等效于调用子级的 Layout 方法。

注意

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

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

重写 InvalidateLayout 方法

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

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

重写会使布局失效,并放弃所有缓存的布局信息。

注意

若要在 Layout 布局中添加或删除子级时停止调用 InvalidateLayout 方法的类,请重写 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 下面的代码示例演示 Image 要添加到 的 WrapLayout元素:

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 元素都根据加载的照片接收新大小。