Создание пользовательского макета в Xamarin.Forms

Xamarin.Forms определяет пять классов макета — StackLayout, AbsoluteLayout, RelativeLayout, Grid и FlexLayout, а каждый — по-другому. Однако иногда необходимо упорядочить содержимое страницы с помощью макета, который не предоставляется Xamarin.Forms. В этой статье объясняется, как написать пользовательский класс макета и продемонстрировать класс WrapLayout с учетом ориентации, который упорядочивает дочерние элементы по горизонтали на странице, а затем преобразует отображение последующих дочерних элементов в дополнительные строки.

Во Xamarin.Formsвсех классах макета наследуют класс Layout<T> и ограничивают универсальный тип View и производные типы. В свою очередь, Layout<T> класс является производным от Layout класса, который предоставляет механизм для размещения и изменения размера дочерних элементов.

Каждый визуальный элемент отвечает за определение собственного предпочтительного размера, который называется запрошенным размером. Page, Layoutи Layout<View> производные типы отвечают за определение расположения и размера своего дочернего или дочернего объекта относительно себя. Таким образом, макет включает отношение "родительский-дочерний", где родитель определяет, какой размер его дочерних элементов должен быть, но будет пытаться вместить запрошенный размер дочернего элемента.

Для создания пользовательского макета требуется тщательное понимание Xamarin.Forms циклов макета и недопустимости. Теперь эти циклы будут обсуждаться.

Макет

Макет начинается в верхней части визуального дерева со страницей, и он проходит через все ветви визуального дерева, чтобы охватывать каждый визуальный элемент на странице. Элементы, которые являются родителями для других элементов, отвечают за изменение размера и размещение своих детей относительно себя.

Класс VisualElement определяет Measure метод, который измеряет элемент для операций макета, и Layout метод, указывающий прямоугольную область, в которую будет отображаться элемент. При запуске приложения и отображении первой страницы цикл макета, состоящий из первых Measure вызовов, а затем Layout вызовы начинаются с Page объекта:

  1. Во время цикла макета каждый родительский элемент отвечает за вызов Measure метода в дочерних элементах.
  2. После измерения дочерних элементов каждый родительский элемент отвечает за вызов Layout метода на дочерних элементах.

Этот цикл гарантирует, что каждый визуальный элемент на странице получает вызовы Measure и Layout методы. Процесс показан на следующей схеме:

Xamarin.Forms Цикл макета

Примечание.

Обратите внимание, что циклы макета также могут возникать в подмножестве визуального дерева, если что-то изменится на макет. К ним относятся элементы, добавляемые или удаленные из коллекции, например в StackLayoutколлекции, изменение IsVisible свойства элемента или изменение размера элемента.

Каждый Xamarin.Forms класс, имеющий Content или Children свойство, имеет переопределимый LayoutChildren метод. Пользовательские классы макетов, производные от Layout<View> этого метода, должны переопределить этот метод и убедиться, что MeasureLayout методы вызываются для всех дочерних элементов, чтобы предоставить нужный пользовательский макет.

Кроме того, каждый класс, производный от Layout метода или Layout<View> должен переопределить OnMeasure метод, который определяет размер класса макета, который он должен быть, вызывая Measure методы своих дочерних элементов.

Примечание.

Элементы определяют их размер на основе ограничений, указывающих, сколько пространства доступно для элемента в родительском элементе. Ограничения, передаваемые Measure в методы, OnMeasure могут варьироваться от 0 до Double.PositiveInfinity. Элемент ограничен или полностью ограничен, когда он получает вызов метода Measure с неиграничными аргументами - элемент ограничен определенным размером. Элемент не ограничен или частично ограничен, когда он получает вызов метода Measure по крайней мере с одним аргументом, равным Double.PositiveInfinity - бесконечное ограничение может рассматриваться как показывание автосвязи.

Недействительность

