UWP下控件开发02 - 进阶瀑布流布局

噗噗…昨天我们算是开了一个头,弄清楚了,布局面板的工作原理与实现了一个 Stack 布局的布局面板…今天呢…打算将 Sack 的布局拓展成多列的 Waterfall 布局,那么如何实现呢…就进入今天的正题…

(注意:本节内容可能有错误或者不确定元素,请带着怀疑的态度来看)

首先,我们了解一下什么是瀑布流… 瀑布流呢,顾名思义,就是和瀑布一样的排版,这种排版适用于控件元素大小无法确定不一样,他的排版效果就大概是这样:

EZjuman

可以看到上面的图片大小并不是一致的,图片分为了5个列,列的宽是固定的,而长可以根据不同的图片,做适当的缩放.既可以看到图片的全貌,也让排版看起来不乱.那么仔细看一下布局,发现呢这其实就是由5个 Stack 组成的布局,之前我们实现了 Stack 布局,之后实现起这个来也应该很简单.

不过呢,先不要急着动手…先分析一下需求,与功能.首先,由于是 UWP,我们的应用可能在 W10M行,对于手机,显示5列是不是有点…嗯…肯定不太好… 那么不如加上一个控制有多少个 Stack 的属性吧.

/// <summary>
/// 设定 <see cref="HLib.Controls.VirtualizingPanel"/> 的栈布局个数,最小值为1.
/// </summary>
public int StatckCount  
{
    get { return (int)GetValue(StatckCountProperty); }
    set { SetValue(StatckCountProperty, value); }
}
 
public static readonly DependencyProperty StatckCountProperty =  
        DependencyProperty.Register("StatckCount", typeof(int), typeof(VirtualizingPanel), new PropertyMetadata(1, RequestArrange));

这样我们为 VirtualizingPanel 添加了一个依赖属性 StackCount ,这个属性用来控制布局里面有多少的 Stack.接下来是 每个 Stack 之前的间隙,如果没有间隙的话,图片挤在一起也不大好,固定间隙的话,有些时候又不适用,那就只能再来一个依赖属性咯.

/// <summary>
/// 设定 <see cref="HLib.Controls.VirtualizingPanel"/> 的栈布局的间距.
/// </summary>
public Double StatckSpacing  
{
    get { return (Double)GetValue(StatckSpacingProperty); }
    set { SetValue(StatckSpacingProperty, value); }
}
 
public static readonly DependencyProperty StatckSpacingProperty =  
        DependencyProperty.Register("StatckSpacing", typeof(Double), typeof(VirtualizingPanel), new PropertyMetadata(10, RequestArrange));

StatckSpacing 属性控制了每个 Stack 之间的距离,为自定义排版更加方便.接下来是每个 Stack 里面的每个 Item 的距离了,这个距离是指 Stack 里面上下的 Item 之间的间隙.

/// <summary>
/// 设定 <see cref="HLib.Controls.VirtualizingPanel"/> 的子元素的间距.
/// </summary>
public Double ItemsSpacing  
{
    get { return (Double)GetValue(ItemsSpacingProperty); }
    set { SetValue(ItemsSpacingProperty, value); }
}

public static readonly DependencyProperty ItemsSpacingProperty =  
        DependencyProperty.Register("ItemsSpacing", typeof(Double), typeof(VirtualizingPanel), new PropertyMetadata(10, RequestArrange));

ItemsSpacing 属性可以设定每个 Stack 里面的 Item 之间的距离.到这里,感觉差不多了,但是呢,如果在平板上面运行,向下滑动并不适合平板上的大量数据列表,我们更推荐左右滑动,那么我们就需要一个参数控制面板是横向排版还是纵向排版.

/// <summary>
/// 设定 <see cref="HLib.Controls.VirtualizingPanel"/> 的布局方向.
/// </summary>
public Orientation Orientation  
{
    get { return (Orientation)GetValue(OrientationProperty); }
    set { SetValue(OrientationProperty, value); }
}
 
public static readonly DependencyProperty OrientationProperty =  
        DependencyProperty.Register("Orientation", typeof(Orientation), typeof(VirtualizingPanel), new PropertyMetadata(Windows.UI.Xaml.Controls.Orientation.Vertical, RequestArrange));

Orientation 属性可以设定我们面板的布局方向,这样我们就得到了一个支持设定多列/行,支持设定元素间距,支持横向与纵向排版的瀑布流面板了.

