Crear un diseño personalizado en Xamarin.Forms

Xamarin.Forms define cinco clases de diseño: StackLayout, AbsoluteLayout, RelativeLayout, Grid y FlexLayout, y cada uno organiza sus elementos secundarios de una manera diferente. Sin embargo, a veces es necesario organizar el contenido de la página mediante un diseño que no proporciona Xamarin.Forms. En este artículo se explica cómo escribir una clase de diseño personalizada y se muestra una clase de WrapLayout que distingue la orientación y organiza sus elementos secundarios horizontalmente en la página y, a continuación, ajusta la presentación de elementos secundarios posteriores a filas adicionales.

En Xamarin.Forms, todas las clases de diseño derivan de la clase Layout<T> y restringen el tipo genérico a View y sus tipos derivados. A su vez, la clase Layout<T> deriva de la clase Layout, que proporciona el mecanismo para posicionar y cambiar el tamaño de los elementos secundarios.

Cada elemento visual es responsable de determinar su propio tamaño preferido, que se conoce como el tamaño solicitado. Los tipos derivados Page, Layout y Layout<View> son responsables de determinar la ubicación y el tamaño de sus elementos secundarios en relación con ellos mismos. Por lo tanto, el diseño implica una relación primario-secundario, en la que el elemento primario determina cuál debe ser el tamaño de sus elementos secundarios, pero intentará acomodar el tamaño solicitado del elemento secundario.

Se requiere una comprensión exhaustiva de los ciclos de diseño e invalidación de Xamarin.Forms para crear un diseño personalizado. Ahora se tratarán estos ciclos.

Layout

El diseño comienza en la parte superior del árbol visual con una página y continúa por todas sus ramas para abarcar todos los elementos visuales de una página. Los elementos que son elementos primarios de otros son responsables de dimensionar y posicionar sus elementos secundarios con respecto a ellos mismos.

La clase VisualElement define un método Measure que mide un elemento para las operaciones de diseño y un método Layout que especifica el área rectangular en la que se representará el elemento. Cuando se inicia una aplicación y se muestra la primera página, se muestra un ciclo de diseño que consta primero de llamadas Measure y, a continuación, de llamadas Layout, se inicia en el objeto Page:

  1. Durante el ciclo de diseño, todos los elementos primarios son responsables de llamar al método Measure en sus elementos secundarios.
  2. Una vez medidos los elementos secundarios, cada elemento primario es responsable de llamar al método Layout en sus elementos secundarios.

Este ciclo garantiza que todos los elementos visuales de la página reciban llamadas a los métodos Measure y Layout. El proceso se muestra en el diagrama siguiente:

Ciclo de diseño de Xamarin.Forms

Nota:

Tenga en cuenta que los ciclos de diseño también pueden producirse en un subconjunto del árbol visual si hay algún cambio que afecte al diseño. Esto incluye los elementos que se agregan o quitan de una colección como en StackLayout, un cambio en la propiedad IsVisible de un elemento o un cambio en el tamaño de un elemento.

Todas las clases Xamarin.Formsque tienen una propiedad Content o Children tienen un método reemplazable LayoutChildren. Las clases de diseño personalizadas que derivan de Layout<View> deben invalidar este método y asegurarse de que se llama a los métodos Measure y Layout en todos los elementos secundarios del elemento, para proporcionar el diseño personalizado deseado.

Además, todas las clases que derivan de Layout o Layout<View> deben invalidar el método OnMeasure, que es donde una clase de diseño determina el tamaño que necesita realizando llamadas a los métodos Measure de sus elementos secundarios.

Nota:

