Xamarin.Forms でカスタム レイアウトを作成する

Xamarin.Forms は、StackLayout、AbsoluteLayout、RelativeLayout、Grid、FlexLayout の 5 つのレイアウト クラスを定義します。これらはそれぞれ、子を異なる方法で配置します。 ただし、Xamarin.Forms が提供していないレイアウトを使用してページ コンテンツを編成することが必要な場合もあります。 この記事では、カスタム レイアウト クラスを記述する方法について説明し、次に、その子をページ全体に水平方向に配置してから、後続の子の表示を追加の行に折り返す、向きを区別する WrapLayout クラスを示します。

Xamarin.Forms では、すべてのレイアウト クラスが Layout<T> クラスから派生し、ジェネリック型を View とその派生型に制約します。 一方 Layout<T> クラスは、子要素の配置とサイズ変更のメカニズムを提供する Layout クラスから派生します。

すべてのビジュアル要素は、"要求された" サイズと呼ばれる優先サイズを自己決定する必要があります。 PageLayoutLayout<View> の各派生型は、自分に対する相対的な子の位置とサイズを決定する必要があります。 このため、レイアウトには親子関係があります。親は子のサイズを決定しますが、要求された子のサイズに対応しようとします。

カスタム レイアウトを作成するには、Xamarin.Forms レイアウトと無効化のサイクルを詳細に理解する必要があります。 このサイクルについてこれから説明します。

Layout

レイアウトは、ページのビジュアル ツリーの上部から始まり、ビジュアル ツリーのすべての分岐を通って、ページ上のすべてのビジュアル要素を網羅します。 他の要素の親である要素は、自分に対する相対的な子のサイズと配置を設定する必要があります。

VisualElement クラスは、レイアウト操作用に要素を測定する Measure メソッドと、要素がレンダリングされる四角形の領域を指定する Layout メソッドを定義します。 アプリケーションが起動し、最初のページが表示されると、最初の Measure 呼び出しと、次の Layout 呼び出しで構成される "レイアウト サイクル" が Page オブジェクトで開始されます。

  1. レイアウト サイクル中は、すべての親要素がその子に対して Measure メソッドを呼び出す必要があります。
  2. 子を測定したら、すべての親要素がその子に対して Layout メソッドを呼び出す必要があります。

このサイクルにより、ページ上のすべてのビジュアル要素が Measure メソッドと Layout メソッドへの呼び出しを受け取ります。 このプロセスを次の図に示します。

Xamarin.Forms レイアウト サイクル

Note

レイアウト サイクルは、レイアウトに影響する変更があると、ビジュアル ツリーのサブセットでも発生する可能性があります。 これには、StackLayout などのコレクションに対する項目の追加や削除、要素の IsVisible プロパティの変更、要素のサイズの変更などがあります。

Content プロパティまたは Children プロパティを持つすべての Xamarin.Forms クラスには、オーバーライド可能な LayoutChildren メソッドがあります。 Layout<View> から派生するカスタム レイアウト クラスは、要素のすべての子に対して Measure メソッドと Layout メソッドが確実に呼び出されるように、このメソッドをオーバーライドして、目的のカスタム レイアウトを実現する必要があります。

さらに、Layout または Layout<View> から派生するすべてのクラスが OnMeasure メソッドをオーバーライドする必要があります。このメソッドでは、レイアウト クラスがその子の Measure メソッドを呼び出すことで、自分に必要なサイズを判断します。

Note

要素は、要素に使用できる要素の親内の領域の量を示す "制約" に基づいてサイズを決定します。 Measure メソッドと OnMeasure メソッドに渡される制約の範囲は 0 から Double.PositiveInfinity です。 要素は、無限でない引数が指定された Measure メソッドへの呼び出しを受け取ると、"制約あり" または "全面的に制約あり" になります。つまり要素は特定のサイズに制約されます。 要素は、少なくとも 1 つの引数が Double.PositiveInfinity と等しい値に設定された Measure メソッドへの呼び出しを受け取ると、"制約なし" または "部分的に制約あり" になります。無限の制約とは、自動サイズ設定を示すと考えることができます。

無効化

無効化とは、ページ上の要素の変更が新しいレイアウト サイクルをトリガーするプロセスです。 要素は、正しいサイズや位置を持たなくなると、無効であるとみなされます。 たとえば、ButtonFontSize プロパティが変更されると、Button は正しいサイズを持たなくなったために無効であるとみなされます。 Button のサイズを変更すると、ページの残りの部分全体にレイアウトの変更が波及する可能性があります。

