UWP下控件开发03 - 照片墙面板概念

大家好…我是 HIGAN ,之前呢…我们完成了一个支持自定义 StackCount 与子元素间隔的瀑布流排版面板,这个面板差不多就已经可以实用了.

我们仅仅是为面板重写了 MeasureOverrideArrangeOverride 两个方法,与添加若干布局需要的属性,就可以完成一个一般用的布局面板.

本来呢…今天我是打算来讲讲面板的性能优化与 UI 虚拟化的,不过呢,由于 UAP 的框架变了,导致 Windows.UI.Xaml.Controls.ItemContainerGenerator 的功能也变了,没办法正常的虚拟化 Item 了,导致我刚刚写好的虚拟化引擎没办法用了…明明花了一个通宵写好的…QAQ…只能重新写虚拟化引擎了…(反正这个引擎不能算高效,只能说不卡.

那么呢…就进入今天的正题!..照片墙布局面板!...

为什么要选这个面板,也是因为今天看到了 OneDrive 的黑科技,为图片自动加入标签,之后觉得 OneDrive 的图片排版也不错,就想来做个.如果大家有什么好的控件建议,也可以向我提出来,说不定会加入 HLib 里面哦.

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

首先,什么是照片墙布局?

照片墙布局顾名思义,就是用于照片墙的布局,和瀑布流不一样的是,瀑布流会呈现图片的全部内容,照片墙排版的话,对照片内容的完全呈现并没有硬性要求只是尽量呈现内容.

先来看个栗子,来自 OneDrive 的图片排版模式

jERrmiV

每张图片看上去是呈现了所有内容,因为保留了自己的宽度与高度,每张图片的大小都不一样.

但是仔细一看,每排图片的高是固定的,但是宽不是固定,这就有点瀑布流的感觉了,但是和瀑布流不一样,他并不是几个 Stack 并行无限添加 Item,照片墙是一排的图片正好宽度是一样的,这样一排排下来,那么他是如何做到,正好一排图片正好就是那么宽呢?是通过计算把正好一排的图片放一起吗?还是有什么特殊的技巧呢?

当然不可能是通过计算,放只能那么多的图片.如果是这种情况就太不实用了,无法动态添加数据.

那么肯定是用了什么技巧,我们之前说了,每张图片看上去呈现了所有内容,看上去代表着其实并没有保留所有的内容,而是剪切了部分边缘,导致能够达成对应的排版.

大致的流程如下:

添加 Item ,先获取 Item 的长宽,进行缩放,放入行中.

该行前面的几个 Item 都保留所有的内容不进行裁剪.

直到,继续向该行添加 Item 时无法容纳了.

就比较需要的空间,和剩余的空间,得到一个空间差.

我们计算一下这一行的 Item 的总和长度(不包括间隙),乘以一个允许的的偏差值,得到一个可扩展的空间.

如果可扩展的空间大于之前的空间差,我们就认为这个 Item 可以放入这一行.

让这一行的元素都按比例减小宽度,裁剪图片,为新的 Item 腾出空间.

如果这一行的元素并不能腾出想要的空间,那么只能将这个 Item 放入一个缓冲列表.

等待一个能够填入这里的元素,进入下一行的填充的时候,就对缓冲列表里的 Item 优先进行填充.

这里有几个要点要解决:

01.如果出现一个过宽的 Item,导致就算是空行也无法容纳,怎么办?那么就只能牺牲一下这个 Item 了,毕竟这种图片算是少数,直接将图片裁减至最大容纳的宽度.

02.如果极限情况下,该行就差一个像素就满足了条件,可扩展的空间一般是无法容纳一般的图片,这样会不会导致缓冲列表的 Item 过多?我们可以实现一个算法,提供一个图片最小宽,当可扩展空间小于这个尺寸的时候,对改行的 Item 进行裁剪,一般我们是裁剪宽度,这个时候我们裁剪高度,达到放宽的效果,让该行的 Item 填充整行.

03.可扩展空间如何计算?可扩展空间是该行的 Item 总宽乘以一个偏差值,这个 Item 包含要放入的 Item ,不包含间隙的宽度.

举个栗子,现在有一行有 4 个 Item ,宽分别是 150 , 99 , 116 , 125.

中间有 3 个间隙,每个间隙宽 10 ,那么这行的长度就是 390 + 30 = 420 了.

一行宽 500 也就是我们下个 Item 还有 80 - 10 = 70 可以用了.

现在我们加入一个 130 的宽度的 Item 发现并放不下,于是我们计算所有的 Item 的宽度, 390 +130 = 520.

520 * 0.2 = 104 这是我们的总宽度乘以偏差值所得出来的结果,也就是 104 是可扩展空间.

70 + 104 = 174 > 130 是可以放入新的 Item.

在这里我为了简单说明原理,复杂化了计算方法,其实实际使用的时候,是有更简单的计算方式.

例如:直接将 520 / (500 - 40) - 1 = 0.1304 < 0.2 ,将所有的 Item 宽度乘以 1 - 0.1304 = 0.8696 就是 Item 的裁剪后宽度.

对 Item 进行重新布局,即可完成这一行的布局工作.

Okay,基本的功能的实现都清楚了,先和之前一样来分析一下需求与其他的功能.首先, Item 之间的间隙也是要考虑的,但是这里我们就不分行之间的间隙和 Item 之间的间隙.

///<summary>
///设定<see cref="HLib.Controls.Primitives.PhotoWallPanel"/>元素之间的间隔.
///</summary>
public Double Spacing  
{
    get { return (Double)GetValue(SpacingProperty); }

    set { SetValue(SpacingProperty, value); }

}

public static readonly DependencyProperty SpacingProperty =  
    DependencyProperty.Register(nameof(Spacing), typeof(Double), typeof(PhotoWallPanel),
    new PropertyMetadata(0, RequestRefreshLayout));

之后仍然是排版方向属性,控制照片墙的排版方向.

///<summary>
///设定<see cref="HLib.Controls.Primitives.PhotoWallPanel"/>的布局方向.
///</summary>
public Orientation Orientation  
{
    get { return (Orientation)GetValue(OrientationProperty); }
    set { SetValue(OrientationProperty, value); }
}

public static readonly DependencyProperty OrientationProperty =  
    DependencyProperty.Register(nameof(Orientation), typeof(Orientation), 
    typeof(VirtualizingPanel), new PropertyMetadata(Orientation.Vertical,    
        RequestRefreshLayout));

紧接着是我们允许的 Item 的裁剪偏差值,为什么需要这个偏差值呢,主要是因为要保证内容裁剪不能太多,不设定的话,当出现一行最后一个 Item 大小严重超标,就会过度压缩其他的 Item ,导致内容被裁剪得厉害,同时也提供一个标准,判断是否能容得下新添加的 Item.

///<summary>
///设定<see cref="HLib.Controls.Primitives.PhotoWallPanel"/>裁剪元素时,允许的偏差值
///</summary>
public Double Deviation  
{
get { return (Double)GetValue(DeviationProperty); }  
set { SetValue(DeviationProperty, value); }  
}

public static readonly DependencyProperty DeviationProperty =  
    DependencyProperty.Register(nameof(Deviation), typeof(Double), 
    typeof(PhotoWallPanel), new PropertyMetadata(0.2, RequestRefreshLayout));

我们一般设定偏差值为 0.2 ,也就是说,一个 Item 最多裁剪到原宽的 0.8 ,一个 100 宽度的 Item 裁剪后最小的尺寸是 80. 之后是元素的固定边长,在竖向排版就是高,横向排版就是宽,也就是所谓的行列宽.

///<summary>
///设定<see cref="HLib.Controls.Primitives.PhotoWallPanel"/>元素最小长度,为0则自动取平均Item
///</summary>
public Double ItemMinLenght  
{
    get
    {
        Double result = 0;
        if ((result = (Double)GetValue(ItemMinLenghtProperty))
        == 0)
        {
            result = itemAverageLength;
        }
        return result;
    }
    set { SetValue(ItemMinLenghtProperty, value); }
}

public static readonly DependencyProperty ItemMinLenghtProperty =  
DependencyProperty.Register(nameof(ItemMinLenght),  
    typeof(Double), typeof(PhotoWallPanel), 
    new PropertyMetadata(0, RequestRefreshLayout));

在这里我们用了个小技巧,当这个属性为0的时候,自动来取所有 Item 的宽度的平均值.

Okay,需求和功能基本都弄明白了,现在就来实现功能,这次我们引入一个LayoutEngine 专门为我们的PhotoWallPanel 排版,使用LayoutEngine 的话可以把布局逻辑独立出来,之后维护与优化做起来也很简单,在之前的WaterfallPanel我也会在之后为其添加LayoutEngine 单独把布局逻辑分离出来.

那么现在就开始构造一个全新的LayoutEngine 类,由于之后LayoutEngine 的应用比较广泛,我们就先拟定一个接口,只要是LayoutEngine 就应该实现这个接口,提供布局.

public interface ILayoutEngine  

定义一个接口叫ILayoutEngine ,由于我们是对界面元素进行布局,我们这个接口需要实现测量与布局子元素的方法,那么也就是只需要实现,测量与布局就好了,我们为这个接口定义两个方法,测量与布局.

///<summary>
///提供测量逻辑
///</summary>
Size Measure(Size availableSize);

///<summary>
///提供布局逻辑
///</summary>
Size Arrange(Size finalSize);  

这就完成了我们的接口定义,接下来我们定义一个PhotoWallLayoutEngine 类,来实现上面的接口,对照片墙布局提供排版.

public class PhotoWallLayoutEngine : ILayoutEngine  

但是呢,如果仅仅是对MeasureOverride或者ArrangeOverride提取出来放到另外一个类里面,我们也不用这么大费周折了,我们要的是高效的布局方式,什么是高效的布局方式呢?当一个 Item 放入面板的时候,我们就对这个 Item 进行测量,并不需要在MeasureOverride里面进行统一的遍历测量,当子元素的 Xaml 内容比较多,重复的测量一个 Item 会对整个布局过程带来不必要的消耗.

那么我们什么时候对 Item 进行测量呢? 当 Item 加入面板,我们需要测量这个 Item. 当 Item 元素改变,我们也只需要测量这个 Item.

那么很简单,我们提供 AddItem 方法, InsertItem 方法,我们在父面板的 Item 变化的时候调用这些方法. 至于接口的Measure方法,我们只要在上面的的方法测量好了, Measure 方法里面只需要直接返回结果就好了. 至于 Item 改变的时候,由于我们的照片墙排版的子元素一般是固定大小,而且排版的需要也不允许元素改变,但是在其他的排版里面元素改变的话,可以监控LayoutUpdated事件或者 SizeChanged 事件.

另外是布局过程,什么时候对 Item 进行布局呢? 当 Item 加入面板,我们需要对这个 Item 进行布局. 当 Item 插入面板,我们需要对这个 Item 以及之后的所有 Item 进行布局. 当 Item 元素改变,我们需要对这个 Item 以及之后的所有 Item 进行布局.

和上面一样,我们也不需要监控 Item 改变的事件,而Arrange 方法也什么都不需要做,我们在测量完 Item 之后就应该立即开始布局.

Okay,基本的逻辑我们已经确定了,实现的话,就放到下一篇啦.下一篇我会着重对布局引擎(Layout Engine)与虚拟化引擎(Virtualizing Engine)进行说明.