首页 > 开发 > 前端 > 正文

飞流直下的精彩 -- 淘宝UWP中瀑布流列表的实现

2016-04-06 13:25:47  来源:极客头条
  在淘宝UWP中,搜索结果列表是用户了解宝贝的重要一环,其中的图片效果对吸引用户点击搜索结果,查看宝贝详情有比较大的影响。为此手机淘宝特意在搜索结果列表上采用了2种表现方式:一种就是普通的列表模式,而另一种则是突出宝贝图片的瀑布流模式。
  
  如果用户搜索某些关键字,如女装类的情况下,淘宝的搜索结果会自动切换到瀑布流模式,让宝贝的美图更加冲击用户的视觉。
  但是UWP默认的列表控件并没有这种效果,listview控件中虽然子元素可以不一样大小,但是只能有1列,gridview控件虽然有多列,但每个子元素都只能取相同大小。经过一番搜索,也只有元素由固定大小的不同倍数构成的gridview控件可以使用,但效果并不理想。那么我们有没有办法能得到瀑布流的效果的控件呢?答案是肯定的。我们可能记得在listview中,如果我们要改变列表的扩展方向,需要在xaml中定义listview的itemspanel:
View Code  在gridview中设置最大的行数或列数时,我们也要定义ItemsWrapGrid。
  这里的ItemsStackPanel,ItemsWrapGrid与我们之前在淘宝UWP--自定义Panel中所提到的panel有什么关系呢?
  实际上它们都是继承自panel的FrameworkElement,也就是说它们都可以对内部的子元素进行布局。不管listview还是gridview,他们列表的形式都是由itemsPanel决定的,listview只有1列,可以纵向或者横向扩展,是由它使用的itemsPanel- ItemsStackPanel确定的,gridview可以有多列,可以纵向或者横向扩展,也是由它使用了ItemsWrapGrid作为itemsPanel来决定的。那么如果我们根据淘宝UWP--自定义Panel中提到的方法,自定义一个panel,就可以实现瀑布流中形式的列表了。
  确定了要实现一个瀑布流的布局panel,我们接下来考虑一下我们的具体有哪些需求呢?在淘宝的搜索结果瀑布流中,只用了2列。但是考虑到我们的淘宝UWP可能运行在PC或者平板等横向屏幕的设备上,如果也用2列的话会有很多图只能在屏幕中显示一部分。所以在PC或者平板等横向屏幕的设备上,我们要让瀑布流的列数增加,也就是说我们的panel需要能自定义列数。
  在淘宝的搜索结果瀑布流中,宝贝的搜索结果是纵向扩展的,那么有没有可能有情况需要使用横向扩展的瀑布流呢?想想似乎是比较酷的,那么就为我们的panel加上扩展方向的选择吧。
  在确定了具体需求之后就可以开始着手实现我们的自定义panel了。
  我们的面板的名字就叫WaterfallPanel吧,需要继承panel类型,能定义行数或者列数NumberOfColumnsOrRows,能定义扩展方向WaterfallOrientation,并实现MeasureOverride和ArrangeOverride方法:
public > WaterfallPanel :Panel{ public int NumbersOfColumnsOrRows{ get { return (int)GetValue(NumbersOfColumnsOrRowsProperty); } set { SetValue(NumbersOfColumnsOrRowsProperty, value); }} // Using a DependencyProperty as the backing store for NumbersOfColumnsOrRows. This enables animation, styling, binding, etc...public static readonly DependencyProperty NumbersOfColumnsOrRowsProperty =DependencyProperty.Register("NumbersOfColumnsOrRows", typeof(int), typeof(WaterfallPanel), new PropertyMetadata(2)); public Orientation WaterfallOrientation{ get { return (Orientation)GetValue(WaterfallOrientationProperty); } set { SetValue(WaterfallOrientationProperty, value); }} // Using a DependencyProperty as the backing store for WaterfallOrientation. This enables animation, styling, binding, etc...public static readonly DependencyProperty WaterfallOrientationProperty =DependencyProperty.Register("WaterfallOrientation", typeof(Orientation), typeof(WaterfallPanel), new PropertyMetadata(Orientation.Vertical)); protected override Size MeasureOverride(Size availableSize){ return base.MeasureOverride(availableSize);} protected override Size ArrangeOverride(Size finalSize){ return base.ArrangeOverride(finalSize);}}View Code  这就是我们的panel的雏形了,需要注意的是我们的NumberOfColumnsOrRows,和WaterfallOrientation属性需要能在xaml中调用,因此必须写成DependencyProperty的形式。在写的时候可以用先输入propdp,再按tab键,在vs自动生成的模板上进行修改的方法,能方便很多。考虑到用户也可能会不输入行列数或者扩展方向,我们给了它们默认值显示2行或列,纵向扩展。
  首先我们来实现MeasureOverride方法。MeasureOverride方法接受一个panel可以占据的空间大小availableSize,再根据这个availableSize给内部的子元素分配可以占据的空间大小。在瀑布流中,以纵向扩展为例,每个元素的最大宽度都是相等的,都是panel宽度的列数分之一。而每个元素的高度则可以自由扩展。因此根据这样的思路我们的MeasureOverride方法的实现应该是:
protected override Size MeasureOverride(Size availableSize){if (NumberOfColumnsOrRows < 1){throw (new ArgumentOutOfRangeException("NumberOfColumnsOrRows", "NumberOfColumnsOrRows must >0"));//太窄}var LenList = new List();for (int i = 0; i < NumberOfColumnsOrRows; i++){LenList.Add(0);} if (WaterfallOrientation == Orientation.Vertical){double maxWidth = availableSize.Width / NumberOfColumnsOrRows;Size maxSize = new Size(maxWidth, double.PositiveInfinity);foreach (var item in Children){item.Measure(maxSize);var itemHeight = item.DesiredSize.Height;var minLen = LenList[0];int minP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] < minLen){minLen = LenList[i];minP = i;}}LenList[minP] += itemHeight;}var maxLen = LenList[0];int maxP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] > maxLen){maxLen = LenList[i];maxP = i;}}return new Size(availableSize.Width, LenList[maxP]);}else{double maxHeight = availableSize.Height / NumberOfColumnsOrRows;Size maxSize = new Size(double.PositiveInfinity, maxHeight);foreach (var item in Children){item.Measure(maxSize);var itemWidth = item.DesiredSize.Width;var minLen = LenList[0];int minP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] < minLen){minLen = LenList[i];minP = i;}}LenList[minP] += itemWidth;}var maxLen = LenList[0];int maxP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] > maxLen){maxLen = LenList[i];maxP = i;}}return new Size(LenList[maxP], availableSize.Height);}} View Code  接下来实现我们的ArrangeOverride方法。在ArrangeOverride方法中,会接受一个可以进行布局的空间大小finalSize,在这个空间中将子元素逐个定位在合适的位置。在我们的瀑布流panel中,我们要将子元素定位成瀑布流的效果。那么如何实现瀑布流的效果呢?以纵向的情况为例,瀑布流中每个元素的宽度一致而长度不一,排成一定数量的列,每列长度虽然参差但差距不大,并列排在panel中形成瀑布的样子。我们可以将panel分成若干列,将子元素分配到这些列中按纵向扩展的顺序排布,每次分配时都挑总长最短的列,将新元素分配到这列。这样就能让各个列的长度差距不大,满足瀑布流的效果。按照这个思路,我们实现了ArrangeOverride方法:
protected override Size ArrangeOverride(Size finalSize){if (NumberOfColumnsOrRows < 1){throw (new ArgumentOutOfRangeException("NumberOfColumnsOrRows", "NumberOfColumnsOrRows must >0"));//太窄}var LenList = new List();var posXorYList = new List();if (WaterfallOrientation == Orientation.Vertical){double maxWidth = finalSize.Width / NumberOfColumnsOrRows;//列的长度和左上角的x值for (int i = 0; i < NumberOfColumnsOrRows; i++){LenList.Add(0);posXorYList.Add(i * maxWidth);}foreach (var item in Children){var itemHeight = item.DesiredSize.Height;var minLen = LenList[0];int minP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] < minLen){minLen = LenList[i];minP = i;}}item.Arrange(new Rect(posXorYList[minP], LenList[minP], item.DesiredSize.Width, item.DesiredSize.Height));LenList[minP] += item.DesiredSize.Height;}var maxLen = LenList[0];int maxP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] > maxLen){maxLen = LenList[i];maxP = i;}}return new Size(finalSize.Width, LenList[maxP]);}else{double maxHeight = finalSize.Height / NumberOfColumnsOrRows;//行的长度和左上角的y值for (int i = 0; i < NumberOfColumnsOrRows; i++){LenList.Add(0);posXorYList.Add(i * maxHeight);}foreach (var item in Children){var itemWidth = item.DesiredSize.Width;var minLen = LenList[0];int minP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] < minLen){minLen = LenList[i];minP = i;}}item.Arrange(new Rect(LenList[minP], posXorYList[minP], item.DesiredSize.Width, item.DesiredSize.Height));LenList[minP] += item.DesiredSize.Width;}var maxLen = LenList[0];int maxP = 0;for (int i = 1; i < NumberOfColumnsOrRows; i++){if (LenList[i] > maxLen){maxLen = LenList[i];maxP = i;}}return new Size(LenList[maxP], finalSize.Height);} }View Code  在MeasureOverride方法和ArrangeOverride方法实现之后,我们的瀑布流panel就可以说初步完成了。实际的运行效果和我们的淘宝UWP版中是基本一致的,只不过在淘宝UWP版的不断迭代中,我们又对一些细节做了优化。另外需要注意的是如果使用横向瀑布流,需要把WaterfallPanel所属的listview或gridview的scrollviewer相关的值进行设置:
  ScrollViewer.VerticalScrollMode="Disabled" ScrollViewer.HorizontalScrollMode="Enabled" ScrollViewer.HorizontalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollBarVisibility="Disabled"
  否则会由于listview或gridview的默认设置是纵向扩展,从而在MeasureOverride方法传入的availableSize的height是无限大,最终导致计算错误而应用崩溃。
  这样看来只要掌握了方法和思路,自定义panel也并没有想象中那么困难。小伙伴们也可以尝试创建自己独有的列表控件,如果你有一些奇思妙想的话,也欢迎分享出来。
  让我们共同进步,让UWP应用更加完善。
  在Windows10 UWP开发平台上内置的XMAL布局面板包括RelativePanel、StackPanel、Grid、VariableSizedWrapGrid 和 Canvas。在开发淘宝UWP应用时,遇到以下业务场景。
业务场景
场景一:淘宝商品提供的一些消费者保障服务  image
场景二:淘宝商品的SKU属性展示  image
实现分析  系统默认的面板容器控件显然不符合要求了。在WPF里面有WrapPanel,但是在UWP应用里面没有,这个时候就需要自定义个Panel了来实现WrapPanel的功能,实现起来不是很复杂。在MSDN的文档上已经给出了详细的实现说明:Xaml自定义面板,主要就是自定义一个Panel的派生类,然后重写(MeasureOverride 和 ArrangeOverride)方法。
  以下是MSDN上对两个方法的解释说明
MeasureOverride方法  MeasureOverride 方法有返回值,当 Measure 方法在面板上受到布局中的父元素调用时,布局系统将使用该值作为面板自身的起始 DesiredSize。方法内的逻辑选择与它返回的内容同等重要,而且逻辑经常影响返回的值。
  所有 MeasureOverride 实现应当循环访问 Children,并且对每个子元素调用 Measure 方法。调用 Measure 方法可为DesiredSize 属性创建值。这可能会通知面板本身需要多少空间,以及如何在元素间划分空间或为特定的子元素调整大小。
  以下是 MeasureOverride 方法非常基本的框架:
protected override Size MeasureOverride(Size availableSize){ Size returnSize; //TODO might return availableSize, might do something else //loop through each Child, call Measure on each foreach (UIElement child in Children) { child.Measure(new Size()); // TODO determine how much space the panel allots for this child, that's what you pass to Measure Size childDesiredSize = child.DesiredSize; //TODO determine how the returned Size is influenced by each child's DesiredSize //TODO, logic if passed-in Size and net DesiredSize are different, does that matter? } return returnSize;}ArrangeOverride方法  ArrangeOverride 方法有 Size 返回值,当 Arrange 在面板上受到布局中的父元素调用时,布局系统将在呈现面板本身时使用该值。通常输入 finalSize 和 ArrangeOverride 返回的 Size 相同。如果不相同,这意味着面板正尝试将自己调整为不同的大小,而不是布局中的其他参与者声明可用的大小。最终大小基于之前已通过面板代码运行布局的度量传递,这是通常不返回不同大小的原因:这意味着你在故意忽略度量逻辑。
  不要返回具有 Infinity 组件的 Size。尝试使用这样的 Size 将从内部布局引发异常。
  所有 ArrangeOverride 实现应当循环访问 Children,并且对每个子元素调用 Arrange 方法。和 Measure 一样,Arrange 没有返回值。与 Measure 不同,经计算的属性不会设置为结果(但是, 问题中的元素通常引发 LayoutUpdated 事件)。
  以下是 ArrangeOverride 方法非常基本的框架:
protected override Size ArrangeOverride(Size finalSize){ //loop through each Child, call Arrange on each foreach (UIElement child in Children) { Point anchorPoint = new Point(); //TODO more logic for topleft corner placement in your panel // for this child, and based on finalSize or other internal state of your panel child.Arrange(new Rect(anchorPoint, child.DesiredSize)); //OR, set a different Size } return finalSize; //OR, return a different Size, but that's rare}创建自定义Panel控件  下面用一个简单的demo演示一下,就知道这两个方法的作用了。
  首先新建一个MyPanel类继承自Panel类,将Mypanel的背景色设置成灰色,在MyPanel里面放入6个Border控件,每个Border控件设置不同的背景颜色,固定Width和Height为100或者200,方便查看各个控件的大小区域。
UI xaml: Code behind:public > MyPanel : Panel { protected override Size MeasureOverride(Size availableSize) { return base.MeasureOverride(availableSize); } protected override Size ArrangeOverride(Size finalSize) { return base.ArrangeOverride(finalSize); } }  这个时候MeasureOverride和ArrangeOverride什么都没有做,如果直接运行会是什么样子呢?
  image
  这个时候界面就是一片空白。添加的6个Border控件没有显示。Mypanel的灰色背景也没有显示,说明Mypanel的size是0,没有显示出来。
  下面来实现MeasureOverride方法,遍历每个子控件并调用Measure方法。
public > MyPanel : Panel { protected override Size MeasureOverride(Size availableSize) { foreach (FrameworkElement child in Children) { child.Measure(availableSize); } return availableSize; } protected override Size ArrangeOverride(Size finalSize) { return base.ArrangeOverride(finalSize); } }  这个时候Mypanel的面板显示出来了,背景颜色是灰色,但是子控件没有显示。
  image
  接下来,实现ArrangeOverride方法
protected override Size ArrangeOverride(Size finalSize) { double x = 0; double y = 0; foreach (FrameworkElement child in Children) { child.Arrange(new Rect(new Point(x, y), child.DesiredSize)); x += child.DesiredSize.Width; y += child.DesiredSize.Height; } return finalSize; }  运行结果:
  image
  子控件出来了,超出Page范围的会被遮住。这个时候就可以根据需要定义每个子控件的(x,y)坐标进行布局了。如果要横向布局,就递增x坐标,如果纵向布局就递增y坐标。
横向布局  递增x坐标
protected override Size ArrangeOverride(Size finalSize) { double x = 0; double y = 0; foreach (FrameworkElement child in Children) { child.Arrange(new Rect(new Point(x, y), child.DesiredSize)); x += child.DesiredSize.Width; // y += child.DesiredSize.Height; } return finalSize; }  运行结果
  image
纵向布局  递增y坐标
protected override Size ArrangeOverride(Size finalSize) { double x = 0; double y = 0; foreach (FrameworkElement child in Children) { child.Arrange(new Rect(new Point(x, y), child.DesiredSize)); //x += child.DesiredSize.Width; y += child.DesiredSize.Height; } return finalSize; }  运行结果:
  image
  接下来的问题,就是横向或者纵向布局的时候,判断何时该换行了。要换行就要计算依次排列的子控件的宽和高,同时和Mypanel的大小进行比较是否超出边界。
  这其中还有个问题是MeasureOverride和 ArrangeOverride 都会返回一个size大小。这两个大小有什么不一样吗。
  MeasureOverride 返回值:此对象在布局过程中基于其对子对象分配大小的计算或者基于固定容器大小等其他因素而确定的它所需的大小。
  ArrangeOverride 返回值:元素在布局中排列后使用的实际大小。
MeasureOverride的输入size和返回size  可以做几个实验看看实际效果:
  1. 如果给Measure传递的值较小,例如比最小的子控件还小:
protected override Size MeasureOverride(Size availableSize) { foreach (FrameworkElement child in Children) { child.Measure(new Size(50,50)); } return availableSize; }  运行结果
  image
  子控件会被裁剪部分区域,显示不完整,以适应较小的size。
  2. 如果给Measure传递的值较大,比子控件的大小要大。
protected override Size MeasureOverride(Size availableSize) { foreach (FrameworkElement child in Children) { child.Measure(new Size(400,400)); } return availableSize; }  运行结果
  image
  结果显示还是正常的大小,没有变化。
总结:  说明子控件的DesiredSize的会受到Measure传递的大小的限制,过小就会被裁剪,过大,不受影响,以实际的DesiredSize显示。Measure方法就是给控件分配一个可以显示的大小范围。
  下面看看MeasureOverride返回值,实际上和Measure的作用是一样的,给MeasureOverride方法的参数size是MyPanel的父控件给MyPanel分配的显示区域大小,实际上会受到MyPanel 的Width、Height、HorizontalAlignment、VerticalAlignment等设置的影响,这里就不展开了。
  如果子控件的大小显示区域超过了MyPanel的父控件给MyPanel分配的显示区域大小,子控件的显示区域会被裁剪。这个时候可以根据业务需要调整MeasureOverride 返回size或者调整每个子控件的Measure输入size,缩小子控件,使每个子控件都完整显示出来。
  下面看看如果设置的MeasureOverride返回值过小是什么效果
protected override Size MeasureOverride(Size availableSize) { foreach (FrameworkElement child in Children) { child.Measure(availableSize); } return new Size(150,150); }  运行结果:
  image
  灰色区域是容器的大小,各个子控件已经超出容器控件的大小范围了,MyPanel的父控件分配的大小是整个page的大小,在遍历子控件Border时分配给子控件也是page的大小显示区域,但是每个子控件都设置了Width和Height,而child.Measure(availableSize)的大小比子控件自己的size大,所以最后会显示控件实际的大小,不会受child.Measure(availableSize)的影响,但是MyPanel的MeasureOverride最后返回的时候,size被修改小了,这样整个容器就显示被修改后的大小,子控件溢出边界。
  所以在自定义容器控件的时候,MeasureOverride 方法返回的size应该是所有子控件显示区域的最小size。而计算显示区域的最小size,应该根据子控件的布局方式来判断。
ArrangeOverride的输入size和返回size  需要注意的是:通常情况下ArrangeOverride输入的size和返回的size一样,如果返回的size过小,也会遇到和MeasureOverride返回的size过小一样的显示问题。所以不建议修改ArrangeOverride的返回size,直接将输入size返回就可以了,ArrangeOverride方法主要作用是给子控件定位坐标和大小,完成布局。
  有了以上的准备,应该就知道怎么实现淘宝的业务了,主要是在水平方向上依次排列子控件,然后自动换行。
  首先在MeasureOverride里面计算子控件需要显示大小区域,在ArrangeOverride里面根据子控件的大小排列方式计算显示坐标,实现自动换行。
protected override Size ArrangeOverride(Size finalSize) { double x = 0; double y = 0; double maxHeight = 0; foreach (FrameworkElement child in Children) { if (maxHeight < child.DesiredSize.Height) { maxHeight = child.DesiredSize.Height; } if ((x + child.DesiredSize.Width) > finalSize.Width) { x = 0; y += maxHeight; maxHeight = 0; } child.Arrange(new Rect(new Point(x, y), child.DesiredSize)); x += child.DesiredSize.Width; } return finalSize; }  运行结果:
  image
  如果要实现更复杂的功能,例如要同时支持可以横向纵向排列子控件,就需要做一些封装了,这里就不一一展开了,最后附上有完整功能的WrapPanel实现代码供大家参考。可以实现横向和纵向排列。