Diciembre de 2017
Volumen 32, número 12
Plataforma universal de Windows: guía del nuevo menú de hamburguesa de Windows 10 para desarrolladores
Por Jerry Nixon
El equipo de XAML de Windows lanzó el control NavigationView con Windows 10 Fall Creators Update. Antes de disponer de este control, los desarrolladores encargados de la implementación de un menú de hamburguesa estaban limitados a las características rudimentarias del control SplitView. Las interfaces resultantes eran incoherentes tanto en presentación visual como en comportamiento. Incluso aplicaciones originales de Microsoft, como Groove, Xbox, Noticias y Correo tenían problemas con la alineación visual en la cartera. A menudo un problema interno da lugar a una solución externa, como es el caso de NavigationView.
El control proporciona a los desarrolladores de XAML un nuevo y atractivo objeto visual, implementado de manera coherente en los dispositivos totalmente compatibles con el escalado adaptable, la localización, la accesibilidad y las experiencias de firma de Windows, como el nuevo sistema de diseño Fluent de Windows. El control resulta atractivo y los usuarios finales se ven obligados a perder interminables horas de productividad invocando la animación de selección del control una y otra vez. Es sobrecogedor. En la Figura 1 se muestra la aplicación del estilo básico NavigationView.
Figura 1 Estilo básico NavigationView
Conceptos básicos
Agregar el estilo NavigationView a una aplicación es fácil. Por lo general, NavigationView se encuentra en una página dedicada, como ShellPage.xaml. Solo unas pocas líneas de XAML proporcionan un menú, botones y controladores para responder a acciones de usuario típicas. Vea NavigationViewItem en MenuItems en el código siguiente:
<NavigationView SelectionChanged="SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItemHeader Content="Section A" />
<NavigationViewItem Content="Item 01" />
<NavigationViewItem Content="Item 02" />
</NavigationView.MenuItems>
<Frame x:Name="NavigationFrame" />
</NavigationView>
Estos son los botones de navegación principales. Otros elementos admitidos son NavigationViewItemHeader y NavigationViewItemSeparator, que, juntos, permiten a los desarrolladores componer menús atractivos y sofisticados. Existen varios aspectos que debe tener en cuenta mientras trabaja a través de la vista de navegación.
Anatomía Las partes del control NavigationView son las que se muestran en la Figura 2. Cada área forma una experiencia de usuario completa. Las opciones de encabezado, pie de página del panel y sugerencias automáticas y el botón Configuración son opcionales y dependen de los requisitos de diseño de la aplicación.
Figura 2 Partes del control NavigationView
Modos El control presenta tres modos posibles: Minimal (Mínimo), Compact (Compacto) y Extended (Expandido), como se ilustra en la Figura 3. Cada uno de ellos se selecciona automáticamente en función de una vista integrada y personalizable con umbrales. Los modos permiten que el control NavigationView siga siendo útil y práctico a medida que el tamaño de la aplicación o el dispositivo cambia.
Figura 3 Modos del control NavigationView
Problemas reales
El control NavigationView es fácil de comprender, pero no siempre de implementar, especialmente en el contexto de escenarios reales sofisticados. Los controles accesibles a los casos de uso de todos los desarrolladores suelen requerir algo de codificación lúcida. A continuación, se muestra un resumen de los problemas y los elementos que los desarrolladores deben reconocer. Los explicaré todos más adelante en este artículo.
Enlace de datos Personalmente, creo que es ridículo el enlace de datos de elementos de menú a un control de navegación de primer nivel, aunque entiendo que no todos los desarrolladores están de acuerdo. Con esta finalidad, el enlace al control NavigationView desde una clase CodeBehind es bastante simple. El enlace de un patrón vista-modelo exige la rotura de reglas cardinales, como la referencia a espacios de nombres de la interfaz de usuario.
Navegación Los desarrolladores se podrían encontrar con que el controlador NavigationView no permite la navegación. Solo es una prestación visual para la navegación. No tiene un objeto Frame ni comprende qué hacen los elementos de navegación. Lo primero que los desarrolladores tendrán que resolver es una navegación simple con alguna lógica en torno a la recarga de páginas.
Botón Back (Atrás) Windows 10 proporciona un botón Atrás dibujado por un shell, que es opcional en algunos casos y necesario en otros (como en el modo tableta). El botón Back (Atrás) guarda el estado real del lienzo y establece un punto unificado para la navegación hacia atrás. La conexión al evento BackRequested universal de WinRT es sencilla, pero la sincronización de la selección de NavigationView es otro requisito.
Botón Settings (Configuración) El control NavigationView proporciona un botón Configuración localizado predeterminado en la parte inferior del panel de menú. Es fantástico. El botón establece un solo punto de invocación estándar para una acción de usuario común. Es el tipo de cosa que los diseñadores y desarrolladores deben aprender y adoptar rápidamente por el bien de una experiencia de usuario alineada visualmente en el ecosistema.
La implementación del botón Settings (Configuración) es simple y limpia, pero es otro requisito del control NavigationView que no se entrega de manera predeterminada. Este problema yace en el deseo de cada desarrollador de XAML de declarar el comportamiento de un control, en lugar de codificarlo.
Elementos del encabezado La propiedad MenuItem del control NavigationView acepta objetos NavigationViewItemHeader usados para rodear botones visualmente; esto resulta especialmente útil para particionar elementos NavigationViewItem. No obstante, al abrir y cerrar el panel de menú del control NavigationView se trunca el contenido de un encabezado. Los desarrolladores deben poder controlar el aspecto y la estructura del menú en ambos modos estrecho y ancho.
Soluciones reales
Los desarrolladores de XAML tienen varias herramientas para resolver problemas. La herencia de un control permite a los desarrolladores extender su comportamiento (bit.ly/2gQ4vN4), los métodos de extensión mejoran la implementación básica incluso de los controles sellados (bit.ly/2ik1rfx) y las propiedades adjuntas pueden ampliar las funcionalidades de un control (bit.ly/2giDGAn), incluso la declaración admitida en XAML.
Enlace de datos Desde 2006, cuando el equipo de XAML lo inventó, el patrón modelo-vista-modelo de vista (MVVM) es el predilecto de los desarrolladores de XAML, incluidas las aplicaciones originales de Microsoft. Un principio del modelo de diseño es evitar la dependencia de los espacios de nombres de la interfaz de usuario, así como la referencia a estos, en los patrones vista-modelo. Esta es una acción inteligente por muchos motivos. Como se muestra en el fragmento de código siguiente, el control NavigationView admite el enlace de datos de los elementos NavigationViewItem a la propiedad MenuItemsSource, similar a ListView.ItemsSource, excepto en que excluye los espacios de nombres de la interfaz de usuario. Eso está bien en la clase CodeBehind, pero es un problema para resolver en los patrones vista-modelo:
public IEnumerable<object> MenuItems
{
get
{
return new[]
{
new NavigationViewItem { Content = "Home" },
new NavigationViewItem { Content = "Reports" },
new NavigationViewItem { Content = "Calendar" },
};
}
}
Para esquivar las referencias a Windows.UI.Xaml.Controls en mi patrón vista-modelo, abstraigo el elemento NavigationViewItem en un DTO. Repito este proceso para cada objeto potencial del mismo nivel. La posición ordinal de cada elemento es responsabilidad del patrón vista-modelo y la lógica de la vista debe mantenerla. Estas abstracciones son simples y fáciles de proporcionar para el patrón vista-modelo, como se muestra en el código siguiente:
public class NavItemEx
{
public string Icon { get; set; }
public string Text { get; set; }
}
public class NavItemHeaderEx
{
public string Text { get; set; }
}
public class NavItemSeparatorEx { }
No obstante, el control NavigationView no conoce mis clases personalizadas, que deben convertirse a los controles NavigationView adecuados para su representación. El enlace a clases personalizadas requiere una cantidad considerable de código personalizado en el control NavigationView para forzar la representación, por lo que lo evitaremos. Nota: Evito intencionadamente las plantillas personalizadas, a fin de no arruinar por error la accesibilidad o perder las mejoras de las plantillas en versiones posteriores de la plataforma. Para facilitar la conversión, introduzco un convertidor de valores al que puedo hacer referencia en mi enlace de XAML. En la Figura 4 se muestra el código responsable de tomar mi método Enumerable de las clases personalizadas y devolver los objetos que espera el control NavigationView.
Figura 4 Convertidor de objetos NavItem
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 { };
}
Después de hacer referencia a este convertidor como un recurso de nivel de página o de toda la aplicación, la sintaxis es tan simple como la de cualquier otro convertidor. Quiero dedicar un momento a reiterar la locura que, en mi opinión, supondría el enlace de datos de una navegación de primer nivel, aunque esta solución extensible funciona sin problemas, como se muestra a continuación:
MenuItemsSource=”{x:Bind ViewModel.Items, Converter={StaticResource NavConverter}}”
Navegación La navegación en la Plataforma universal de Windows (UWP) comienza con el objeto Frame de XAML. No obstante, el control no tiene ningún objeto Frame. Además, no existe ningún modo de declarar mi intención con un botón de menú, es decir, la página que quiero que abra. Esto se resuelve fácilmente con las propiedades adjuntas de XAML que se muestran a continuación:
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));
}
Cuando tengo PageType en el elemento NavigationViewItem, puedo declarar la página de destino en XAML o enlazarla a mi patrón vista-modelo. Nota: Podía agregar las propiedades Parameter y TransitionInfo adicionales si eran necesarias para mi diseño; esta muestra se centra en una implementación de navegación básica. Luego, dejé la navegación del controlador NavigationView extendida, como se muestra en la Figura 5.
Figura 5 NavViewEx, un control NavigationView extendido
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;
}
}
Si mira la Figura 5, observará cuatro mejoras importantes. Una, un objeto Frame de XAML se insertó durante la creación de instancias del control. Dos, se agregaron controladores para Frame.Navigated, ItemInvoked y BackRequested. Tres, se invalidó SelectedItem para agregar la lógica BackStack y BackButton. Y cuatro, se agregó una nueva propiedad SettingsPageType a la clase.
Botón Back (Atrás) El nuevo marco explícito no es solo una ventaja, ya que me proporciona el origen de los eventos de navegación. Esto es importante. Cuando el control NavigationView invoca la navegación, actualizo la visibilidad del botón Atrás dibujado por un shell. Si el usuario navega de otra manera, no puedo saber que se debe actualizar el botón Back (Atrás) sin algún tipo de evento. El evento Frame.Navigated es una excelente opción global.
Find Un comportamiento inesperado del evento ItemInvoked del control NavigationView es que la propiedad InvokedItem pasada en los argumentos del evento personalizados es el contenido de la cadena del elemento NavigationViewItem y no una referencia de objeto al propio elemento. Como resultado, los métodos Find de este control personalizado localizan el elemento NavigationViewItem correcto según el contenido pasado en ItemInvoked o PageType pasado en el evento Frame.Navigated.
Vale la pena tener en cuenta que el contenido del elemento NavigationViewItem puede cambiar de forma dinámica con la configuración de localización del dispositivo. El control de ItemInvoked mediante una instrucción switch codificada de forma rígida, como se demuestra en la documentación en línea (bit.ly/2xQodCM) solo funcionaría para hablantes de inglés o exigiría que el modificador se expandiera de manera exponencial al agregar idiomas para admitir una aplicación para UWP. Intente evitar números mágicos y cadenas mágicas en cualquier parte del código. No son compatibles con bases de código importantes.
Settings (Configuración) El botón Settings (Configuración) es el único botón del panel de menú inferior que participa en la lógica de selección del control NavigationView. Al invocarlo, los usuarios navegan a la página de configuración. Para simplificar esa implementación, observe la propiedad SettingsPageType personalizada, que contiene el tipo de página de destino deseado para la configuración. El establecedor SelectedItem invalidado prueba el botón Settings (Configuración) y, a continuación, navega según lo declarado.
Lo que no se controla en la propiedad PageType ni en la propiedad SettingsPageType del elemento NavigationViewItem es una manera de indicar la propiedad TransitionInfo personalizada en el método Navigate del objeto Frame para forzar la información de transición durante la navegación. Esta puede ser una personalización importante para cualquier aplicación y podrían agregarse propiedades personalizadas o adjuntas adicionales para permitir esta instrucción adicional. El código para hacerlo tiene el aspecto siguiente:
<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>
Este tipo de extensibilidad permite a los desarrolladores extender de manera agresiva el comportamiento de los controles y las clases sin alterar sus implementaciones fundamentales subyacentes. Es una funcionalidad de C# y XAML que existe desde hace años y que hace que la sintaxis de la codificación sea concisa y la declaración de XAML sea simple. Es un enfoque intuitivo que se traslada a otros desarrolladores claramente con instrucción escasa.
Start Page Cuando se carga una aplicación, no se invoca ningún menú inicialmente. Agregar otra propiedad adjunta, como se muestra a continuación, me permite declarar mi intención en XAML, de modo que el control NavigationView extendido pueda inicializar la primera página en su objeto Frame. La propiedad es la siguiente:
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));
}
El uso de esta nueva propiedad en el control NavigationView consiste en localizar el elemento NavigationViewItem en MenuItems con la propiedad Start establecida y, a continuación navegar a este cuando el control se haya cargado correctamente. Esta lógica es opcional (admite el valor de configuración pero no lo exige) como se muestra a continuación:
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));
Observe el uso del selector SingleOrDefault de LINQ en mi método FindStart, en lugar de su selector del mismo nivel, First. Donde FirstOrDefault devuelve el primer elemento que encuentra, SingleOrDefault lanza una excepción si se detecta más de uno por su predicado. Esto sirve para guiar e incluso para imponer el uso de la propiedad por parte de los desarrolladores, ya que siempre debe declararse una única página inicial.
Page Header Como se muestra en la Figura 2, el elemento Header del control NavigationView no es opcional. Esta área encima del objeto Page, con una altura fija de 48px, está destinada a contenido global. La implementación de un título simple es tan fácil como este fragmento, que adjuntó una propiedad Header al objeto 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));
}
Con el evento Navigated del objeto Frame, el elemento NavViewEx busca la propiedad en la página resultante e inyecta el valor opcional en el objeto Header del control NavigationView. El ámbito de la nueva propiedad Page adjunta se puede establecer en páginas individuales y localizarse a través del subsistema de localización x:Uid de UWP. El código de la Figura 6 muestra cómo la actualización del encabezado introduce efectivamente dos nuevas líneas de código únicamente en el control extendido.
Figura 6 Actualización de Header
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;
}
}
En este ejemplo, el objeto TextBlock predeterminado de Header está aceptado. En mi experiencia y corroborado por aplicaciones originales incluidas de Microsoft, un control CommandBar suele aceptar este valioso estado real de la pantalla. Si quisiera lo mismo en mi aplicación, podría actualizar la propiedad HeaderTemplate con esta simple revisión:
<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>
La aplicación de estilos de TextBlock imita el elemento Header predeterminado del control y lo coloca dentro de un elemento CommandBar disponible globalmente, que una aplicación puede implementar mediante programación en un contexto página por página o global. Como resultado, el diseño básico es el mismo visualmente, pero su potencial funcional se expande considerablemente.
Problema del encabezado Items estrecho
Existe un problema persistente. De acuerdo con lo explicado anteriormente en este artículo, el control NavigationView presenta distintos modos de visualización, que varían según el ancho de la vista. También puede abrir y cerrar de manera explícita el panel de menú. Cuando el panel de menú está abierto, el ancho viene determinado por el valor de la propiedad OpenPaneLength. No quiero empezar en este nombre de propiedad usando Longitud en lugar de Ancho. De todos modos, lo importante es lo siguiente: Ese valor de propiedad no influye en el ancho del panel de menú cuando está cerrado; cuando está cerrado, el ancho del panel está codificado de forma rígida en 48px de ancho.
Aquí, los elementos NavigationViewItem tienen un excelente aspecto con los iconos establecidos en 48px de ancho, pero los elementos NavigationViewItemHeader solo tienen una propiedad Content, que es la misma tanto si el panel está abierto como si está cerrado. El texto (atractivo cuando el panel está abierto) se trunca cuando el panel se cierra, como se muestra en la Figura 7.
Figura 7 NavigationViewHeader en los estados abierto y cerrado (estrecho)
¿Qué se debe hacer? Primero, pensé en agregar un icono en los encabezados, aunque al cerrar el panel parecería un elemento NavigationViewItem, pero con el comportamiento extraño y posiblemente frustrante de no responder a las pulsaciones. Pensé en alternar texto, pero en 48px apenas queda espacio para tres caracteres. Finalmente, termine por ocultar los encabezados al cerrar el panel, como se muestra en el fragmento de código siguiente:
RegisterPropertyChangedCallback(IsPaneOpenProperty, IsPaneOpenChanged);
private void IsPaneOpenChanged(DependencyObject sender,
DependencyProperty dp)
{
foreach (var item in MenuItems.OfType<NavigationViewItemHeader>())
{
item.Opacity = IsPaneOpen ? 1: 0;
}
}
En este caso, cambiar su visibilidad impedía cualquier movimiento repentino de los elementos de la lista. Esto no solo es lo más fácil de implementar, también es visualmente agradable y, en cierto modo, intuitivo con respecto al motivo por el que sucede. Dado que el control NavigationView no expone un evento Opened ni Closed, debe registrar un cambio de propiedad de dependencia sobre la propiedad IsPaneOpenProperty mediante RegisterPropertyChangedCallback, una práctica utilidad que se introdujo en Windows 8. Identificaré la devolución de llamada, y activaré y desactivaré cada uno de los encabezados. Si quisiera, podría tratar los distintos encabezados de maneras diferentes; en este ejemplo todos los encabezados se controlan igual.
Resumen
Lo atractivo de la Plataforma universal de Windows y XAML es la abundancia de soluciones a los problemas. Ningún control satisface las necesidades de todos los desarrolladores. Ninguna API satisface las necesidades de todos los diseños. La compilación en una completa plataforma que encanta a sus desarrolladores convierte los problemas en soluciones con solo una gota de código y un pequeño esfuerzo. Le permite crear experiencias de firma propias con propuestas de valor exclusivas, que le harán destacar en el ecosistema. Ahora, incluso el menú de hamburguesa es una simple adición de oportunidades a su apariencia para las extensiones en cada rincón.
Jerry Nixon es un autor, orador, desarrollador y gurú de Colorado. Entrena e inspira a desarrolladores de todo el mundo para compilar mejores aplicaciones con código elaborado. Nixon invierte la mayor parte de su tiempo libre explicando a sus tres hijas el trasfondo de los personajes y las tramas de los episodios de Star Trek.
Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Daren May
Daren May es MVP de Windows Development desde hace cuatro años y dirige CustomMayd, una compañía de entrenamiento de desarrolladores y desarrollo personalizado.