UWP下控件开发05 - 自适应交错布局

好久没有更新这个系列了,最近正好有用到一个新的布局方式,写了个布局面板,顺便就来更新一下咯,这次讲到的面板是 AdaptiveStaggeredPanel ,也就是标题的自适应交错布局。

这个布局面板的特性用一张图来表示就是

6ZVJ3q

大体上就是一个多列多行的布局模式,在竖向布局情况下,每行宽度都是相等的,但是我们可以看到每行都被沾满,即使有多余的子项正常情况下无法沾满一行,但是通过设定不同的宽度,还是能沾满一行。

这个面板的布局行为大体上就是:

FB32ua

AZNvuaN

每行设定一个 Grid 作为父容器,该行有多少个子项,就为该 Grid 设定多少个列,每个列的长度都是 1 Star。 但是每行添加一个 Grid 会导致一定的效率问题,而且为了实现更多的效果,例如列之间的距离,行之间的距离,处于不同方位时显示不同的效果,例如实现下列的布局效果。

所以我打算实现一个这样的自适应交错布局面板。了解了面板的布局过程,实现起来就有思路了,那么就从第一步开始吧。 所以我打算实现一个这样的自适应交错布局面板。了解了面板的布局过程,实现起来就有思路了,那么就从第一步开始吧。

/// <summary>
/// 提供自适应交错布局面板
/// </summary>
public class AdaptiveStaggeredPanel : Panel  
{
}

创建一个 AdaptiveStaggeredPanel 类 ,继承自面板 Panel 基类,接下来考虑需要的布局参数,提供相应的属性。 首先是布局方向,我们的面板既可以横向布局也可以纵向布局,那么先创建一个 Orientation 属性,默认是纵向布局,当属性改变时需要无效化布局。

public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(  
    nameof(Orientation), typeof(Orientation), typeof(AdaptiveStaggeredPanel), new PropertyMetadata(Orientation.Vertical, OnLayoutPropertyChanged));
/// <summary>
/// 获取或设置一个值,该值表示布局栈的方向
/// </summary>
public Orientation Orientation  
{
    get { return (Orientation)GetValue(OrientationProperty); }
    set { SetValue(OrientationProperty, value); }
}

之后是列数,也就是一行布局最大的子项的个数,这个属性决定了一行最多放几个子项之后需要换行,默认值是 2 个。

public static readonly DependencyProperty RowItemCountProperty = DependencyProperty.Register(  
    nameof(RowItemCount), typeof(int), typeof(AdaptiveStaggeredPanel), new PropertyMetadata(2, OnLayoutPropertyChanged));
/// <summary>
/// 获取或设置一个值,该值表示一行最多的子项的个数
/// </summary>
public int RowItemCount  
{
    get { return (int)GetValue(RowItemCountProperty); }
    set { SetValue(RowItemCountProperty, value); }
}

接下来是行间距和列间距,这些值决定了行与列之间的间距,这样就不需要子项来处理 Margin 与 Padding 全部交给面板就好了。

public static readonly DependencyProperty RowSpacingProperty = DependencyProperty.Register(  
    nameof(RowSpacing), typeof(double), typeof(AdaptiveStaggeredPanel), new PropertyMetadata(default(double), OnLayoutPropertyChanged));
/// <summary>
/// 获取或设置一个值,该值表示行之间的间隔
/// </summary>
public double RowSpacing  
{
    get { return (double)GetValue(RowSpacingProperty); }
    set { SetValue(RowSpacingProperty, value); }
}
 
public static readonly DependencyProperty ColumnSpacingProperty = DependencyProperty.Register(  
    nameof(ColumnSpacing), typeof(double), typeof(AdaptiveStaggeredPanel), new PropertyMetadata(default(double), OnLayoutPropertyChanged));
/// <summary>
/// 获取或设置一个值,该值表示列之间的间隔
/// </summary>
public double ColumnSpacing  
{
    get { return (double)GetValue(ColumnSpacingProperty); }
    set { SetValue(ColumnSpacingProperty, value); }
}