Los elementos determinan su tamaño en función de las restricciones, lo que indica la cantidad de espacio disponible para un elemento dentro del elemento primario del elemento. Las restricciones que pasaron a los métodos Measurey OnMeasure pueden oscilar entre o y Double.PositiveInfinity. Un elemento está restringido o totalmente restringido cuando recibe una llamada a su método Measure con argumentos no infinitos. El elemento está restringido a un tamaño determinado. Un elemento no está restringido o está parcialmente restringido cuando recibe una llamada a su método Measure con al menos un argumento igual a Double.PositiveInfinity. Se puede considerar que la restricción infinita indica el ajuste automático.

Invalidación

La invalidación es el proceso por el que un cambio en un elemento de una página desencadena un nuevo ciclo de diseño. Los elementos se consideran no válidos cuando ya no tienen el tamaño ni la posición correctos. Por ejemplo, si la propiedad FontSize de un Button cambia, se dice que Button no es válido porque ya no tendrá el tamaño correcto. El cambio de tamaño de Button puede tener un efecto ondulado de los cambios en el diseño a través del resto de una página.

Los elementos se invalidan invocando el método InvalidateMeasure, generalmente cuando cambia una propiedad del elemento que podría dar lugar a un nuevo tamaño del elemento. Este método desencadena el evento MeasureInvalidated, que el elemento primario controla para desencadenar un nuevo ciclo de diseño.

La clase Layout establece un controlador para el evento MeasureInvalidated en cada elemento secundario agregado a su propiedad Content o colección Children y desasocia el controlador cuando se quita el elemento secundario. Por lo tanto, todos los elementos del árbol visual que tienen elementos secundarios reciben alertas cada vez que uno de sus elementos secundarios cambia el tamaño. En el diagrama siguiente se muestra cómo un cambio en el tamaño de un elemento del árbol visual puede provocar cambios que ondulan el árbol:

Invalidación en el árbol visual

Sin embargo, la clase Layout intenta restringir el impacto de un cambio en el tamaño de un elemento secundario en el diseño de una página. Si el diseño está restringido, un cambio de tamaño en un elemento secundario no afecta a nada mayor que al diseño de un elemento primario en el árbol visual. Sin embargo, normalmente un cambio de tamaño de un diseño afecta a cómo el diseño organiza sus elementos secundarios. Por lo tanto, cualquier cambio en el tamaño de un diseño iniciará un ciclo de diseño para el diseño y el diseño recibirá llamadas a sus métodos OnMeasure y LayoutChildren.

La clase Layout también define un método InvalidateLayout que tiene un propósito similar al método InvalidateMeasure. Se debe invocar el método InvalidateLayout cada vez que se realice un cambio que afecte a la forma en que el diseño coloca y ajusta el tamaño de sus elementos secundarios. Por ejemplo, la clase Layout invoca el método InvalidateLayout cada vez que se agrega o quita un elemento secundario de un diseño.

El InvalidateLayout se puede invalidar para implementar una memoria caché con el fin de minimizar las invocaciones repetitivas de los métodos de Measure de los elementos secundarios del diseño. Al invalidar el método InvalidateLayout, se proporcionará una notificación de cuándo se agregan o quitan elementos secundarios del diseño. Del mismo modo, el método OnChildMeasureInvalidated se puede invalidar para proporcionar una notificación cuando uno de los elementos secundarios del diseño cambia el tamaño. En ambas invalidaciones del método, un diseño personalizado debería responder borrando la memoria caché. Para obtener más información, consulte Cálculo y almacenamiento en caché de datos de diseño.

Crear un diseño personalizado

