前言
在淘宝的收藏夹页面本地化过程中,收藏的宝贝和店铺的分类展示通过一个下拉菜单的方式进行展示。如果单独为此从头重写一个控件,那么不但费时费力,包括所有的动画方式都要全新设计,而且还容易出 bug。好在 Windows 提供了一些类似 “下拉菜单” 的控件,例如 Flyout,这个控件最常的使用场景就是弹出菜单,我们可以在这个基础上进行修改,使得其展示方式尽量贴近淘宝的下拉菜单风格。
---------这里是现实和理想的分割线---------
以上都是我脑中美好的幻想。现在欢迎来到残酷的现实世界。在这里,我将介绍我是如何一步走入 Flyout 的大坑,然后一步一步勉强爬出来的囧境。在这个坑里,我天天与 Flyout 作斗争,希望他变成我希望它变成的样子,但是它我行我素从不就范,一直坚持着自己的“控件信条”,使我伤透了脑筋。我每天徘徊在犹豫是否要“重写控件”还是“改造 Flyout ”的边缘,无比矛盾。如果你在应用中也有类似的下拉菜单的需求,而不知道该如何实现,那么这篇文章就是你所需要的。同时,希望你和和我自己,都可以避开这些曾经掉下去过人的大坑。
对手 -- Flyout
Flyout 的基础形态
Flyout 顾名思义,就是在某些事件触发下(例如点击按钮),从点击处 Fly Out 出的一个浮动于屏幕元素之上的一个“对话框”,例如在 msdn 网站对 Flyout 的介绍图是:
Flyout 的普遍应用场景是:用户点击按钮,弹出 Flyout,提示用户某些信息,用户做出选择,然后 Flyout 消失,程序执行用户选择的操作。
我们的需求
在淘宝的应用中,我们需要Flyout实现宝贝/店铺分类的下拉菜单。如果需要仿照 iOS 等其他设备上的效果图,那么这一下拉菜单的实现效果可能是这样的:
但是这毕竟是 UWP 平台,有 UWP 自己的设计语言。我们需要让这些控件的设计思路和用户使用思路统一,但同时又能体现 UWP 的独有风格。所以我们在 UWP 上最终实现这一效果,目标是既要让它与其他平台交互统一,又要让整体设计思路贴合 Windows 控件设计的风格。
所以我们最终实现的是这个样子的 Flyout:
WP端:
Win 端:
不服气的 Flyout
Flyout 毕竟不是一个轻易就范的控件。在我和他打交道的大部分时间里,他的形态是这样的:
亦或者是这样的:
所谓知己知彼百战不殆,为了搞清楚如何给 Flyout 大变身,我们要先了解 Flyout。所以我们先来看看 Flyout 的基本属性、事件和方法:
1. 事件
Closed | Occurs when the Flyout is hidden. | (Inherited from FlyoutBase) |
Opened | Occurs when the Flyout is shown. | (Inherited from FlyoutBase) |
Opening | Occurs before the Flyout is shown. | (Inherited from FlyoutBase) |
Reference: Flyout class
Flyout 有三个基本事件,分别是 Closed, Opened 和 Opening,顾名思义,分别是在 Flyout 关闭后,打开后和打开的时候触发的三个事件。
2. 主要属性
a. Content,这个是大家最熟悉的属性了,不用说,Flyout 的内容就是由这个属性承载。
b. FlyoutPresenterStyle 用于设置 Flyout 的一些基本属性,Flyout 不像我们常见的控件,不能直接通过 XAML 或C# 代码设置他的一些常用属性:例如布局相关和外观相关的属性,所以所有的这些属性都要先放置在某种样式中,然后让 Flyout 设置为这种样式,这样才能使 Flyout 变成我们所需要的样子。
c. Placement,这是 Flyout 的一个独有属性。它决定了 Flyout 弹出时的相对位置。Placement 是一个枚举类型,共提供了 Full,Right,Left,Up 和 Down五个选项。其中除了 Full 的四个选项都好理解,而 Full 在我看来是一个表里不一的选项,因为在 WP 端,它可以很好地填满屏幕,而在 Win 端或平板端,它只会固定出现在屏幕的中央,尺寸由内容或其他选项决定,完全不符合所谓 “Full” 的称号。这对于一个普通的单 Frame 的 App 来说或许还是一个仅仅能算是能接受的放置方法,但是对于淘宝这样一个界面复杂的应用来说,放在主窗口中央怎么样也不能算是一个好的选择了。毕竟淘宝的主窗口也是由多个 Frame 组成,而我们的收藏夹往往处在屏幕偏左边或右边的位置。而这个位置,仅仅是噩梦的开端。
3. 主要方法
我们主要用到的 Flyout 的方法一共有两个,一个是 Hide,一个是 ShowAt。从名字就可以看出,这是两个用来“展现”及“隐藏” Flyout 的方法。
斗争开始
1. Flyout 本身实现
最初的时候,Flyout 的主要实现框架是这样的:
1 <Button.Flyout> 2 <Flyout FlyoutPresenterStyle="{StaticResource FlyoutStyle}" Placement="Full"> 3 <ListView/> 4 </Flyout> 5 </Button.Flyout>
这样 Flyout 作为 Button 的 Flyout 出现,而 Button 就是顶部的 。这样做的好处就是省略了 Flyout 的 ShowAt 方法,只要我们点击按钮,Flyout 就会自动弹出。缺点是少了一点灵活性,Flyout 的 Placement 会变成固定以这个按钮作为相对位置锚点。
对于 WP 端,Placement 设置为 Full 以后,就可以基本达到要求:
而对于桌面端来说,根据之前的描述,Full 的显示显然不能满足要求,所以我们针对设备做了判断,当设备是桌面环境的时候,将 Placement 设置为 Bottom
if (Pages.Main.MasterPage.IsDesktopFamily == true) cf.Placement = FlyoutPlacementMode.Bottom;
我们需要 Flyout 在下方出现,但是 Flyout 在按钮下方出现的时候,会以按钮作为水平方向的中心点,从而占用了左边窗口的位置,这是我们不想看到的,如下图所示。
所以为了让 Flyout 在收藏窗口的中央显示,所以我们不能让 Flyout 再丛属于 Button,而是单独出现:
1 <FlyoutBase.AttachedFlyout> 2 <Flyout FlyoutPresenterStyle="{StaticResource FlyoutStyle}" Placement="Full" > 3 <ListView/> 4 </Flyout> 5 </FlyoutBase.AttachedFlyout>
然后在按钮点击事件中添加:cf.ShowAt(FlyoutGrid);一行代码,就可以让 Flyout 像是从属于按钮一样方便显示了。同时显示位置变换到了收藏窗口的中央。
2. ListView设计
Flyout 的框架有了,重点就转到了其中的 ListView 的设计中。抛去界面上的繁琐设计和无数次的调整不提,这里最大的复杂点在于上下层事件和参数的互相传递。如下图所示:
图中是收藏店铺的结构图,从图中可以看出:
1. 我们首先由 FavoritePage 导航到 FavoriteShopPage,这一步是比较简单的。
2. FavoriteShopPage 有两个主要功能:
a. 呼出分类 Flyout 菜单
b. 展示收藏的店铺列表。
收藏店铺的具体列表是由分类菜单的类别选项所决定的,默认是 “全部分类” 。分类 Flyout 菜单被呼出以后,需要展示具体的分类列表,而当用户点击某一类别的时候:
a. 首先要传递信息给 FavoriteShopPage,通知他们用户点击了其他的类别,需要更改数据源,改变当前显示的收藏的店铺列表;
b. 其次,我们要传递信息给分类 Flyout 菜单,通知他们改变当前显示的类别为用户点击的类别。
c. 每次分类 Flyout 菜单被呼出的时候,都还需要将当前显示的类别高亮标记为淘宝的主题色橙色,而且后面要打上对勾,表明是当前选中项。
d. 这还没完,当用户删除某个商品或店铺的时候,需要同时更改分类菜单中商品或店铺的数量,当前删除商品或店铺如果是该类别最后一个,那么删除完成后,该类别剩余数量为 0,则应自动跳转到全部分类。
这之间的互相联系和通信不可谓不复杂。为此我们主要采取的思路就是:
1) 下层向上层传递,主要通过事件的触发来传递。
2) 上层向下层传递,主要通过数据源和上下文的修改来传递。
例如对于“每次分类 Flyout 菜单被呼出的时候,需要将当前显示的类别高亮标记为淘宝的主题色橙色,同时后面要打上对勾,表明是当前选中项”这个功能,我们的视线思路如下:
- 点击时修改当前点击的类别样式。这是最容易也最直观的思路,但是会带来其他问题:例如刷新后整个数据源上下文发生变化,导致刷新后当前选中项失去高亮样式。
- 在Flyout 控件中记录上次最后点击的选项。然后在 Flyout 展开后高亮展示这一选项。但是在删除最后一个商品或店铺自动跳转分类导致分类发生变化的时候,这个方法也不能完全满足。
- 在上层页面的操作后,触发 Flyout 控件的某个方法,将当前显示的类别的 id 传过来,使 Flyout 将上次点击选项强制设置为上层操作导致变化的选项。
最后使用了这三个方法结合才使当前选中的高亮项可以正常显示。
3. 其他战役
宽度问题
在 Placement = Bottom 的情况下,Flyout 的宽度不固定,此时其宽度由内容决定。而且由于手机和 PC 屏幕尺寸的多样性,我们不能给出一个固定的尺寸。在我们的场景中,内容是列表,所以 Flyout 的宽度由列表的最大宽度决定,如果放任这种情况的话,会出现各种奇葩的形态。所以我们为了让 Flyout 在各种尺寸的窗口上都能显示出合适的宽度,并且使用户改变窗口尺寸时,Flyout 的尺寸可以随之变化,可谓绞尽脑汁。
首先,我们要使不管列表内容是什么,Flyout 宽度都和收藏窗口的宽度一致。那么我们要做两件事:1. 限定 Flyout 宽度,使其和窗口一致;2. 绑定每一列列表的宽度,使其等于 Flyout 实际宽度。
第一点比较好办,那就是点击按钮展开 Flyout 之前,设定展开菜单的宽度等于当前控件的宽度(即窗口宽度)
if (Pages.Main.MasterPage.IsDesktopFamily == true) cl.Width = uc.ActualWidth - 32;
第二点就是要使 Flyout 的内容宽度等于其容器的宽度。这一点其实不难,但我之所以在这里要再点一下,是因为关于 grid 宽度如何填充容器宽度这个问题,实在是有太多人问了,就连博主本人都会偶尔想不起来去神站 StackOverflow 搜索一下。看看这个问题的点赞数量就知道有多少人卡在过这里了:
所以只要一句简单的代码就可以代替楼主之前试过的很多绑定 ActualWidth 的冗杂代码了:
<Grid HorizontalAlignment="Stretch" Background="Transparent">
希望大家以后不要走上这条弯路。
使 Flyout 的宽度等于窗口宽度只是第一步,下一步我们需要让窗体宽度随时变化的时候,让控件的尺寸随之变化。这个任务本该由控件的 SizeChanged 事件完成,但是在实际尝试中,不管怎么改变窗口大小,这个事件都不会被触发,所以我们不得不尝试另外的弯路。
我第一次尝试的办法是触发上层页面,也就是上面结构图中的 FavoriteShopPage 的 SizeChanged 事件,然后由它触发 Flyout 的其他事件,使 Flyout 宽度适配。这个方法可以么?可行。但是不够简洁,上下级页面和控件之间的信息传递当然是能少则少,至于这种宽度适配的任务,还是尽量不要劳烦页面出手。最后我试遍了许多方法,还是发现老办法最好用。那就是在 Flyout 展开前的一瞬间,根据当前窗口的宽度进行适配:
private void favoriteFlyoutButton_Tapped(object sender, TappedRoutedEventArgs e) { if (Pages.Main.MasterPage.IsDesktopFamily == true) cl.Width = uc.ActualWidth - 32; cf.ShowAt(FlyoutGrid); }
不得不说,有的时候,最好的办法就躲在一边静静地看你出糗,而我,则像“蓦然回首,那办法却在灯火阑珊处”一般醍醐灌顶豁然开朗。
0 -> Visibility.Collapsed Converter
大家应该知道,在淘宝的收藏商品中,有一类商品叫做“失效”。由于其 API 的特殊性,我们无法像获取其他类别商品一样,获取其商品数量。所以只能显示 失效(0)。但其实实际数量并非0,所以我们希望这种无法获取数量不得不显示数量为 0 的商品类别,干脆就不显示数量,眼不见心不烦。
当然我们可以在代码里每次需要显示的时候做一个判断,如果为 0,则后半不显示。但是本着能用 XAML 绑定就不写代码的懒惰态度,我祭出了 Binding 大法。如果数量为 0,则数量不显示。由于系统没有内置 int 到 Visibility 的转换器,所以我们要先自己写一个:
1 public class IntToVisibilityConverter : IValueConverter 2 { 3 public object Convert(object value, Type targetType, object parameter, string language) 4 { 5 int quantity = System.Convert.ToInt32(value); 6 7 if (parameter == null) 8 { 9 return quantity > 0 ? Visibility.Visible : Visibility.Collapsed; 10 } 11 else 12 { 13 return quantity > 0 ? Visibility.Collapsed : Visibility.Visible; 14 } 15 } 16 17 public object ConvertBack(object value, Type targetType, object parameter, string language) 18 { 19 throw new NotImplementedException(); 20 } 21 }
然后使用这个转换器将该段文字的 Visibility 与 Count 绑定起来,就可以实现只要数字为0,就不显示数量的目的了:
<UserControl.Resources> ...... <helper:IntToVisibilityConverter x:Key="ZeroToVisibilityConverter"/> </UserControl.Resources>
<TextBlock ...... Visibility="{Binding Path=Count, Converter={StaticResource ZeroToVisibilityConverter}}"> ...... </TextBlock>
希望这段简单的实现可以帮助到不太熟悉数据绑定和转换器的同学。
4. 残寇
最后不得不说说和 Flyout 斗争期间最大的残寇。
在某些情况,我们精心调教好的 Flyout 仍会完全跑到左边显示,后来我发现了规律:当 Flyout 展开后的高度 B 略大于 可供其展示的页面高度 A 但是小于页面完整高度 C 的时候,Windows 会自作主张地无视 Flyout 的 Placement,将它放到左边显示。个人猜测是为了避免出现右侧的滚动条才这么做的。但是由于这是 Windows 自作主张的举动,我实在没有办法与其斗争,只能看着这个残寇越跑越远。
总结
写这篇文章,其实是想对自己和 Flyout 做斗争的过程做一个总结,不是为了犒劳自己,而是为了指出自己走过的那些弯路,让自己,也让读这篇文章的人们,不再走这些弯路。这,才是这篇文章最主要的目的。
参考