2017 年 12 月
第 32 卷,第 12 期
通用 Windows 平台 - Windows 10 中新汉堡菜单开发人员指南
作者 Jerry Nixon
Windows XAML 团队通过 Windows 10 Fall Creators Update 发布了 NavigationView 控件。在该控件之前,负责实施汉堡菜单的开发人员仅关注 SplitView 控件的基本功能。由此产生的接口在可视化展示和行为上不一致。即使像 Groove、Xbox、新闻和 Mail 这样的第一方 Microsoft 应用也很难在整个产品组合中保持视觉显示上的一致。内部问题通常会促成外部解决方案,就像 NavigationView 那样。
该控件为 XAML 开发人员提供了一个新颖美观且一致地在各种设备上实现的视觉显示,全面支持自适应缩放、本地化、可访问性以及诸如新的 Microsoft Fluent 设计系统之类的签名 Windows 体验。该控件很棒,最终用户肯定会节省大量的工作时间,只需要反复调用控件的选择动画即可。这是非常吸引人的。图 1 显示了 NavigationView 的基本样式。
****图 1 NavigationView 的基本样式
基本信息
将 NavigationView 添加到应用很简单。通常,NavigationView 位于专用页面中,如 ShellPage.xaml。只需几行 XAML 就可以提供菜单、按钮和处理程序来响应典型的用户操作。参考以下代码中 MenuItems 的 NavigationViewItem:
<NavigationView SelectionChanged="SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItemHeader Content="Section A" />
<NavigationViewItem Content="Item 01" />
<NavigationViewItem Content="Item 02" />
</NavigationView.MenuItems>
<Frame x:Name="NavigationFrame" />
</NavigationView>
这些是主要的导航按钮。支持的其他项有 NavigationViewItemHeader 和 NavigationViewItemSeparator,开发人员可结合使用这两个项目编写美观和复杂的菜单。完成导航视图后你应该考虑以下事项。
剖析 NavigationView 各部分如图 2 所示。各个区域都构建了一个全面的 UX。“页眉”、“窗格页脚”、“自动建议”和“设置”按钮是可选的,这取决于应用程序的设计要求。
****图 2 NavigationView 部分
模式 控件有三种可能的模式:最小、紧凑和扩展,如图 3 所示。每种模式都是基于带有阈值的内置和可自定义视图自动选择的。这些模式使 NavigationView 在应用程序或设备的大小改变时保持其可用性和实用性。
****图 3 NavigationView 模式
实际问题
NavigationView 很容易理解,但并不总是很容易实现,特别是在复杂的真实世界中。每个开发人员用例可访问的控件通常需要一些清晰的编码。以下是开发人员需要识别的问题和元素概要。我将在本文稍后一一介绍这些内容。
数据绑定 就个人而言,我认为将菜单项数据绑定到顶级导航控件是很荒谬的,但是我知道并不是所有开发人员都同意这个观点。为此,从 codebehind 绑定到 NavigationView 是非常简单的。从视图模型的绑定需要破坏像引用 UI 命名空间这样的基本规则。
导航 开发人员可能会惊讶于 NavigationView 不会导航。它只是一个用于导航的视觉功能。它没有框架或不知道其菜单项应该做什么。开发人员需要解决的第一件事情就是简单的导航功能,并提供有关重新加载页面的一些逻辑。
“后退”按钮 Windows 10 提供了一个 shell 绘制的“后退”按钮,在某些情况下是可选的,但在其他情况下(例如平板电脑模式)是必需的。“后退”按钮保存了画布空间,并建立了一个统一的后退导航点。附加到通用 WinRT BackRequested 事件很简单,但同步 NavigationView 的选择是另一个要求。
“设置”按钮 NavigationView 在菜单窗格的底部提供一个默认的本地化设置按钮。这个按钮棒极了。它为普通用户操作创建了一个单一的标准调用点。为了使整个生态系统的 UX 在视觉上保持一致,这就是设计人员和开发人员应从中汲取经验和快速学习的东西。
“设置”按钮的实现简单而干净,但 NavigationView 的另一个要求不是能立即实现的。问题在于,每个 XAML 开发人员都希望声明一个控件的行为,而不是为其编码。
页眉项 NavigationView 的 MenuItem 属性接受直观上用于书档按钮的 NavigationViewItemHeader 对象;这对于为 NavigationViewItem 分区特别有用。但是打开和关闭 NavigationView 的菜单窗格会截断页眉的内容。开发人员需要能够在窄模式和宽模式下控制菜单外观和结构。
切合实际的解决方案
XAML 开发人员有几个用于解决问题的工具。从控件继承的能力使开发人员能够扩展控件行为 (bit.ly/2gQ4vN4),扩展方法增强了密封控件的基本实现 (bit.ly/2ik1rfx),附加的属性可以扩大控件的能力 (bit.ly/2giDGAn),甚至支持 XAML 中的声明。
数据绑定 从 2006 年 XAML 团队发明之初,Model-View-ViewModel (MVVM) 一直都是 XAML 开发人员的宠儿,包括 Microsoft 自己的第一方应用程序。该设计模式的一个原则是防止依赖和引用视图模型中的 UI 命名空间。这样做很明智,原因有很多。如以下代码片段所示,NavigationView 支持 NavigationViewItems 与 MenuItemsSource 属性的数据绑定,类似于 ListView.ItemsSource,但是它排除了 UI 命名空间。这在代码隐藏方面很好,但要解决视图模型的问题:
public IEnumerable<object> MenuItems
{
get
{
return new[]
{
new NavigationViewItem { Content = "Home" },
new NavigationViewItem { Content = "Reports" },
new NavigationViewItem { Content = "Calendar" },
};
}
}
为了避开在视图模型中引用 Windows.UI.Xaml.Control,我将 NavigationViewItem 抽象为一个 DTO。我为每个潜在的对等对象重复这个过程。每个项目的序号位置是视图模型的责任,应该由视图逻辑来维护。这些抽象对于视图模型来说是简单和容易提供的,如下面的代码所示:
public class NavItemEx
{
public string Icon { get; set; }
public string Text { get; set; }
}
public class NavItemHeaderEx
{
public string Text { get; set; }
}
public class NavItemSeparatorEx { }
但是,NavigationView 不知道我的自定义类,它们需要转换为适当的 NavigationView 控件进行渲染。绑定到自定义类需要在 NavigationView 中提供重要的自定义代码来强制渲染,所以我们将避免这种情况。注意:我有意避免使用自定义模板,所以我不会错误地损坏可访问性或在后续的平台版本中漏掉模板改进。为了简化转换,我引入了一个可以在我的 XAML 绑定中引用的值转换器。图 4 显示了负责获取自定义类的可枚举对象并返回 NavigationView 所需对象的代码。
图 4 NavItems 的转换器
public class INavConverter : IvalueConverter
{
public object Convert(object v, Type t, object p, string l)
{
var list = new List<object>();
foreach (var item in (v as Ienumerable<object>))
{
switch (item)
{
case NavItemEx dto:
list.Add(ToItem(dto));
break;
case NavItemHeaderEx dto:
list.Add(ToItem(dto));
break;
case NavItemSeparatorEx dto:
list.Add(ToItem(dto));
break;
}
}
return list;
}
object IvalueConverter.ConvertBack(object v, Type t, object p, string l)
throw new NotImplementedException();
NavigationViewItem ToItem(NavItemEx item)
new NavigationViewItem
{
Content = item.Text,
Icon = ToFontIcon(item.Icon),
};
FontIcon ToFontIcon(string glyph)
new FontIcon { Glyph = glyph, };
NavigationViewItemHeader ToItem(NavItemHeaderEx item)
new NavigationViewItemHeader { Content = item.Text, };
NavigationViewItemSeparator ToItem(NavItemSeparatorEx item)
new NavigationViewItemSeparator { };
}
将该转换器作为应用程序范围或页面级资源引用后,语法与其他转换器一样简单。我想再次重申我认为将数据绑定到顶级导航是多么的疯狂,但是这个可扩展的解决方案能够无缝工作,如下所示:
MenuItemsSource=”{x:Bind ViewModel.Items, Converter={StaticResource NavConverter}}”
导航 通用 Windows 平台 (UWP) 中的导航从 XAML 框架开始。但是,NavigationView 没有框架。另外,无法通过菜单按钮声明我的意向,即我希望打开的页面。这一点很容易通过如下所示的 XAML 附加属性解决:
public partial class NavProperties : DependencyObject
{
public static Type GetPageType(NavigationViewItem obj)
=> (Type)obj.GetValue(PageTypeProperty);
public static void SetPageType(NavigationViewItem obj, Type value)
=> obj.SetValue(PageTypeProperty, value);
public static readonly DependencyProperty PageTypeProperty =
DependencyProperty.RegisterAttached("PageType", typeof(Type),
typeof(NavProperties), new PropertyMetadata(null));
}
NavigationViewItem 上有了 PageType 后,我可以在 XAML 中声明目标页面,或将其绑定到我的视图模型。注意:如果我的设计需要,我可以添加额外的 Parameter 和 TransitionInfo 属性;本例重点介绍基本的导航实现。然后我让扩展的 NavigationView 处理导航,如图 5 所示。
图 5 NavViewEx - 一个扩展的 NavigationView
public class NavViewEx : NavigationView
{
Frame _frame;
public Type SettingsPageType { get; set; }
public NavViewEx()
{
Content = _frame = new Frame();
_frame.Navigated += Frame_Navigated;
ItemInvoked += NavViewEx_ItemInvoked;
SystemNavigationManager.GetForCurrentView()
.BackRequested += ShellPage_BackRequested;
}
private void NavViewEx_ItemInvoked(NavigationView sender,
NavigationViewItemInvokedEventArgs args)
{
if (args.IsSettingsInvoked)
SelectedItem = SettingsItem;
else
SelectedItem = Find(args.InvokedItem.ToString());
}
private void Frame_Navigated(object sender, NavigationEventArgs e)
=> SelectedItem = (e.SourcePageType == SettingsPageType)
? SettingsItem : Find(e.SourcePageType) ?? base.SelectedItem;
private void ShellPage_BackRequested(object sender, BackRequestedEventArgs e)
=> _frame.GoBack();
NavigationViewItem Find(string content)
=> MenuItems.OfType<NavigationViewItem>()
.SingleOrDefault(x => x.Content.Equals(content));
NavigationViewItem Find(Type type)
=> MenuItems.OfType<NavigationViewItem>()
.SingleOrDefault(x => type.Equals(x.GetValue(NavProperties.PageTypeProperty)));
public virtual void Navigate(Frame frame, Type type)
=> frame.Navigate(type);
public new object SelectedItem
{
set
{
if (value == SettingsItem)
{
Navigate(_frame, SettingsPageType);
base.SelectedItem = value;
_frame.BackStack.Clear();
}
else if (value is NavigationViewItem i && i != null)
{
Navigate(_frame, i.GetValue(NavProperties.PageTypeProperty) as Type);
base.SelectedItem = value;
_frame.BackStack.Clear();
}
UpdateBackButton();
}
}
private void UpdateBackButton()
{
SystemNavigationManager.GetForCurrentView().AppViewBackButtonVisibility =
(_frame.CanGoBack) ? AppViewBackButtonVisibility.Visible
: AppViewBackButtonVisibility.Collapsed;
}
}
查看图 5,你会注意到以下四项重要增强。****第一,在控件实例化过程中注入了一个 XAML 框架。第二,已经为 Frame.Navigated、ItemInvoked 和 BackRequested 添加处理程序。第三,已替代 SelectedItem 以添加 BackStack 和 BackButton 逻辑。第四,已将一个新的 SettingsPageType 属性添加到该类。
“后退”按钮 新的、明确的框架不仅仅是一个方便的工具,还为我提供了导航事件的来源。这一点很重要。当 NavigationView 调用导航时,我会更新 shell 绘制的“后退”按钮的可见性。但是,如果用户以另一种方式导航,没有某种事件的话,我就不知道何时更新“后退”按钮。Frame.Navigated 事件是一个很好的全局选择。
查找 NavigationView 的 ItemInvoked 事件的意外行为是,传入自定义事件参数的 InvokedItem 属性是 NavigationViewItem 的字符串内容,而不是对项目本身的对象引用。因此,此自定义控件中的 Find 方法会根据传入 ItemInvoked 的内容或传入 Frame.Navigated 事件的 PageType 查找正确的 NavigationViewItem。
值得注意的是,NavigationViewItem 的内容可以通过设备上的本地化设置动态地进行更改。如联机文档 (bit.ly/2xQodCM) 中所示,使用硬编码的 switch 语句处理 ItemInvoked 仅适用于英语使用者,或者需要将 switch 语句以指数方式进行扩展,原因是要添加语言以支持 UWP 应用程序。尽量避免在代码中的任何位置使用幻数和魔力字符串。它们与重要的代码库不兼容。
设置 “设置”按钮是下部菜单窗格中唯一参与 NavigationView 的选择逻辑的按钮。用户在调用该按钮时即导航到“设置”页面。为了简化该实现,请注意自定义的 SettingsPageType 属性,该属性保留了所需的目标页面类型的设置。已替代的 SelectedItem 资源库会测试“设置”按钮,并因此按照声明进行导航。
NavigationViewItem 的 PageType 属性或 SettingsPageType 属性中未处理的内容是一种向 Frame 的 Navigate 方法指定自定义 TransitionInfo 以强制在导航期间转换信息的方式。这对于任何应用程序来说都是一个重要的自定义功能,可以添加额外的自定义或附加属性来允许这个额外的指令。完成这项任务的代码如下所示:
<local:NavViewEx SettingsPageType="views:SettingsPage">
<NavigationView.MenuItems>
<NavigationViewItem Content="Item 01"
local:NavProperties.PageType="views:Page01" />
<NavigationViewItem Content="Item 02"
local:NavProperties.PageType="views:Page02" />
<NavigationViewItem Content="Item 03"
local:NavProperties.PageType="views:Page03" />
</NavigationView.MenuItems>
</local:NavViewEx>
这种扩展性使开发人员积极地扩展控件和类的行为,而不会改变其基本实现。它是已经存在多年的 C# 和 XAML 的一项功能,使编码语法简洁明了、使 XAML 声明清楚明白。对其他开发人员而言,这是一种很直观的方法,只需少许的指令即可转换。
起始页 加载应用程序时,最初不会调用菜单项。如下所示添加另一个附加属性,可以让我在 XAML 中声明我的意向,以便扩展的 NavigationView 可以初始化其 Frame 中的第一页。属性如下所示:
public partial class NavProperties : DependencyObject
{
public static bool GetIsStartPage(NavigationViewItem obj)
=> (bool)obj.GetValue(IsStartPageProperty);
public static void SetIsStartPage(NavigationViewItem obj, bool value)
=> obj.SetValue(IsStartPageProperty, value);
public static readonly DependencyProperty IsStartPageProperty =
DependencyProperty.RegisterAttached("IsStartPage", typeof(bool),
typeof(NavProperties), new PropertyMetadata(false));
}
在 NavigationView 中使用这个新属性,需要在具有 Start 属性集的 MenuItem 中查找 NavigationViewItem,然后在控件成功加载时导航到它。该逻辑是可选的,支持设置但并不是必需的,如下所示:
Loaded += (s, e) =>
{
if (FindStart() is NavigationViewItem i && i != null)
Navigate(_frame, i.GetValue(NavProperties.PageTypeProperty) as Type);
};
NavigationViewItem FindStart()
=> MenuItems.OfType<NavigationViewItem>()
.SingleOrDefault(x => (bool)x.GetValue(NavProperties.IsStartPageProperty));
请注意在我的 FindStart 方法中使用 LINQ SingleOrDefault 选择器,而不是它的第一个同级选择器。FirstOrDefault 返回找到的第一个对象时,如果它的谓词发现了多个对象,SingleOrDefault 将引发异常。这有助于引导、甚至强制开发人员使用该属性,因为只可声明一个初始页。
页眉如图 2 所示,NavigationView 页眉不是可选的。页面上方的这个区域用于全局内容,其固定高度为 48px。实施一个简单的标题就像这里用于将 Header 属性附加到 Page 对象的代码片段一样简单:
public partial class NavProperties : DependencyObject
{
public static string GetHeader(Page obj)
=> (string)obj.GetValue(HeaderProperty);
public static void SetHeader(Page obj, string value)
=> obj.SetValue(HeaderProperty, value);
public static readonly DependencyProperty HeaderProperty =
DependencyProperty.RegisterAttached("Header", typeof(string),
typeof(NavProperties), new PropertyMetadata(null));
}
使用 Frame 的 Navigated 事件,NavViewEx 在生成的页面中查找该属性,将可选值注入到 NavigationView 的 Header 中。新附加的 Page 属性可以限定到单独的页面,并通过 UWP x:Uid 本地化子系统进行本地化。图 6 中的代码显示了如何有效地更新页眉,仅向扩展控件引入了两行新代码。****
图 6 更新页眉
private void Frame_Navigated(object sender,
Windows.UI.Xaml.Navigation.NavigationEventArgs e)
{
SelectedItem = Find(e.SourcePageType);
UpdateHeader();
}
private void UpdateHeader()
{
if (_frame.Content is Page p
&& p.GetValue(NavProperties.HeaderProperty) is string s
&& !string.IsNullOrEmpty(s))
{
Header = s;
}
}
在这个简单的例子中,接受 Header 中的默认 TextBlock。根据我的经验,并经 Microsoft 第一方现成应用程序证实,CommandBar 控件通常会占用这块宝贵的屏幕空间。如果我想在我的应用程序中使用相同内容,可以使用以下简单的标记更新 HeaderTemplate 属性:
<NavigationView.HeaderTemplate>
<DataTemplate>
<CommandBar>
<CommandBar.Content>
<Grid Margin="12,5,0,11" VerticalAlignment="Stretch">
<TextBlock Text="{Binding}"
Style="{StaticResource TitleTextBlockStyle}"
TextWrapping="NoWrap" VerticalAlignment="Bottom"/>
</Grid>
</CommandBar.Content>
</CommandBar>
</DataTemplate>
</NavigationView.HeaderTemplate>
TextBlock 样式模仿控件的默认 Header,将其放置在全局可用的 CommandBar 中,可通过应用程序(逐页)或在全局上下文中以编程方式实现。因此,基本设计在视觉上是相同的,但其功能潜力显著扩大。
窄项目页眉问题
还有一个问题。如前文所述,NavigationView 具有不同的显示模式,这些模式因视图宽度而异。该控件也可以显式打开和关闭菜单窗格。菜单窗格打开时,其宽度由 OpenPaneLength 属性的值决定。别让我讲使用 Length 而不是 Width 的属性名称了。无论如何,下面才是重要的部分:该属性值在关闭时不会影响菜单窗格的宽度;在关闭状态下,窗格宽度被硬编码为 48px。
在这里,NavigationViewItem 看起来很棒,它们的图标设置为 48px 宽,但是 NavigationViewItemHeaders 只有一个 Content 属性,而且它在窗格打开或关闭状态下是一样的。打开状态下时很有吸引力,在关闭状态时文本被截断,如图 7 所示。
图 7 打开和(窄)关闭状态下的 NavigationViewHeader****
怎么办呢?我首先想到在页眉中添加一个图标,但是当窗格关闭时,它看起来像 NavigationViewItem,但不响应点击可能会令人沮丧。我想过使用备用文字,但在 48px 内,几乎没有三个字符的空间。当窗格关闭时,我最终着手隐藏页眉,如以下代码片段所示:
RegisterPropertyChangedCallback(IsPaneOpenProperty, IsPaneOpenChanged);
private void IsPaneOpenChanged(DependencyObject sender,
DependencyProperty dp)
{
foreach (var item in MenuItems.OfType<NavigationViewItemHeader>())
{
item.Opacity = IsPaneOpen ? 1: 0;
}
}
在这种情况下,更改其可见性可以防止列表中的项目突然移动。这不仅是最容易实现的,在视觉上也是令人愉快的,并且发生原因有些直观。由于 NavigationView 不公开已打开或关闭的事件,因此可以使用 RegisterPropertyChangedCallback(一个在 Windows 8 中引入的方便的实用工具)注册 IsPaneOpenProperty 上的依赖项属性更改。我将标识回调并切换每个页眉。如果需要,我可以通过不同的方式处理不同的页眉;本例是以相同的方式处理了所有页眉。
总结
通用 Windows 平台和 XAML 的优势是提供了很多的问题解决方案。没有一个控件能够满足所有开发人员的需求。没有一个 API 能满足所有设计的需求。带着对开发人员的关爱,只需少许代码和一点点努力,就能在丰富的平台上构建出应对问题的解决方案。它可以让你创建自己的签名体验,实现独特的价值主张,让你的应用程序在生态系统中脱颖而出。现在,即使汉堡菜单是简单地对外观进行了增添,也提供了推广到每个角落的机会。
Jerry Nixon 是一位作者、演讲者、开发者和推广专家,居住在科罗拉多州。他培训和激励世界各地的开发人员用精心制作的代码来构建更好的应用程序。在大部分的空闲时间里,Nixon 喜欢给他的三个女儿讲《星际迷航》的人物背景和故事情节。
衷心感谢以下技术专家对本文的审阅:Daren May
Daren May 是一名有着四年经验的 Windows 开发 MVP,他经营着一家开发人员培训和自定义开发公司 CustomMayd。