El proceso para crear un diseño personalizado es el siguiente:

  1. Cree una clase que se derive de la clase Layout<View>. Para obtener más información, consulte Crear un WrapLayout.

  2. [opcional] Agregue propiedades, guardadas en propiedades enlazables, para los parámetros que se deben establecer en la clase de diseño. Para obtener más información, consulte Add Properties Backed by Bindable Properties (Agregar propiedades con copia en propiedades enlazables).

  3. Invalide el método OnMeasure para invocar el método Measure en todos los elementos secundarios del diseño y devuelva un tamaño solicitado para el diseño. Para obtener más información, consulte Invalidación del método OnMeasure.

  4. Invalide el método LayoutChildren para invocar el método Layout en todos los elementos secundarios del diseño. Si no se invoca el método Layout en cada elemento secundario de un diseño, el elemento secundario nunca recibirá un tamaño o una posición correctos y, por tanto, el elemento secundario no estará visible en la página. Para obtener más información, consulte Invalidación del método LayoutChildren.

    Nota:

    Al enumerar elementos secundarios en las invalidaciones OnMeasure y LayoutChildren, omita cualquier elemento secundario cuya propiedad de IsVisible esté establecida en false. Esto garantizará que el diseño personalizado no deje espacio para elementos secundarios invisibles.

  5. [opcional] Invalide el método InvalidateLayout que se va a notificar cuando se agregan o quitan elementos secundarios del diseño. Para obtener más información, consulte Invalidación del método InvalidateLayout.

  6. [opcional] Invalide el método OnChildMeasureInvalidated que se va a notificar cuando uno de los elementos secundarios del diseño cambia el tamaño. Para obtener más información, consulte Invalidación del método OnChildMeasureInvalidated.

Nota:

Tenga en cuenta que la invalidación de OnMeasure no se invocará si el tamaño del diseño se rige por su elemento primario, en lugar de por sus elementos secundarios. Sin embargo, se invocará la invalidación si una o ambas restricciones son infinitas o si la clase de diseño tiene valores de propiedad HorizontalOptions o VerticalOptions no predeterminados. Por esta razón, la invalidación de LayoutChildren no puede basarse en los tamaños secundarios obtenidos durante la llamada al método OnMeasure. En su lugar, LayoutChildren debe invocar el método Measure en los elementos secundarios del diseño, antes de invocar el método Layout. Como alternativa, el tamaño de los elementos secundarios obtenidos en la invalidación de OnMeasure se puede almacenar en caché para evitar invocaciones Measure posteriores en la invalidación LayoutChildren, pero la clase de diseño deberá saber cuándo deben obtenerse de nuevo los tamaños. Para obtener más información, consulte Cálculo y almacenamiento en caché de datos de diseño.

A continuación, la clase de diseño se puede consumir agregándola a un Page y agregando elementos secundarios al diseño. Para obtener más información, consulte Consumo de WrapLayout.

Crear un WrapLayout

La aplicación de ejemplo muestra una clase WrapLayout que diferencia la orientación y organiza sus elementos secundarios horizontalmente en la página y, a continuación, ajusta la presentación de elementos secundarios posteriores a filas adicionales.

La clase WrapLayout asigna la misma cantidad de espacio para cada elemento secundario, conocido como tamaño de celda, en función del tamaño máximo de los elementos secundarios. Los elementos secundarios menores que el tamaño de celda se pueden colocar dentro de la celda en función de sus valores de propiedad HorizontalOptions y VerticalOptions.

La definición de clase WrapLayout se muestra en el siguiente ejemplo de código:

public class WrapLayout : Layout<View>
{
  Dictionary<Size, LayoutData> layoutDataCache = new Dictionary<Size, LayoutData>();
  ...
}

Cálculo y almacenamiento en caché de datos de diseño

La estructura LayoutData almacena datos sobre una colección de elementos secundarios en una serie de propiedades:

  • VisibleChildCount: el número de elementos secundarios que están visibles en el diseño.
  • CellSize: el tamaño máximo de todos los elementos secundarios, ajustado al tamaño del diseño.
  • Rows: el número de filas.
  • Columns: el número de columnas.