到这里应该就没有其他的与布局相关的属性了,当布局相关的属性被更改时,我们还需要将当前的布局无效化,重新布局子项,就是我们之前用到的 OnLayoutPropertyChanged 方法。

/// <summary>
/// 布局相关属性变更,无效化布局
/// </summary>
private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)  
{
    (d as AdaptiveStaggeredPanel).InvalidateMeasure();
    (d as AdaptiveStaggeredPanel).InvalidateArrange();
}

接下来就是重中之重,进行测量与布局过程。 那么再让我么回忆一下布局过程,用简单的文字描述一下测量过程,将测量过程理顺吧。

// 判断面板布局方向
//// 纵向布局
////// 所需的宽度为提供的宽度
////// 计算正常一行的子项需要的宽度,注意将列间距算上
////// 计算如果当子项按正常一行无法填满时,所需要的宽度
////// 遍历子项
//////// 为每个子项调用调用子项的测量方法
//////// 如果为正常行里的子项,那么提供正常的子项宽度,否则提供特殊的子项宽度
//////// 如果是一行的第一子项,那么提供面板所剩下的高度,否则提供这一行的第一个子项的高度
//////// 如果已经到了一行的最后一个,那么从可用高度减去这一行的高度与行间距,进入下一行的布局
////// 纵向布局测量就此完成
//// 横向布局
////// 所需的高度为提供的高度
////// 计算正常一列的子项需要的高度,注意将列间距算上,这里仍然用列间距代表列间距始终是代表一个布局单元中的子项间的间距,在横向排版中,布局单元室列,纵向排版中则是行
////// 计算如果当子项按正常一列无法填满时,所需要的高度
////// 遍历子项
//////// 为每个子项调用调用子项的测量方法
//////// 如果为正常列里的子项,那么提供正常的子项高度,否则提供特殊的子项高度
//////// 如果是一列的第一子项,那么提供面板所剩下的宽度,否则提供这一行的第一个子项宽度
//////// 如果已经到了一列的最后一个,那么从可用宽度减去这一行的宽度与行间距,进入下一列的布局
////// 纵向布局测量就此完成
// 完成测量

通过这些说明就可以在下面写出相应操作的代码,我们只需正确的完成每一行的工作就可以写出基本不会出错的代码。 那么测量过程大致就是如此:

