在 Xamarin.Forms 中创建自定义布局
Xamarin.Forms 定义 StackLayout、AbsoluteLayout、RelativeLayout、Grid 和 FlexLayout 的五个布局类,每个布局类都以不同的方式排列其子级。 但是,有时必须使用并非由 Xamarin.Forms 提供的布局来组织页面内容。 本文介绍如何编写自定义布局类,并演示一个区分方向的 WrapLayout 类,该类跨页面水平排列其子级,然后将后续子级的显示包装到其他行。
在 Xamarin.Forms 中,所有布局类都派生自 Layout<T>
类,并将泛型类型限制为 View
及其派生类型。 反过来,Layout<T>
类派生自 Layout
类,该类提供定位和调整子元素大小的机制。
每个视觉元素都负责确定其自己的首选大小,这称为请求的大小。 Page
、Layout
和 Layout<View>
派生类型负责确定其子级或子级相对于自身的位置和大小。 因此,布局涉及父子关系,其中父级确定其子级的大小,但会尝试容纳子级的请求大小。
需要全面了解 Xamarin.Forms 布局和失效周期才能创建自定义布局。 现在将讨论这些周期。
Layout
布局以页面开头的可视化树顶部,它继续浏览可视化树的所有分支,以包含页面上的每个视觉元素。 其他元素的父元素负责调整其子级,并定位其子元素相对于自身。
VisualElement
类定义一个 Measure
方法,该方法度量布局操作的元素,以及一个 Layout
方法,用于指定元素将在其中呈现的矩形区域。 当应用程序启动并显示第一页时,布局周期首先由 Measure
调用组成,然后 Layout
调用,在 Page
对象上启动:
- 在布局周期中,每个父元素都负责对其子元素调用
Measure
方法。 - 测量子级后,每个父元素都负责对其子元素调用
Layout
方法。
此周期可确保页面上的每个视觉元素都接收对 Measure
和 Layout
方法的调用。 下图显示了该过程:
注意
请注意,如果某些更改会影响布局,布局周期也可能发生在可视化树的子集上。 这包括从集合中添加或删除的项,例如在 StackLayout
、元素的 IsVisible
属性更改或元素大小的变化。
具有 Content
或 Children
属性的每个 Xamarin.Forms 类都有可重写的 LayoutChildren
方法。 派生自 Layout<View>
的自定义布局类必须替代此方法,并确保对所有元素的子元素调用 Measure
和 Layout
方法,以提供所需的自定义布局。
此外,派生自 Layout
或 Layout<View>
的每个类都必须替代 OnMeasure
方法,即布局类通过调用其子级的 Measure
方法来确定它需要的大小。
注意
元素根据约束确定其大小,该约束指示元素父元素的可用空间量。 传递给 Measure
和 OnMeasure
方法的约束的范围可以是 0 到 Double.PositiveInfinity
。 当一个元素接收到对其 Measure
方法的调用,且具有非无限参数时,它就是受限的,或者说是完全受限的 - 该元素被限制在一个特定的大小范围内。 当一个元素接收到对其 Measure
方法的调用,且至少有一个参数等于 Double.PositiveInfinity
时,该元素就是无约束的,或者是部分受约束的 - 无限约束可以视作表示自动调整。
失效
无效是页面上元素发生更改触发新布局周期的过程。 当元素不再具有正确的大小或位置时,这些元素被视为无效。 例如,如果 Button
的 FontSize
属性发生更改,则表示 Button
无效,因为它将不再具有正确的大小。 然后,调整 Button
的大小可能会对页面其余部分的布局更改产生连锁反应。
元素通过调用 InvalidateMeasure
方法自行失效,通常当元素的属性发生更改时,可能会导致元素的新大小。 此方法触发 MeasureInvalidated
事件,元素的父句柄触发新的布局周期。
Layout
类为添加到其 Content
属性或 Children
集合的每个子级设置 MeasureInvalidated
事件的处理程序,并在删除子级时分离处理程序。 因此,每当其中一个子元素更改大小时,都会提醒具有子元素的可视化树中的每个元素。 下图演示了可视化树中元素大小的更改如何导致树出现连锁变化:
但是,Layout
类会尝试限制子级大小更改对页面布局的影响。 如果布局受限制,则子大小更改不会影响比可视化树中的父布局更高的任何内容。 但是,布局大小的更改通常会影响布局排列其子级的方式。 因此,布局大小的任何更改都将启动布局的布局周期,布局将接收对其 OnMeasure
和 LayoutChildren
方法的调用。
Layout
类还定义了一个与 InvalidateMeasure
方法类似的 InvalidateLayout
方法。 每当发生影响布局位置及其子级大小的更改时,都应调用 InvalidateLayout
方法。 例如,每当向布局添加或删除子级时,Layout
类都会调用 InvalidateLayout
方法。
可以重写 InvalidateLayout
来实现缓存,以最大程度地减少对布局子级的 Measure
方法的重复调用。 重写 InvalidateLayout
方法将提供在布局中添加或删除子级时通知。 同样,当其中一个布局的子级更改大小时,可以重写 OnChildMeasureInvalidated
方法以提供通知。 对于这两种方法替代,自定义布局应通过清除缓存做出响应。 有关详细信息,请参阅计算和缓存布局数据。
创建自定义布局
创建自定义布局的过程如下所示:
创建一个从
Layout<View>
类派生的类。 有关详细信息,请参阅创建 WrapLayout。[可选] 为应在布局类上设置的任何参数添加受可绑定属性支持的属性。 有关详细信息,请参阅添加受可绑定属性支持的属性。
替代
OnMeasure
方法以在所有布局的子级上调用Measure
方法,并返回所请求的布局大小。 有关详细信息,请参阅替代 OnMeasure 方法。替代
LayoutChildren
方法,以在所有布局的子元素上调用Layout
方法。 未能对布局中的每个子级调用Layout
方法将导致子级永远不会收到正确的大小或位置,因此子级不会在页面上可见。 有关详细信息,请参阅替代 LayoutChildren 方法。注意
枚举
OnMeasure
和LayoutChildren
替代中的子级时,请跳过IsVisible
属性设置为false
的任何子级。 这将确保自定义布局不会为不可见的子级留出空间。[可选] 替代在向布局中添加或删除子级时要通知的
InvalidateLayout
方法。 有关详细信息,请参阅替代 InvalidateLayout 方法。[可选] 替代
OnChildMeasureInvalidated
方法,以在布局的子级更改大小时通知。 有关详细信息,请参阅替代 OnChildMeasureInvalidated 方法。
注意
请注意,如果布局的大小由其父级而不是其子级控制,则不会调用 OnMeasure
替代。 但是,如果一个或两个约束是无限的,或者布局类具有非默认 HorizontalOptions
或 VerticalOptions
属性值,将调用替代。 因此,LayoutChildren
替代不能依赖于在 OnMeasure
方法调用期间获取的子大小。 相反,在调用 Layout
方法之前,LayoutChildren
必须在布局的子级上调用 Measure
方法。 或者,可以缓存在 OnMeasure
替代中获取的子级的大小,以避免以后在 LayoutChildren
替代中调用 Measure
,但布局类需要知道何时需要再次获取大小。 有关详细信息,请参阅计算和缓存布局数据。
然后,可以通过将布局类添加到 Page
以及将子级添加到布局来使用。 有关详细信息,请参阅使用 WrapLayout。
创建 WrapLayout
示例应用程序演示了一个方向敏感的 WrapLayout
类,该类在页面中水平排列其子级,然后将后续子级的显示包装到其他行。
WrapLayout
类根据子级的最大大小为每个子级分配相同的空间量,称为单元格大小。 小于单元格大小的子级可以根据单元格 HorizontalOptions
和 VerticalOptions
属性值放置在单元格中。
以下代码示例显示了 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
替代 width
和 height
参数。 将设备旋转为横向方向时,将再次调用 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
类定义 ColumnSpacing
和 RowSpacing
属性,这些属性的值用于分隔布局中的行和列,并由可绑定属性提供支持。 可绑定属性显示在以下代码示例中:
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
对象,同时考虑 RowSpacing
和 ColumnSpacing
属性值。 有关 GetLayoutData
方法的详细信息,请参阅计算和缓存布局数据。
重要
Measure
和 OnMeasure
方法不应通过返回属性设置为 Double.PositiveInfinity
的 SizeRequest
值来请求无限维度。 但是,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 方法
当向布局中添加或删除子级,或者其中一个 WrapLayout
属性更改值时,将调用 InvalidateLayout
替代,如以下代码示例所示:
protected override void InvalidateLayout()
{
base.InvalidateLayout();
layoutInfoCache.Clear();
}
替代会使布局失效,并丢弃所有缓存的布局信息。
注意
若要在向布局中添加或删除子级时停止调用 InvalidateLayout
方法的 Layout
类,请替代 ShouldInvalidateOnChildAdded
和 ShouldInvalidateOnChildRemoved
方法,并返回 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
。 以下代码示例演示要添加到 WrapLayout
的 Image
元素:
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
:
每行中的列数取决于照片大小、屏幕宽度以及每个独立于设备的单位的像素数。 Image
元素异步加载照片,因此 WrapLayout
类会频繁调用其 LayoutChildren
方法,因为每个 Image
元素都根据加载的照片接收新大小。