Недопустимое — это процесс, с помощью которого изменение элемента на странице активирует новый цикл макета. Элементы считаются недопустимыми, если они больше не имеют правильного размера или положения. Например, если FontSize свойство Button изменения, считается недопустимым, Button так как он больше не будет иметь правильный размер. Изменение Button размера может иметь эффект рябь изменений в макете через остальную часть страницы.

Элементы недействительна путем вызова InvalidateMeasure метода, как правило, при изменении свойства элемента, который может привести к новому размеру элемента. Этот метод запускает MeasureInvalidated событие, которое родительский дескриптор элемента запускает новый цикл макета.

Класс Layout задает обработчик события для MeasureInvalidated каждого дочернего элемента, добавленного в его Content свойство или Children коллекцию, и отсоединяет обработчик при удалении дочернего элемента. Таким образом, каждый элемент в визуальном дереве с дочерними элементами оповещается всякий раз, когда один из дочерних элементов изменяет размер. На следующей схеме показано, как изменение размера элемента в визуальном дереве может привести к изменениям, которые рябь вверх по дереву:

Недопустимое значение в визуальном дереве

Layout Однако класс пытается ограничить влияние изменения размера дочернего элемента на макет страницы. Если макет ограничен, изменение размера дочернего элемента не влияет ни на что большее, чем родительский макет в визуальном дереве. Однако обычно изменение размера макета влияет на то, как макет упорядочивает его дочерние элементы. Таким образом, любое изменение размера макета начнет цикл макета для макета, и макет получит вызовы его OnMeasure и LayoutChildren методов.

Класс Layout также определяет InvalidateLayout метод, имеющий аналогичную цель InvalidateMeasure метода. Метод InvalidateLayout должен вызываться всякий раз, когда изменения влияют на то, как позиции макета и размеры его дочерних элементов. Например, класс вызывает InvalidateLayout метод всякий раз, Layout когда дочерний элемент добавляется или удаляется из макета.

Можно InvalidateLayout переопределить для реализации кэша, чтобы свести к минимуму повторяющиеся вызовы Measure методов дочерних элементов макета. Переопределение InvalidateLayout метода предоставит уведомление о добавлении или удалении дочерних элементов из макета. Аналогичным образом OnChildMeasureInvalidated метод можно переопределить, чтобы предоставить уведомление при изменении одного из дочерних размеров макета. Для переопределения обоих методов настраиваемый макет должен реагировать путем очистки кэша. Дополнительные сведения см. в разделе "Вычисление и кэширование данных макета".

Создание пользовательского макета

Процесс создания пользовательского макета выглядит следующим образом:

  1. Создайте класс, производный от класса Layout<View>. Дополнительные сведения см. в разделе "Создание оболочки".

  2. [необязательно] Добавьте свойства, поддерживаемые привязываемыми свойствами, для всех параметров, которые должны быть заданы в классе макета. Дополнительные сведения см. в разделе "Добавление свойств, поддерживаемых привязываемыми свойствами".

  3. Переопределите OnMeasure метод, чтобы вызвать Measure метод на всех дочерних элементах макета и вернуть запрошенный размер макета. Дополнительные сведения см. в разделе "Переопределение метода OnMeasure".

  4. Переопределите LayoutChildren метод для вызова Layout метода во всех дочерних элементах макета. Сбой Layout вызова метода для каждого дочернего элемента в макете приведет к тому, что дочерний объект никогда не получает правильный размер или положение, поэтому дочерний элемент не станет видимым на странице. Дополнительные сведения см. в разделе "Переопределение метода LayoutChildren".

    Примечание.

    При перечислении дочерних элементов в OnMeasure и LayoutChildren переопределении пропустите любой дочерний элемент, для которого IsVisible задано falseсвойство. Это обеспечит, чтобы пользовательский макет не оставлял место для невидимых дочерних элементов.

  5. [необязательно] Переопределите InvalidateLayout метод, который следует уведомлять при добавлении или удалении дочерних элементов из макета. Дополнительные сведения см. в разделе "Переопределение метода InvalidateLayout".

  6. [необязательно] Переопределите OnChildMeasureInvalidated метод, чтобы получать уведомления при изменении размера одного из дочерних элементов макета. Дополнительные сведения см. в разделе "Переопределение метода OnChildMeasureInvalidated".