El campo layoutDataCache se usa para almacenar varios valores LayoutData. Cuando se inicia la aplicación, dos objetos LayoutData se almacenarán en caché en el diccionario layoutDataCache para la orientación actual: uno para los argumentos de restricción a la invalidación de OnMeasure y otro para los argumentos width y height para la invalidación de LayoutChildren. Al girar el dispositivo en orientación horizontal, se volverán a invocar las invalidaciones de OnMeasure y LayoutChildren, lo que hará que otros dos objetos LayoutData se almacenen en caché en el diccionario. Sin embargo, al devolver el dispositivo a la orientación vertical, no se requieren más cálculos porque layoutDataCache ya tiene los datos necesarios.

En el ejemplo de código siguiente se muestra el método GetLayoutData, que calcula las propiedades del LayoutData estructurado basado en un tamaño determinado:

LayoutData GetLayoutData(double width, double height)
{
  Size size = new Size(width, height);

  // Check if cached information is available.
  if (layoutDataCache.ContainsKey(size))
  {
    return layoutDataCache[size];
  }

  int visibleChildCount = 0;
  Size maxChildSize = new Size();
  int rows = 0;
  int columns = 0;
  LayoutData layoutData = new LayoutData();

  // Enumerate through all the children.
  foreach (View child in Children)
  {
    // Skip invisible children.
    if (!child.IsVisible)
      continue;

    // Count the visible children.
    visibleChildCount++;

    // Get the child's requested size.
    SizeRequest childSizeRequest = child.Measure(Double.PositiveInfinity, Double.PositiveInfinity);

    // Accumulate the maximum child size.
    maxChildSize.Width = Math.Max(maxChildSize.Width, childSizeRequest.Request.Width);
    maxChildSize.Height = Math.Max(maxChildSize.Height, childSizeRequest.Request.Height);
  }

  if (visibleChildCount != 0)
  {
    // Calculate the number of rows and columns.
    if (Double.IsPositiveInfinity(width))
    {
      columns = visibleChildCount;
      rows = 1;
    }
    else
    {
      columns = (int)((width + ColumnSpacing) / (maxChildSize.Width + ColumnSpacing));
      columns = Math.Max(1, columns);
      rows = (visibleChildCount + columns - 1) / columns;
    }

    // Now maximize the cell size based on the layout size.
    Size cellSize = new Size();

    if (Double.IsPositiveInfinity(width))
      cellSize.Width = maxChildSize.Width;
    else
      cellSize.Width = (width - ColumnSpacing * (columns - 1)) / columns;

    if (Double.IsPositiveInfinity(height))
      cellSize.Height = maxChildSize.Height;
    else
      cellSize.Height = (height - RowSpacing * (rows - 1)) / rows;

    layoutData = new LayoutData(visibleChildCount, cellSize, rows, columns);
  }

  layoutDataCache.Add(size, layoutData);
  return layoutData;
}

El método GetLayoutData realiza las siguientes acciones:

  • Determina si un valor calculado LayoutData ya está en la memoria caché y lo devuelve si está disponible.
  • De lo contrario, enumera todos los elementos secundarios, invocando el método Measure en cada elemento secundario con un ancho y alto infinitos, y determina el tamaño máximo del elemento secundario.
  • Siempre que haya al menos un elemento secundario visible, calcula el número de filas y columnas necesarias y, a continuación, calcula el tamaño de una celda para los elementos secundarios en función de las dimensiones del WrapLayout. Tenga en cuenta que el tamaño de la celda suele ser ligeramente más ancho que el tamaño máximo del elemento secundario, pero que también podría ser más pequeño si WrapLayout no es lo suficientemente ancho para el elemento secundario más ancho o lo suficientemente alto como para el elemento secundario más alto.
  • Almacena el nuevo valor LayoutData en la memoria caché.

Agregar propiedades guardadas en propiedades enlazables

La clase WrapLayout define las propiedades ColumnSpacing y RowSpacing, cuyos valores se usan para separar las filas y columnas del diseño, y que están guardadas por propiedades enlazables. Las propiedades enlazables se muestran en el ejemplo de código siguiente:

public static readonly BindableProperty ColumnSpacingProperty = BindableProperty.Create(
  "ColumnSpacing",
  typeof(double),
  typeof(WrapLayout),
  5.0,
  propertyChanged: (bindable, oldvalue, newvalue) =>
  {
    ((WrapLayout)bindable).InvalidateLayout();
  });

public static readonly BindableProperty RowSpacingProperty = BindableProperty.Create(
  "RowSpacing",
  typeof(double),
  typeof(WrapLayout),
  5.0,
  propertyChanged: (bindable, oldvalue, newvalue) =>
  {
    ((WrapLayout)bindable).InvalidateLayout();
  });

El controlador modificado por propiedades de cada propiedad enlazable invoca la invalidación del método InvalidateLayout para desencadenar un nuevo pase de diseño en el WrapLayout. Para obtener más información, consulte Invalidación del método InvalidateLayoute Invalidación del método OnChildMeasureInvalidated.

Invalidación del método OnMeasure

La invalidación de OnMeasure se muestra en el ejemplo de código siguiente:

protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
{
  LayoutData layoutData = GetLayoutData(widthConstraint, heightConstraint);
  if (layoutData.VisibleChildCount == 0)
  {
    return new SizeRequest();
  }

  Size totalSize = new Size(layoutData.CellSize.Width * layoutData.Columns + ColumnSpacing * (layoutData.Columns - 1),
                layoutData.CellSize.Height * layoutData.Rows + RowSpacing * (layoutData.Rows - 1));
  return new SizeRequest(totalSize);
}

La invalidación invoca el método GetLayoutData y construye un objeto SizeRequest a partir de los datos devueltos, a la vez que tiene en cuenta los valores de propiedad RowSpacing y ColumnSpacing. Para obtener más información sobre el método GetLayoutData, consulte Cálculo y almacenamiento en caché de datos de diseño.

Importante

Los métodos Measure y OnMeasure nunca deben solicitar una dimensión infinita devolviendo un valor SizeRequest con una propiedad establecida en Double.PositiveInfinity. Sin embargo, al menos uno de los argumentos de restricción para OnMeasure puede ser Double.PositiveInfinity.

Invalidación del método LayoutChildren

La invalidación de LayoutChildren se muestra en el ejemplo de código siguiente:

protected override void LayoutChildren(double x, double y, double width, double height)
{
  LayoutData layoutData = GetLayoutData(width, height);

  if (layoutData.VisibleChildCount == 0)
  {
    return;
  }

  double xChild = x;
  double yChild = y;
  int row = 0;
  int column = 0;

  foreach (View child in Children)
  {
    if (!child.IsVisible)
    {
      continue;
    }

    LayoutChildIntoBoundingRegion(child, new Rectangle(new Point(xChild, yChild), layoutData.CellSize));
    if (++column == layoutData.Columns)
    {
      column = 0;
      row++;
      xChild = x;
      yChild += RowSpacing + layoutData.CellSize.Height;
    }
    else
    {
      xChild += ColumnSpacing + layoutData.CellSize.Width;
    }
  }
}

La invalidación comienza con una llamada al método GetLayoutData y, a continuación, enumera todos los elementos secundarios para ajustar el tamaño y colocarlos dentro de la celda de cada elemento secundario. Esto se logra invocando el método LayoutChildIntoBoundingRegion, que se usa para colocar un elemento secundario dentro de un rectángulo en función de sus valores de propiedad HorizontalOptions y VerticalOptions. Esto equivale a realizar una llamada al método Layout del elemento secundario.

Nota:

Tenga en cuenta que el rectángulo pasado al método LayoutChildIntoBoundingRegion incluye todo el área en la que puede residir el elemento secundario.

Para obtener más información sobre el método GetLayoutData, consulte Cálculo y almacenamiento en caché de datos de diseño.

Invalidación del método InvalidateLayout

La invalidación de InvalidateLayout se invoca cuando se agregan o quitan elementos secundarios del diseño, o cuando una de las propiedades WrapLayout cambia el valor, como se muestra en el ejemplo de código siguiente:

protected override void InvalidateLayout()
{
  base.InvalidateLayout();
  layoutInfoCache.Clear();
}

La invalidación invalida el diseño y descarta toda la información de diseño almacenada en caché.

Nota:

Para detener la clase Layout invocando el método InvalidateLayout cada vez que se agrega o quita un elemento secundario de un diseño, invalide los métodos ShouldInvalidateOnChildAdded y ShouldInvalidateOnChildRemoved y devuelva false. Después, la clase de diseño puede implementar un proceso personalizado cuando se agregan o quitan elementos secundarios.

Invalidación del método OnChildMeasureInvalidated

La invalidación de OnChildMeasureInvalidated se invoca cuando uno de los elementos secundarios del diseño cambia el tamaño y se muestra en el ejemplo de código siguiente:

protected override void OnChildMeasureInvalidated()
{
  base.OnChildMeasureInvalidated();
  layoutInfoCache.Clear();
}

La invalidación invalida el diseño del elemento secundario y descarta toda la información de diseño almacenada en caché.

Consumo de WrapLayout

La clase WrapLayout se puede consumir colocándola en un tipo derivado dePage, como se muestra en el siguiente ejemplo de código XAML:

<ContentPage ... xmlns:local="clr-namespace:ImageWrapLayout">
    <ScrollView Margin="0,20,0,20">
        <local:WrapLayout x:Name="wrapLayout" />
    </ScrollView>
</ContentPage>

El código de C# equivalente se muestra a continuación:

public class ImageWrapLayoutPageCS : ContentPage
{
  WrapLayout wrapLayout;

  public ImageWrapLayoutPageCS()
  {
    wrapLayout = new WrapLayout();

    Content = new ScrollView
    {
      Margin = new Thickness(0, 20, 0, 20),
      Content = wrapLayout
    };
  }
  ...
}

A continuación, se pueden agregar elementos secundarios a WrapLayout según sea necesario. En el ejemplo de código siguiente se muestran elementos Image que se agregan al WrapLayout:

protected override async void OnAppearing()
{
    base.OnAppearing();

    var images = await GetImageListAsync();
    if (images != null)
    {
        foreach (var photo in images.Photos)
        {
            var image = new Image
            {
                Source = ImageSource.FromUri(new Uri(photo))
            };
            wrapLayout.Children.Add(image);
        }
    }
}

async Task<ImageList> GetImageListAsync()
{
    try
    {
        string requestUri = "https://raw.githubusercontent.com/xamarin/docs-archive/master/Images/stock/small/stock.json";
        string result = await _client.GetStringAsync(requestUri);
        return JsonConvert.DeserializeObject<ImageList>(result);
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"\tERROR: {ex.Message}");
    }

    return null;
}

Cuando aparece la página que contiene el WrapLayout, la aplicación de ejemplo accede de forma asincrónica a un archivo JSON remoto que contiene una lista de fotos, crea un elemento Image para cada foto y lo agrega a WrapLayout. El resultado es el aspecto que se muestra en las capturas de pantalla siguientes:

Capturas de pantalla verticales de aplicación de ejemplo

Las capturas de pantalla siguientes muestran el WrapLayout una vez que se ha girado a la orientación horizontal:

Captura de pantalla del panorama de aplicaciones iOS de ejemploCaptura de pantalla del panorama de aplicaciones Android de ejemploCaptura de pantalla del panorama de aplicaciones UWP de ejemplo

El número de columnas de cada fila depende del tamaño de la foto, el ancho de la pantalla y el número de píxeles por unidad independiente del dispositivo. Los elementos Image cargan asincrónicamente las fotos y, por tanto, la clase WrapLayout recibirá llamadas frecuentes a su método LayoutChildren, ya que cada elemento Image recibe un nuevo tamaño basado en la foto cargada.