protected override Size MeasureOverride(Size availableSize)  
{
    Size requestSize = Size.Empty;
    Size itemAvailableSize = Size.Empty;
    // 判断面板布局方向
    switch (Orientation)
    {
        // 纵向布局
        case Orientation.Vertical:
            // 所需的宽度为提供的宽度
            requestSize.Width = availableSize.Width;
            // 计算正常一行的子项需要的宽度,注意将列间距算上
            var normalItemWidth = (availableSize.Width - ((RowItemCount - 1) * ColumnSpacing)) / RowItemCount;
            // 计算如果当子项按正常一行无法填满时,所需要的宽度
            var notEnoughItemWidth = (availableSize.Width - (((Children.Count % RowItemCount) - 1) * ColumnSpacing)) / (Children.Count % RowItemCount);
            var fristItemHeight = 0.0;
            // 遍历子项
            for (int i = 0; i < Children.Count; i++)
            {
                // 为每个子项调用调用子项的测量方法
 
                // 如果为正常行里的子项,那么提供正常的子项宽度,否则提供特殊的子项宽度
                if (i < Children.Count - (Children.Count % RowItemCount))
                {
                    itemAvailableSize.Width = normalItemWidth;
                }
                else
                {
                    itemAvailableSize.Width = notEnoughItemWidth;
                }
                // 如果是一行的第一子项,那么提供面板所剩下的高度,否则提供这一行的第一个子项的高度
                if (i % RowItemCount == 0)
                {
                    itemAvailableSize.Height = availableSize.Height - requestSize.Height;
                }
                else
                {
                    itemAvailableSize.Height = fristItemHeight;
                }
                Children[i].Measure(itemAvailableSize);
                // 如果已经到了一行的最后一个,那么从可用高度减去这一行的高度与行间距,进入下一行的布局
                if ((i + 1) % RowItemCount == 0)
                {
                    requestSize.Height += (fristItemHeight + RowSpacing);
                }
 
                if (i % RowItemCount == 0)
                {
                    fristItemHeight = Children[i].DesiredSize.Height;
                }
            }
            // 这里需要减一个行间距,因为上面加行间距是在每行下面,但是最后一行是不需要行间距的
            requestSize.Height -= RowSpacing;
            // 纵向布局测量就此完成
            break;
        // 横向布局
        case Orientation.Horizontal:
            // 所需的高度为提供的高度
            requestSize.Height = availableSize.Height;
            // 计算正常一列的子项需要的高度,注意将列间距算上,这里仍然用列间距代表列间距始终是代表一个布局单元中的子项间的间距,在横向排版中,布局单元室列,纵向排版中则是行
            var normalItemHeight = (availableSize.Height - ((RowItemCount - 1) * ColumnSpacing)) / RowItemCount;
            // 计算如果当子项按正常一列无法填满时,所需要的高度
            var notEnoughItemHeight =(availableSize.Height - (((Children.Count % RowItemCount) - 1) * ColumnSpacing)) / (Children.Count % RowItemCount);
            var fristItemWidth = 0.0;
            // 遍历子项
            for (int i = 0; i < Children.Count; i++)
            {
                // 为每个子项调用调用子项的测量方法
 
                // 如果为正常列里的子项,那么提供正常的子项高度,否则提供特殊的子项高度
                if (i < Children.Count - (Children.Count % RowItemCount))
                {
                    itemAvailableSize.Height = normalItemHeight;
                }
                else
                {
                    itemAvailableSize.Height = notEnoughItemHeight;
                }
                // 如果是一列的第一子项,那么提供面板所剩下的宽度,否则提供这一行的第一个子项的宽度
                if (i % RowItemCount == 0)
                {
                    itemAvailableSize.Width = availableSize.Width - requestSize.Width;
                }
                else
                {
                    itemAvailableSize.Width = fristItemWidth;
                }
                Children[i].Measure(itemAvailableSize);
                // 如果已经到了一列的最后一个,那么从可用宽度减去这一行的宽度与行间距,进入下一列的布局
                if ((i + 1) % RowItemCount == 0)
                {
                    requestSize.Width += (fristItemWidth + RowSpacing);
                }
 
                if (i % RowItemCount == 0)
                {
                    fristItemWidth = Children[i].DesiredSize.Width;
                }
            }
            // 这里需要减一个行间距,因为上面加行间距是在每列右面,但是最后一列是不需要行间距的
            requestSize.Width -= RowSpacing;
            // 纵向布局测量就此完成
            break;
    }
    // 完成测量,返回所需的高度与宽度
    return requestSize;
}

完成测量之后应该是布局过程,布局过程要比测量更加的复杂一些,所以我们需要更加小心的梳理测量过程。 由于我们的子项布局属于强制定宽布局(纵向),也就是在纵向布局中,无论子项是否需要这么多布局空间,我们都希望子项沾满他们所得的空间,那么我们在布局过程中的大小就按照测量中的来,而不是最后子项测量的大小,知道了这个特性,我们可以在测量过程中,为每个子项提供一个字典,记录分配的大小,这样在测布局过程中就可以直接使用,而不需要重新计算,这样的话就可以极力的简化布局过程。

private Dictionary<UIElement, Size> ArrangeSizeDictionary;  

接下来就是用文字描述布局过程