Примечание.

Обратите внимание, что OnMeasure переопределение не будет вызываться, если размер макета регулируется родительским элементом, а не его дочерними элементами. Однако переопределение будет вызываться, если одно или оба ограничения являются бесконечными, или если класс макета имеет значения свойств, отличных от по умолчанию HorizontalOptions или VerticalOptions свойств. По этой причине переопределение не может полагаться на дочерние размеры, LayoutChildren полученные во время OnMeasure вызова метода. Вместо этого LayoutChildren необходимо вызвать Measure метод в дочерних элементах макета перед вызовом Layout метода. Кроме того, размер дочерних элементов, полученных в OnMeasure переопределении, можно кэшировать, чтобы избежать последующих Measure вызовов в LayoutChildren переопределении, но класс макета должен знать, когда размеры должны быть получены снова. Дополнительные сведения см. в разделе "Вычисление и кэширование данных макета".

Затем класс макета можно использовать, добавив его в Pageмакет, и добавив дочерние элементы в макет. Дополнительные сведения см. в разделе "Использование WrapLayout".

Создание оболочки

Пример приложения демонстрирует класс с учетом WrapLayout ориентации, который упорядочивает дочерние элементы по горизонтали на странице, а затем упаковывает отображение последующих дочерних элементов в дополнительные строки.

Класс WrapLayout выделяет одинаковое количество места для каждого дочернего элемента, известного как размер ячейки, на основе максимального размера дочерних элементов. Дочерние элементы меньше, чем размер ячейки, можно разместить в ячейке на основе их HorizontalOptions и VerticalOptions значений свойств.

Определение WrapLayout класса показано в следующем примере кода:

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

Вычисление и кэширование данных макета

Структура LayoutData хранит данные о коллекции дочерних элементов в ряде свойств:

  • VisibleChildCount — число дочерних элементов, видимых в макете.
  • CellSize — максимальный размер всех дочерних элементов, отрегулированных по размеру макета.
  • Rows — количество строк.
  • Columns — количество столбцов.

Поле layoutDataCache используется для хранения нескольких LayoutData значений. При запуске приложения два LayoutData объекта будут кэшироваться в layoutDataCache словарь для текущей ориентации — один для аргументов OnMeasure ограничения переопределения, а один — для width переопределения и height аргументов LayoutChildren переопределения. При повороте устройства в альбомную ориентацию OnMeasure переопределение и LayoutChildren переопределение снова вызывается, что приведет к кэшированию еще двух LayoutData объектов в словарь. Однако при возвращении устройства в книжную ориентацию дальнейшие вычисления не требуются, так как layoutDataCache у него уже есть необходимые данные.

В следующем примере кода показан GetLayoutData метод, который вычисляет свойства структурированного на основе определенного LayoutData размера:

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;
}

Метод GetLayoutData выполняет следующие операции:

  • Он определяет, уже ли вычисляемое LayoutData значение находится в кэше и возвращает его, если оно доступно.
  • В противном случае он перечисляет все дочерние элементы, вызывая Measure метод для каждого дочернего элемента с бесконечной шириной и высотой, и определяет максимальный размер дочернего элемента.
  • При условии, что есть хотя бы один видимый дочерний элемент, он вычисляет количество строк и столбцов, необходимых, а затем вычисляет размер ячейки для дочерних элементов на основе измерений WrapLayout. Обратите внимание, что размер ячейки обычно немного шире, чем максимальный размер ребенка, но он также может быть меньше, если WrapLayout недостаточно широкий для самого широкого ребенка или достаточно высокого для самого высокого ребенка.
  • Он сохраняет новое LayoutData значение в кэше.

Добавление свойств, поддерживаемых привязываемыми свойствами

Класс WrapLayout определяет ColumnSpacing и RowSpacing свойства, значения которых используются для разделения строк и столбцов в макете и которые поддерживаются привязываемыми свойствами. Свойства, привязываемые, показаны в следующем примере кода:

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();
  });