通常、要素のプロパティが変更されて、要素のサイズが新しくなる可能性があるときは、要素が InvalidateMeasure メソッドを呼び出すことによって自分を無効にします。 このメソッドは MeasureInvalidated イベントを発生させ、これを要素の親が処理して、新しいレイアウト サイクルをトリガーします。

Layout クラスは、その Content プロパティまたは Children コレクションに追加されたすべての子に対して MeasureInvalidated イベントのハンドラーを設定し、子が削除されたときにハンドラーをデタッチします。 したがって、子を持つビジュアル ツリー内のすべての要素は、その子のいずれかがサイズを変更するたびにアラートを受け取ります。 次の図は、ビジュアル ツリー内の要素のサイズ変更が、ツリーの上方へと波及する変化を発生させるようすを示しています。

ビジュアル ツリーでの無効化

ただし、Layout クラスは、子のサイズ変更の影響を、ページのレイアウトに制約しようとします。 レイアウトのサイズに制約がある場合、子のサイズ変更は、ビジュアル ツリーの親レイアウトより上方には影響しません。 ただし、通常、レイアウトのサイズが変更されると、レイアウトの子の配置方法に影響します。 このため、レイアウトのサイズを変更すると、そのレイアウトのレイアウト サイクルが開始され、レイアウトは、その OnMeasure メソッドと LayoutChildren メソッドへの呼び出しを受け取ります。

Layout クラスは、InvalidateMeasure メソッドと同様の目的を持つ InvalidateLayout メソッドも定義します。 InvalidateLayout メソッドは、レイアウトで子の位置とサイズをどのように設定するかに影響を与える変更が行われるたびに呼び出す必要があります。 たとえば、レイアウトに対する子の追加や削除のたびに、Layout クラスが InvalidateLayout メソッドを呼び出します。

レイアウトの子による Measure メソッドの繰り返し呼び出しを最小限に抑えるために、InvalidateLayout をオーバーライドして、キャッシュを実装できます。 InvalidateLayout メソッドをオーバーライドすると、レイアウトに対する子の追加や削除が行われたときに通知が発行されます。 同様に、OnChildMeasureInvalidated メソッドをオーバーライドして、レイアウトの子の 1 つがサイズを変更したときに通知を提供できます。 どちらのメソッドのオーバーライドでも、カスタム レイアウトはそれに応答してキャッシュをクリアする必要があります。 詳細については、「レイアウト データの計算とキャッシュ」を参照してください。

カスタム レイアウトを作成する

カスタム レイアウトを作成するプロセスは次のとおりです。

  1. Layout<View> クラスから派生するクラスを作成します。 詳細については、「WrapLayout を作成する」を参照してください。

  2. [省略可能] レイアウト クラスで設定する必要があるパラメーターに対して、バインド可能プロパティでサポートされるプロパティを追加します。 詳細については、「バインド可能プロパティでサポートされるプロパティを追加する」を参照してください。

  3. レイアウトのすべての子に対して Measure メソッドを呼び出して、レイアウトの要求されたサイズを返すように、OnMeasure メソッドをオーバーライドします。 詳細については、「OnMeasure メソッドをオーバーライドする」を参照してください。

  4. レイアウトのすべての子に対して Layout メソッドを呼び出すように、LayoutChildren メソッドをオーバーライドします。 レイアウト内のそれぞれの子に対する Layout メソッドの呼び出しに失敗すると、子が正しいサイズや位置を受け取らないため、その子がページに表示されなくなります。 詳細については、「LayoutChildren メソッドをオーバーライドする」を参照してください。

    Note

    OnMeasure オーバーライドと LayoutChildren オーバーライド内の子を列挙するときは、IsVisible プロパティが false に設定されている子をすべてスキップしてください。 これにより、非表示の子があるために、カスタム レイアウトに空白ができることを防ぎます。

  5. [省略可能] レイアウトに対する子の追加や削除時に通知を受け取るように、InvalidateLayout メソッドをオーバーライドします。 詳細については、「InvalidateLayout メソッドをオーバーライドする」を参照してください。

  6. [省略可能] レイアウトの子の 1 つがサイズを変更したときに通知を受け取るように、OnChildMeasureInvalidated メソッドをオーバーライドします。 詳細については、「OnChildMeasureInvalidated メソッドをオーバーライドする」を参照してください。