// 判断面板布局方向
//// 纵向布局
////// 最后的宽度为提供的宽度
////// 遍历子项,并创建布局元素坐标
//////// 如果 X 坐标为一行最多的子项的个数,表示这是新的行,需要将 X 清0,Y 递增
//////// 计算布局时的坐标
//////// 用之前测量的大小与计算的坐标,定位并布局子项
//////// 如果已经到了一行的最后一个,那么从需求的高度需要加上当前子项的高度与行间距,进入下一行的布局
////// 纵向布局过程就此完成
//// 横向布局
////// 最后的高度为提供的高度
////// 遍历子项,并创建布局元素坐标
//////// 如果 X 坐标为一行最多的子项的个数,表示这是新的行,需要将 X 清0,Y 递增
//////// 计算布局时的坐标
//////// 用之前测量的大小与计算的坐标,定位并布局子项
//////// 如果已经到了一行的最后一个,那么从需求的高度需要加上当前子项的高度与行间距,进入下一行的布局
////// 纵向布局过程就此完成
// 完成布局

将文字翻译成代码:

protected override Size ArrangeOverride(Size finalSize)  
{
    var location = new Point();
 
    // 判断面板布局方向
    switch (Orientation)
    {
        // 纵向布局
        case Orientation.Vertical:
            // 最后的宽度为提供的宽度
            finalSize.Width = DesiredSize.Width;
            finalSize.Height = 0;
 
            // 遍历子项,并创建布局元素坐标
            for (int i = 0, posX = 0, posY = 0; i < Children.Count; i++, posX++)
            {
 
                // 如果 X 坐标为一行最多的子项的个数,表示这是新的行,需要将 X 清0,Y 递增
                if (posX == RowItemCount)
                {
                    posX = 0;
                    posY++;
                }
                var size = ArrangeSizeDictionary[Children[i]];
 
                // 计算布局时的坐标
                location.X = posX * (size.Width + ColumnSpacing);
                location.Y = finalSize.Height;
 
                // 用之前测量的大小与计算的坐标,定位并布局子项
                Children[i].Arrange(new Rect(location, size));
 
                // 如果已经到了一行的最后一个,那么从需求的高度需要加上当前子项的高度与行间距,进入下一行的布局
                if (posX == RowItemCount - 1 || i == Children.Count - 1)
                {
                    finalSize.Height += ArrangeSizeDictionary[Children[i - 1]].Height + RowSpacing;
                }
            }
            // 与测量时相同,需要将高度减去一个行间距
            finalSize.Height -= RowSpacing;
 
            // 纵向布局过程就此完成
            break;
        ///横向布局
        case Orientation.Horizontal:
            // 最后的高度为提供的高度
            finalSize.Height = DesiredSize.Height;
            finalSize.Width = 0;
 
            // 遍历子项,并创建布局元素坐标
            for (int i = 0, posX = 0, posY = 0; i < Children.Count; i++, posX++)
            {
 
                // 如果 X 坐标为一行最多的子项的个数,表示这是新的行,需要将 X 清 0,Y 递增
                if (posX == RowItemCount)
                {
                    posX = 0;
                    posY++;
                }
                var size = ArrangeSizeDictionary[Children[i]];
 
                // 计算布局时的坐标
                location.X = finalSize.Width;
                location.Y = posX * (size.Height + ColumnSpacing);
 
                // 用之前测量的大小与计算的坐标,定位并布局子项
                Children[i].Arrange(new Rect(location, size));
 
                // 如果已经到了一行的最后一个,那么从需求的高度需要加上当前子项的高度与行间距,进入下一行的布局
                if (posX == RowItemCount - 1 || i == Children.Count - 1)
                {
                    finalSize.Width += ArrangeSizeDictionary[Children[i - 1]].Width + RowSpacing;
                }
            }
            // 与测量时相同,需要将宽度减去一个行间距
            finalSize.Width -= RowSpacing;
 
            // 纵向布局过程就此完成
            break;
    }
 
    // 完成布局,清空测量的字典
    ArrangeSizeDictionary?.Clear();
    ArrangeSizeDictionary = null;
    return finalSize;
}

那么如何让子项知道自己所处的位置并提供不同的显示效果呢?这就留给大家思考了,实现这个效果的代码会在之后同步到我的 GitHub 上,我会加上比较详细的注释,还喜欢大家给个星星。