前言
在淘宝的收藏夹页面本地化过程中,收藏的宝贝和店铺的分类展示通过一个下拉菜单的方式进行展示。如果单独为此从头重写一个控件,那么不但费时费力,包括所有的动画方式都要全新设计,而且还容易出 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 菜单被呼出的时候,需要将当前显示的类别高亮标记为淘宝的主题色橙色,同时后面要打上对勾,表明是当前选中项”这个功能,我们的视线思路如下:
最后使用了这三个方法结合才使当前选中的高亮项可以正常显示。
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 做斗争的过程做一个总结,不是为了犒劳自己,而是为了指出自己走过的那些弯路,让自己,也让读这篇文章的人们,不再走这些弯路。这,才是这篇文章最主要的目的。
参考
Flyout class
How to get controls in WPF to fill available space?
Advanced Flyouts for UWP (Windows 10)