如果细心的话,会发现在设定上面的依赖属性的时候,设定了 PropertyChangeCallback 参数,提供了一个 RequestArrange 方法,当属性值改变的时候,这个 RequestArrange 方法是为了在上面和布局有关的属性改变的时候对面板的布局进行刷新与重画.

/// <summary>
/// 请求重新测量与布局面板
/// </summary>
private static void RequestArrange(DependencyObject d, DependencyPropertyChangedEventArgs e)  
{
    (d as VirtualizingPanel).InvalidateMeasure();
    (d as VirtualizingPanel).InvalidateArrange();
}

InvalidateMeasure 方法和 InvalidateArrange 方法的调用会导致该对象的布局测量失效,请求重新测量.

我们的需求和功能都确定了,之后就是应该动手来实现了. 我会对代码进行比较详细的注释,看代码的话应该能看得懂.

/// <summary>
/// 测量面板需要的空间
/// </summary>
protected override Size MeasureOverride(Size availableSize)  
{
    var measure = base.MeasureOverride(availableSize);
 
    double itemFixed = 0;
    Size requestSize = Size.Empty;
 
    //判断面板的布局类型,是横向布局还是纵向布局
    if (Orientation == Orientation.Vertical)
    {
        //纵向布局
 
        //创建一个列表记录所有 Stack 的长度
        List<Double> offsetY = new Double[StatckCount].ToList();
 
        //计算一个 Item 的固定边长度,纵向布局的话是宽固定
        itemFixed = (availableSize.Width - StatckSpacing * (StatckCount - 1)) / StatckCount;
 
        requestSize = new Size()
        {
            //设定需要的空间的宽,一般是提供多少要多少
            Width = availableSize.Width
        };
 
        //遍历 Children 来测量长度
        foreach (var item in this.Children)
        {
            //寻找最短的 Stack ,将新的 Item 分配到这个 Stack
            int minIndex = offsetY.IndexOf(offsetY.Min());
            //向 Item 发送测量请求,让 Item 测量自己需要的空间
            item.Measure(new Size(itemFixed, double.PositiveInfinity));
            //测量结果保存在 DesiredSize 属性里面
            var itemRequestSize = item.DesiredSize;
            //将这个 Stack 的长度加上新的 Item 的长度和 Item 的间隙
            offsetY[minIndex] += itemRequestSize.Height + ItemsSpacing;
        }
        //寻找最长的 Stack,这个 Stack 就是面板需要的高度
        requestSize.Height = offsetY.Max();
    }
    else
    {
        //横向布局,内容大同小异,区别就是把长变成了宽
 
        List<Double> offsetX = new Double[StatckCount].ToList();
 
        //Item 的固定边为长
        itemFixed = (availableSize.Height - StatckSpacing * (StatckCount - 1)) / StatckCount;
 
        requestSize = new Size()
        {
            Height = availableSize.Height
        };
 
        foreach (var item in this.Children)
        {
            int minIndex = offsetX.IndexOf(offsetX.Min());
            item.Measure(new Size(double.PositiveInfinity, itemFixed));
            var itemRequestSize = item.DesiredSize;
            offsetX[minIndex] += itemRequestSize.Width;
        }
        requestSize.Width = offsetX.Max();
    }
 
    //返回我们面板需要的大小
    return requestSize;
}

上面是测量过程,无非是分配 Item 到 Stack 之后就是 Stack 布局的那套了.

下面是布局过程.

/// <summary>
/// 对面板内的元素进行布局
protected override Size ArrangeOverride(Size finalSize)  
{
    //建立两个列表储存 Item 的X坐标和Y坐标
    List<Double> offsetX = new List<Double>();
    List<Double> offsetY = new List<Double>();
 
    //最短栈默认为第一个
    int minIndex = 0;
 
    //判定布局类型
    if (Orientation == Orientation.Vertical)
    {
        //纵向布局
 
        //初始化坐标,由于是纵向布局,纵坐标是从0开始,横坐标则是固定值
        for (int i = 0; i < StatckCount; i++)
        {
            //这里的GetOffsetX是计算每个栈的横坐标,计算过程是这样的:
            //(int index) => index * (this.DesiredSize.Width + StatckSpacing) / StatckCount
            offsetX.Add(GetOffsetX(i));
            offsetY.Add(0);
        }
 
        //遍历 Children 进行布局
        foreach (var item in this.Children)
        {
            //取最短的 Stack 加入新的 Item
            double min = offsetY.Min();
            //获取最短的 Stack 的编号
            minIndex = offsetY.IndexOf(min);
 
            //对 item 进行布局
            item.Arrange(new Rect(offsetX[minIndex], offsetY[minIndex], item.DesiredSize.Width, item.DesiredSize.Height));
            //递增纵坐标
            offsetY[minIndex] += (item.DesiredSize.Height + ItemsSpacing);
        }
    }
    else
    {
        //横向布局,内容也是大同小异
 
        for (int i = 0; i < StatckCount; i++)
        {
            offsetX.Add(0);
            //这里的 GetOffsetY 的计算过程如下:
            //(int index) => index * (this.DesiredSize.Height + StatckSpacing) / StatckCount
            offsetY.Add(GetOffsetY(i));
        }
 
        foreach (var item in this.Children)
        {
            double min = offsetX.Min();
            minIndex = offsetX.IndexOf(min);
 
            item.Arrange(new Rect(offsetX[minIndex], offsetY[minIndex], item.DesiredSize.Width, item.DesiredSize.Height));
            offsetX[minIndex] += (item.DesiredSize.Width + ItemsSpacing);
        }
    }
 
    //直接返回参数
    return finalSize;
}