Note

レイアウトのサイズが子ではなく親によって管理されている場合、OnMeasure オーバーライドは呼び出されません。 ただし、制約の一方または両方が無限である場合、またはレイアウト クラスに既定値以外の HorizontalOptions プロパティ値または VerticalOptions プロパティ値がある場合は、オーバーライドが呼び出されます。 このため、LayoutChildren オーバーライドは、OnMeasure のメソッド呼び出し中に取得した子のサイズに依存できません。 代わりに、LayoutChildren は、Layout メソッドを呼び出す前に、レイアウトの子に対して Measure メソッドを呼び出す必要があります。 別の方法として、OnMeasure オーバーライドで取得した子のサイズをキャッシュして、後で LayoutChildren オーバーライドで Measure を呼び出すことを回避できますが、レイアウト クラスがサイズを再度取得する必要があるタイミングを把握することが必要になります。 詳細については、「レイアウト データの計算とキャッシュ」を参照してください。

これで、レイアウト クラスを Page に追加し、そのレイアウトに子を追加して、レイアウト クラスを使用できます。 詳細については、「WrapLayout を使用する」を参照してください。

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 値を保存するために使用されます。 アプリケーションが起動すると、2 つの LayoutData オブジェクトが現在の向きの layoutDataCache ディクショナリにキャッシュされます。1 つは OnMeasure オーバーライドの制約引数用、1 つは LayoutChildren オーバーライドの height 引数と width 引数用です。 デバイスを横向きに回転させると、OnMeasure オーバーライドと LayoutChildren オーバーライドが再度呼び出され、別の 2 つの LayoutData オブジェクトがディクショナリにキャッシュされます。 ただし、デバイスを縦向きに戻しても、layoutDataCache には必要なデータが既に存在するため、それ以上の計算は必要ありません。

次のコード例は、特定のサイズに基づいて LayoutData 構造化のプロパティを計算する GetLayoutData メソッドを示しています。

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 メソッドを呼び出して、最大子サイズを決定します。
  • 表示される子が少なくとも 1 つある場合は、必要な行と列の数を計算し、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 メソッドと OnMeasure メソッドでは、プロパティが Double.PositiveInfinity に設定された SizeRequest 値を返して、無限寸法を要求しないようにする必要があります。 ただし、OnMeasure に対する制約引数の少なくとも 1 つは 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 メソッドを呼び出すのと同じです。

Note

LayoutChildIntoBoundingRegion メソッドに渡される四角形には、子が存在できる領域全体が含まれていることに注意してください。

GetLayoutData メソッドの詳細については、「レイアウト データの計算とキャッシュ」を参照してください。

InvalidateLayout メソッドをオーバーライドする

InvalidateLayout オーバーライドは、次のコード例に示すように、レイアウトに対して子の追加や削除が行われたとき、または WrapLayout プロパティのいずれかが値を変更したときに呼び出されます。

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

このオーバーライドは、レイアウトを無効にし、キャッシュされたすべてのレイアウト情報を破棄します。

Note

Layout クラスが、レイアウトに対する子の追加や削除のたびに InvalidateLayout メソッドを呼び出すことを停止するには、ShouldInvalidateOnChildAdded メソッドと ShouldInvalidateOnChildRemoved メソッドをオーバーライドして false を返します。 これで、子が追加または削除されたときのカスタム プロセスをレイアウト クラスが実装できるようになります。

OnChildMeasureInvalidated メソッドをオーバーライドする

OnChildMeasureInvalidated オーバーライドは、次のコード例に示すように、レイアウトの子の 1 つがサイズを変更したときに呼び出されます。

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

このオーバーライドは、子のレイアウトを無効にし、キャッシュされたすべてのレイアウト情報を破棄します。

WrapLayout を使用する

WrapLayout クラスは、次の XAML コード例に示すように、Page 派生型に配置することで使用できます。

<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 に追加できます。 次のコード例は、WrapLayout に追加される Image 要素を示しています。

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 要素は写真を非同期的に読み込むため、各 Image 要素が読み込まれた写真に基づいて新しいサイズを受け取るたびに、WrapLayout クラスがその LayoutChildren メソッドへの呼び出しを頻繁に受け取ります。