Обработчик, измененный свойством каждого привязываемого свойства, вызывает InvalidateLayout переопределение метода для активации нового прохода макета WrapLayout. Дополнительные сведения см. в разделе "Переопределение метода InvalidateLayout" и переопределение метода OnChildMeasureInvalidated.

Переопределите метод OnMeasure

Переопределение OnMeasure показано в следующем примере кода:

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);
}

Переопределение вызывает GetLayoutData метод и создает SizeRequest объект из возвращаемых данных, а также учитывает RowSpacing значения свойств и ColumnSpacing значений свойств. Дополнительные сведения о методе см. в разделе "Вычисление GetLayoutData и кэширование данных макета".

Внимание

Методы Measure никогда не должны запрашивать бесконечное измерение, возвращая SizeRequest значение с заданным свойствомDouble.PositiveInfinity.OnMeasure Однако хотя бы один из аргументов OnMeasure ограничения может быть Double.PositiveInfinity.

Переопределите метод LayoutChildren

Переопределение LayoutChildren показано в следующем примере кода:

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;
    }
  }
}

Переопределение начинается с вызова GetLayoutData метода, а затем перечисляет все дочерние элементы для их размера и размещения в ячейке каждого дочернего элемента. Это достигается путем вызова LayoutChildIntoBoundingRegion метода, который используется для размещения дочернего объекта в прямоугольнике на основе его HorizontalOptions и VerticalOptions значений свойств. Это эквивалентно вызову метода дочернего Layout объекта.

Примечание.

Обратите внимание, что прямоугольник, переданный LayoutChildIntoBoundingRegion методу, включает всю область, в которой может находиться дочерний элемент.

Дополнительные сведения о методе см. в разделе "Вычисление GetLayoutData и кэширование данных макета".

Переопределите метод InvalidateLayout

InvalidateLayout Переопределение вызывается при добавлении или удалении дочерних элементов из макета или при изменении одного из WrapLayout значений свойств, как показано в следующем примере кода:

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

Переопределение отменяет макет и отменяет карта все сведения о кэшированном макете.

Примечание.

Чтобы остановить вызов класса InvalidateLayout при каждом добавлении или удалении дочернего элемента из макета, переопределении ShouldInvalidateOnChildAdded методов ShouldInvalidateOnChildRemoved и возвратеfalse.Layout Затем класс макета может реализовать пользовательский процесс при добавлении или удалении дочерних элементов.

Переопределите метод OnChildMeasureInvalidated

Переопределение OnChildMeasureInvalidated вызывается при изменении одного из дочерних размеров макета и отображается в следующем примере кода:

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

Переопределение делает недопустимым дочерний макет и не карта все сведения о кэшированном макете.

Использование wrapLayout

Класс WrapLayout можно использовать, поместив его в производный Page тип, как показано в следующем примере кода XAML:

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

Ниже приведен эквивалентный код на C#:

public class ImageWrapLayoutPageCS : ContentPage
{
  WrapLayout wrapLayout;

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

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

Затем дочерние элементы можно добавить в необходимые WrapLayout элементы. В следующем примере кода показаны Image элементы, добавляемые в 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;
}

Когда откроется страница WrapLayout , пример приложения асинхронно обращается к удаленному JSON-файлу, содержаму список фотографий, создает Image элемент для каждой фотографии и добавляет его в него WrapLayout. Результат показан на следующих снимках экрана:

Примеры снимков экрана с портретными приложениями

На следующих снимках WrapLayout экрана показана после поворота на альбомную ориентацию:

Пример ландшафтного снимка приложения iOSПример ландшафтного снимка приложения AndroidПример ландшафтного снимка приложения UWP

Количество столбцов в каждой строке зависит от размера фотографии, ширины экрана и количества пикселей на устройство независимо от устройства. Элементы Image асинхронно загружают фотографии, поэтому WrapLayout класс будет получать частые вызовы к его LayoutChildren методу, так как каждый Image элемент получает новый размер на основе загруженной фотографии.