这样的话,就完成了 VirtualizingPanel 的测量与布局过程,一个瀑布流布局的面板已经完成了,下面来看使用情况与效果 纵向排版 Xaml

uMBryiY

<ListView>  
    <ListView.ItemsPanel>
        <ItemsPanelTemplate>
                    <local:TestPanel StatckCount="5" ItemsSpacing="2" StatckSpacing="4"/>
        </ItemsPanelTemplate>
    </ListView.ItemsPanel>
</ListView>  

效果 横向排版 Xaml

i2aeQn

<ListView ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Disabled" ScrollViewer.HorizontalScrollMode="Auto">  
    <ListView.ItemsPanel>
        <ItemsPanelTemplate>
            <local:TestPanel StatckCount="5" ItemsSpacing="2" StatckSpacing="4" Orientation="Horizontal"/>
        </ItemsPanelTemplate>
    </ListView.ItemsPanel>
</ListView>  

效果 值得注意的是,在测量过程中,返回值的 Size 的长宽不能超过提供的参数的 Size 的长宽,不然就会出错. 所以在横向排版的时候需要设定 ListView 的 ScrollView 提供的附加属性 HorizontalScrollBarVisibility 与 HorizontalScrollMode 为 Auto ,绝对不能是 Disabled.

另外是对于网络图片,需要事先获取图片的长宽,所以对子元素需要重写测量过程.

protected override Size MeasureOverride(Size availableSize)  
{
    if(Double.IsInfinity(availableSize.Width))
    {
        if(Double.IsInfinity(availableSize.Height))
        {
            return new Size((this.DataContext as PostViewModel).Post.PreviewImageWidth, (this.DataContext as PostViewModel).Post.PreviewImageHeight);
        }
        else
        {
            return MathHelper.ResizeWithHeight((this.DataContext as PostViewModel).Post.PreviewImageWidth, (this.DataContext as PostViewModel).Post.PreviewImageHeight, availableSize.Height);
        }
    }
    else
    {
        if (Double.IsInfinity(availableSize.Height))
        {
            return MathHelper.ResizeWithWidth((this.DataContext as PostViewModel).Post.PreviewImageWidth, (this.DataContext as PostViewModel).Post.PreviewImageHeight, availableSize.Width);
        }
        else
        {
            double scale = availableSize.Width / availableSize.Height;
            if((DataContext as PostViewModel).Post.PreviewImageHeight * scale > (DataContext as PostViewModel).Post.PreviewImageWidth)
            {
                return MathHelper.ResizeWithHeight((this.DataContext as PostViewModel).Post.PreviewImageWidth, (this.DataContext as PostViewModel).Post.PreviewImageHeight, availableSize.Height);
            }
            else
            {
                return MathHelper.ResizeWithWidth((this.DataContext as PostViewModel).Post.PreviewImageWidth, (this.DataContext as PostViewModel).Post.PreviewImageHeight, availableSize.Width);
            }
        }
    }
}

这段代码看起来比较长其实就是一些根据提供的可用大小参数采用不同的逻辑,进行缩放,提供图片的长宽,来缩放,适应提供的可用大小参数.MathHelper 的 ResizeWithHeight 和 ResizeWithWidth 方法就是提供给定的长宽,和新的长与宽,对 Size 进行比例缩放.

下一次呢,我打算来讲讲性能优化与UI虚拟化,嗯,UI虚拟化还是有点悬…OTZ