¡Hola!
Es posible que alguna vez, en una aplicación móvil XF, te hubiese sido interesante añadir contenido de forma dinámica a alguna pantalla. Con esto me refiero a contenido totalmente nuevo, es decir, tienes tu aplicación en el market correspondiente y quieres añadir o cambiar, por ejemplo, algún control de alguna página, pero antes de subir tu aplicación al market no sabes cómo será ese contenido. La solución obvia es cambiar el código, compilar, subir una nueva versión de la aplicación y esperar a que tus usuarios se la descarguen. Pero si has tenido algunas cosas en cuenta puedes generar y cargar XAML en caliente en alguna pantalla.
La solución que vamos a ver creo que es algo para casos puntuales, me da la sensación que si tienes que cambiar o añadir contenido nuevo muy habitualmente, y necesitas montar un sistema muy complejo para hacerlo, es posible que para resolver tu problema no necesites una aplicación móvil, necesites una web y listo…
Realmente generar contenido XAML nuevo es muy sencillo, XF nos proporciona un par de métodos de extensión para ello:
1 2 | public static TXaml LoadFromXaml<TXaml> (this TXaml view, Type callingType); public static TXaml LoadFromXaml<TXaml> (this TXaml view, string xaml); |
En nuestro caso nos interesa la segunda función, que nos permitirá crear nuestro control a partir de su definición como string. Vemos un ejemplo de cómo añadir contenido en el método OnAppearing de una página:
1 2 3 4 5 6 7 8 9 10 11 | protected override void OnAppearing() { base.OnAppearing(); var boxViewDefinition = "<BoxView" + " BackgroundColor=\"Red\"" + " VerticalOptions=\"Center\"" + " HeightRequest=\"200\"" + " HorizontalOptions=\"FillAndExpand\"/>"; var boxView = new BoxView().LoadFromXaml(boxViewDefinition); this.Content = boxView; } |
Y el resultado sería el siguiente:
A partir de aquí podemos crear cualquier contenido a través de una cadena de texto (páginas incluidas). Lo que tenemos que tener claro es que no nos da total flexibilidad, al fin y al cabo, sólo estamos definiendo la vista, no la lógica. Vamos a poder utilizar todo lo que habitualmente podemos incluir en nuestro XAML (triggers, estilos, etc) pero no la parte del code-behind. Es cierto que hay una librería llamada CSharp.Scripting para poder ejecutar código C# al vuelo, pero en mi caso no he podido hacer que funcione en Xamarin Forms (tampoco tengo claro que sea compatible). Siempre podemos complicarnos la vida y hacer alguna cosa con reflection, pero volvemos a lo que comentábamos antes, seguramente para algún caso puntual pueda ser razonable, pero si esto va a ser algo muy importante en nuestra aplicación (quizá) deberías plantearte hacer una web.
En el siguiente ejemplo vamos a ver como inicialmente tenemos tenemos en nuestra aplicación una página de login muy básica cutre y vamos a reutilizar la lógica (la viewmodel) para cargar una nueva página algo más vistosa. Esto puede darse si por algún motivo necesitamos lanzar la aplicación muy pronto sin dedicar demasiado tiempo a ciertas partes y, posteriormente, ir lanzando poco a poco contenido visualmente más atractivo hasta subir una nueva versión en el market. También podemos querer aplicar ciertos cambios de diseño a ciertos usuarios y analizar su grado de aceptación. Todo esto siempre dentro del contexto en que, no queremos subir una nueva versión de la aplicación y tenemos la aplicación algo preparada para cargar ese XAML al vuelo. Vamos a ver nuestra página actual de login:
Por un lado tenemos la vista:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | <?xml version="1.0" encoding="UTF-8"?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="DynamicXAML.LoginPage"> <ContentPage.Content> <Grid Padding="20"> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="20"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Entry Grid.Row="1" Placeholder="usuario" Text="{Binding Username}"/> <Entry Grid.Row="2" IsPassword="true" Placeholder="contraseña" Text="{Binding Password}"/> <Button Grid.Row="3" Text="Entrar" Command="{Binding LoginCommand}"/> <ActivityIndicator Grid.Row="4" IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}"/> </Grid> </ContentPage.Content> </ContentPage> |
Y por otro nuestra viewmodel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public class LoginViewModel : BaseViewModel { public ICommand LoginCommand { get; } public string Username { get; set; } public string Password { get; set; } public LoginViewModel() { this.LoginCommand = new Command(async() => await LoginAsync().ConfigureAwait(false)); } private async Task LoginAsync() { this.IsBusy = true; await Task.Delay(2500); this.IsBusy = false; await Application.Current.MainPage.DisplayAlert("XAML dinámico", "Has iniciado sesión", "OK"); } } |
Ahora deberíamos tener un sistema de creación de páginas preparado para poder crearlas a partir de strings. Podemos echar un vistazo al sistema de navegación de la aplicación SmartHotel360 de Microsoft (aquí el sistema de navegación) y añadirle algo tal que así:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | private readonly Dictionary<Type, string> customPages = new Dictionary<Type, string>(); public Page CreateAndBindPage(Type viewModelType) { var pageType = mappings[viewModelType]; string customPage = null; if (customPages.ContainsKey(pageType)) { customPage = customPages[pageType]; } Page page = customPage != null ? new ContentPage().LoadFromXaml(customPage) : Activator.CreateInstance(pageType) as Page; var viewModel = Activator.CreateInstance(viewModelType); page.BindingContext = viewModel; return page; } |
Lo podríamos elaborar un poco más, pero para el ejemplo es más que suficiente. De lo que se trata es de que, en caso de que desde nuestro backend recibamos strings con datos para reemplazar las páginas, éstas se añadirían al diccionario customPages. Vamos a pensar que desde el servidor nos llegan datos y los añadimos:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 | customPages.Add(Type.GetType("DynamicXAML.LoginPage"), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + "<ContentPage " + " xmlns=\"http://xamarin.com/schemas/2014/forms\" " + " xmlns:x=\"http://schemas.microsoft.com/winfx/2009/xaml\"" + " BackgroundColor=\"White\"" + " x:Class=\"DynamicXAML.LoginPage\">" + " " + " <ContentPage.Content>" + " <Grid>" + " <Grid.ColumnDefinitions>" + " <ColumnDefinition Width=\"30\"/>" + " <ColumnDefinition Width=\"*\"/>" + " <ColumnDefinition Width=\"30\"/>" + " </Grid.ColumnDefinitions>" + " <Grid.RowDefinitions>" + " <RowDefinition Height=\"auto\"/>" + " <RowDefinition Height=\"25\"/>" + " <RowDefinition Height=\"50\"/>" + " <RowDefinition Height=\"2\"/>" + " <RowDefinition Height=\"50\"/>" + " <RowDefinition Height=\"25\"/>" + " <RowDefinition Height=\"auto\"/>" + " <RowDefinition Height=\"*\"/>" + " </Grid.RowDefinitions>" + " " + " <Image " + " Grid.Row=\"0\"" + " Grid.ColumnSpan=\"3\"" + " HeightRequest=\"250\"" + " Aspect=\"AspectFill\"" + " HorizontalOptions=\"FillAndExpand\"" + " Source=\"https://picsum.photos/1200/800\">" + " <Image.Triggers>" + " <DataTrigger " + " TargetType=\"Image\"" + " Binding=\"{Binding IsBusy}\"" + " Value=\"true\">" + " <Setter Property=\"Opacity\" Value=\"0.75\" />" + " </DataTrigger>" + " </Image.Triggers>" + " </Image>" + " " + " <Entry " + " x:Name=\"UserEntry\"" + " Grid.Row=\"2\"" + " Grid.Column=\"1\"" + " IsEnabled=\"{Binding IsNotBusy}\"" + " Placeholder=\"usuario\"" + " Text=\"{Binding Username}\"/>" + " " + " <Entry " + " x:Name=\"PasswordEntry\"" + " Grid.Row=\"4\"" + " Grid.Column=\"1\"" + " IsPassword=\"true\"" + " IsEnabled=\"{Binding IsNotBusy}\"" + " Placeholder=\"contraseña\"" + " Text=\"{Binding Password}\"/>" + " " + " <Button " + " Grid.Row=\"6\"" + " Grid.Column=\"1\"" + " BackgroundColor=\"#22a7f0\"" + " TextColor=\"White\"" + " IsEnabled=\"{Binding IsNotBusy}\"" + " Text=\"Entrar\"" + " Command=\"{Binding LoginCommand}\"/>" + " " + " <StackLayout" + " BackgroundColor=\"White\"" + " Grid.Row=\"1\"" + " Grid.ColumnSpan=\"3\"" + " Grid.RowSpan=\"7\"" + " Spacing=\"10\"" + " IsVisible=\"{Binding IsBusy}\">" + " " + " <Label" + " Margin=\"0,50,0,0\"" + " HorizontalOptions=\"Center\"" + " Text=\"Iniciando sesión...\"" + " FontSize=\"Large\"" + " TextColor=\"Gray\"/>" + " " + " <ActivityIndicator " + " HorizontalOptions=\"Center\"" + " VerticalOptions=\"Center\"" + " IsRunning=\"{Binding IsBusy}\"/>" + " " + " </StackLayout>" + " " + " " + " </Grid>" + " </ContentPage.Content>" + " " + "</ContentPage>"); |
Ahora, al navegar a la página de Login, aunque no es ninguna maravilla, ha mejorado sustancialmente (y sin necesidad de compilar, generar y subir a la tienda).
Si seguimos dándole una vuelta más, también podríamos hacer que, tras la creación de la página, se puedan añadir o reemplazar nuevos controles al propio contenido de la página. Por ejemplo tenemos la siguiente página:
A continuación vamos a ver el XAML de la página:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | <?xml version="1.0" encoding="UTF-8"?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:ffimageloading="clr-namespace:FFImageLoading.Forms;assembly=FFImageLoading.Forms" x:Class="DynamicXAML.ProfilePage"> <ContentPage.Content> <Grid RowSpacing="0" ColumnSpacing="0"> <Grid.RowDefinitions> <RowDefinition Height="250"/> <RowDefinition Height="60"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.5*"/> <ColumnDefinition Width="0.5*"/> </Grid.ColumnDefinitions> <ffimageloading:CachedImage Grid.Row="0" Grid.ColumnSpan="2" VerticalOptions="FillAndExpand" HorizontalOptions="FillAndExpand" HeightRequest="200" Aspect="AspectFill" DownsampleToViewSize="true" Source="https://picsum.photos/458/354"/> <ffimageloading:CachedImage x:Name="ImageProfile" Grid.Row="0" Grid.ColumnSpan="2" VerticalOptions="Center" HorizontalOptions="Center" WidthRequest="80" HeightRequest="80" DownsampleToViewSize="true" Source="{Binding User.ImageUrl}"/> <StackLayout Grid.Row="0" Grid.ColumnSpan="2" VerticalOptions="End" Margin="0,0,0,30"> <Label Text="{Binding User.Name}" TextColor="White" HorizontalOptions="Center"/> </StackLayout> <StackLayout Grid.Row="1" Grid.Column="0" Spacing="0" BackgroundColor="Teal"> <Label TextColor="White" HorizontalOptions="Center" Margin="0,10,0,0" FontSize="Large" Text="{Binding User.Followers}"/> <Label TextColor="White" HorizontalOptions="Center" VerticalTextAlignment="Center" FontSize="Micro" Text="FOLLOWERS"/> </StackLayout> <StackLayout Grid.Row="1" Grid.Column="1" Spacing="0" BackgroundColor="#88d8c0"> <Label TextColor="White" HorizontalOptions="Center" Margin="0,10,0,0" FontSize="Large" Text="{Binding User.Following}"/> <Label TextColor="White" HorizontalOptions="Center" VerticalTextAlignment="Center" FontSize="Micro" Text="FOLLOWING"/> </StackLayout> <Label Grid.Row="2" Grid.ColumnSpan="2" HorizontalTextAlignment="Center" VerticalOptions="Center" FontSize="Large" Text="Blablablablabla.... aquí el resto de contenido"/> </Grid> </ContentPage.Content> </ContentPage> |
Ahora añadimos algo de código para poder modificar las páginas una vez creadas, es decir, en vez de hacer como antes (reemplazar totalmente la página) podemos crear controles al vuelo y sustituirlos o añadirlos a la página existente. Ojo, este código sólo lo he utilizado para esta entrada y seguramente no esté del todo bien (además de que los nombres…), por lo que no te recomiendo utilizarlo en producción 🙂
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | public class DynamicXAML { public string XAML { get; set; } public Type ViewType { get; set; } public ViewPosition ControlPosition { get; set; } public ViewPosition RemoveControlPosition { get; set; } public void AddInView(View view) { this.RemoveCurrentControl(view); this.InsertControl(view); } private void RemoveCurrentControl(View view) { if (this.RemoveControlPosition == null) return; var currentView = RemoveControlPosition.GetView(view); var parent = (IViewContainer<View>)currentView.Parent; parent.Children.Remove(currentView); } private void InsertControl(View view) { if (this.ControlPosition == null) return; var parent = (IViewContainer<View>)ControlPosition.GetView(view); var baseView = Activator.CreateInstance(ViewType); var control = (View)baseView.LoadFromXaml(XAML); parent.Children.Add(control); } } public abstract class ViewPosition { public abstract View GetView(View parent); } public class ViewNamePosition : ViewPosition { public string Name { get; } public ViewNamePosition(string name) { this.Name = name; } public override View GetView(View parent) { return parent.FindByName<View>(this.Name); } } public class ViewHierarchyPosition : ViewPosition { public int[] Positions { get; } public ViewHierarchyPosition(int[] positions) { this.Positions = positions; } public override View GetView(View parent) { IViewContainer<View> currentViewContainer = (IViewContainer<View>)parent; View selectedView = parent; foreach (int position in this.Positions) { selectedView = currentViewContainer.Children[position] as View; currentViewContainer = selectedView as IViewContainer<View>; } return selectedView; } } |
Al final de lo que se trata es de poder añadir o reemplazar controles. Para identificar cuál es la posición donde añadir/eliminar los controles lo podemos hacer de dos formas, o buscándola por nombre (si se lo hubiésemos dado) con ViewNamePosition o por posición dentro dentro de la lista de Children (si el contenedor implementa IViewContainer
Ahora si recibiésemos datos desde el servidor y se transformasen a objetos de tipo DynamicXAML, podríamos modificar nuestra página:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | //Se crearía la página... page = CreateAndBindPage(typeof(ProfileViewModel)); //Estos datos los recibiríamos del servidor y los transformaríamos a la lista de DynamicXAML var dynamicXamlData = new List<DynamicXAML>() { new DynamicXAML() { ControlPosition = new ViewHierarchyPosition(new int[] { 2 }), ViewType = typeof(Label), XAML = " <Label xmlns=\"http://xamarin.com/schemas/2014/forms\" xmlns:x=\"http://schemas.microsoft.com/winfx/2009/xaml\" Text=\"{Binding User.Country}\" FontSize=\"Micro\" TextColor=\"White\" FontAttributes=\"Italic\" HorizontalOptions=\"Center\"/>" }, new DynamicXAML() { ControlPosition = new ViewHierarchyPosition(new int[] { }), RemoveControlPosition = new ViewNamePosition("ImageProfile"), ViewType = typeof(CachedImage), XAML = "<ffimageloading:CachedImage xmlns=\"http://xamarin.com/schemas/2014/forms\" xmlns:x=\"http://schemas.microsoft.com/winfx/2009/xaml\" xmlns:fftransformations=\"clr-namespace:FFImageLoading.Transformations;assembly=FFImageLoading.Transformations\" xmlns:ffimageloading=\"clr-namespace:FFImageLoading.Forms;assembly=FFImageLoading.Forms\" Grid.Row=\"0\" Grid.ColumnSpan=\"2\" VerticalOptions=\"Center\" HorizontalOptions=\"Center\" WidthRequest=\"80\" HeightRequest=\"80\" DownsampleToViewSize=\"true\" Source=\"{Binding User.ImageUrl}\"><ffimageloading:CachedImage.Transformations> <fftransformations:CircleTransformation/></ffimageloading:CachedImage.Transformations></ffimageloading:CachedImage>" } }; //Aplicamos los controles al contenido de nuestra página foreach (var dynamicXAML in dynamicXamlData) { dynamicXAML.AddInView(page.Content); } |
Y el resultado sería:
Hemos hecho dos cosas. Por un lado hemos añadido una etiqueta con el país y por otro hemos eliminado la imagen de perfil existente para añadir una nueva con una transformación para hacerla circular. Es cierto que el ejemplo estaba un poco preparado; entre otras cosas el StackLayout que envolvía a la etiqueta del nombre de usuario no tenía mucho sentido, pero sólo es un ejemplo 🙂 También hay que tener en cuenta que al ir añadiendo los diferentes elementos dinámicos vamos cambiando el orden original de la lista Children donde los añadamos. Además es posible que, en ciertos casos, después de añadir un control nos interese traerlo al frente (Layout.RaiseChild), y muchas más cosas que se podrían añadir o hacer mejor.
Conclusiones
Hemos visto que es muy sencillo poder crear XAML al vuelo y nos ofrece muchísima flexibilidad para generar contenido dinámico en nuestras aplicaciones. Además, si nuestra aplicación está un poco preparada para ello, no es demasiado complejo hacer que cualquier pantalla pueda cambiar de aspecto. Con todo esto podemos conseguir hacer cambios sin necesidad de tener que subir una nueva versión de la app y esperar a que nuestros usuarios la descarguen, o aplicar esos cambios a ciertos usuarios para estudiar cómo reaccionan ante ellos, etc. Por otro lado estamos complicando nuestra aplicación, además de que es posible que tendamos a utilizar demasiado esta utilidad y perdamos un poco de vista lo que estamos haciendo, una app móvil, no una web. Obviamente también vamos a perder algo de rendimiento, la creación de los controles es algo más lento usando LoadFromXaml, aunque en las pruebas que he hecho tampoco es algo que parezca crítico.
¡Un saludo!