Xamarin.Forms でカスタム レイアウトを作成する
Xamarin.Forms は、StackLayout、AbsoluteLayout、RelativeLayout、Grid、FlexLayout の 5 つのレイアウト クラスを定義します。これらはそれぞれ、子を異なる方法で配置します。 ただし、Xamarin.Forms が提供していないレイアウトを使用してページ コンテンツを編成することが必要な場合もあります。 この記事では、カスタム レイアウト クラスを記述する方法について説明し、次に、その子をページ全体に水平方向に配置してから、後続の子の表示を追加の行に折り返す、向きを区別する WrapLayout クラスを示します。
Xamarin.Forms では、すべてのレイアウト クラスが Layout<T>
クラスから派生し、ジェネリック型を View
とその派生型に制約します。 一方 Layout<T>
クラスは、子要素の配置とサイズ変更のメカニズムを提供する Layout
クラスから派生します。
すべてのビジュアル要素は、"要求された" サイズと呼ばれる優先サイズを自己決定する必要があります。 Page
、Layout
、Layout<View>
の各派生型は、自分に対する相対的な子の位置とサイズを決定する必要があります。 このため、レイアウトには親子関係があります。親は子のサイズを決定しますが、要求された子のサイズに対応しようとします。
カスタム レイアウトを作成するには、Xamarin.Forms レイアウトと無効化のサイクルを詳細に理解する必要があります。 このサイクルについてこれから説明します。
Layout
レイアウトは、ページのビジュアル ツリーの上部から始まり、ビジュアル ツリーのすべての分岐を通って、ページ上のすべてのビジュアル要素を網羅します。 他の要素の親である要素は、自分に対する相対的な子のサイズと配置を設定する必要があります。
VisualElement
クラスは、レイアウト操作用に要素を測定する Measure
メソッドと、要素がレンダリングされる四角形の領域を指定する Layout
メソッドを定義します。 アプリケーションが起動し、最初のページが表示されると、最初の Measure
呼び出しと、次の Layout
呼び出しで構成される "レイアウト サイクル" が Page
オブジェクトで開始されます。
- レイアウト サイクル中は、すべての親要素がその子に対して
Measure
メソッドを呼び出す必要があります。 - 子を測定したら、すべての親要素がその子に対して
Layout
メソッドを呼び出す必要があります。
このサイクルにより、ページ上のすべてのビジュアル要素が Measure
メソッドと Layout
メソッドへの呼び出しを受け取ります。 このプロセスを次の図に示します。
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
メソッドへの呼び出しを受け取ると、"制約なし" または "部分的に制約あり" になります。無限の制約とは、自動サイズ設定を示すと考えることができます。
無効化
無効化とは、ページ上の要素の変更が新しいレイアウト サイクルをトリガーするプロセスです。 要素は、正しいサイズや位置を持たなくなると、無効であるとみなされます。 たとえば、Button
の FontSize
プロパティが変更されると、Button
は正しいサイズを持たなくなったために無効であるとみなされます。 Button
のサイズを変更すると、ページの残りの部分全体にレイアウトの変更が波及する可能性があります。
通常、要素のプロパティが変更されて、要素のサイズが新しくなる可能性があるときは、要素が InvalidateMeasure
メソッドを呼び出すことによって自分を無効にします。 このメソッドは MeasureInvalidated
イベントを発生させ、これを要素の親が処理して、新しいレイアウト サイクルをトリガーします。
Layout
クラスは、その Content
プロパティまたは Children
コレクションに追加されたすべての子に対して MeasureInvalidated
イベントのハンドラーを設定し、子が削除されたときにハンドラーをデタッチします。 したがって、子を持つビジュアル ツリー内のすべての要素は、その子のいずれかがサイズを変更するたびにアラートを受け取ります。 次の図は、ビジュアル ツリー内の要素のサイズ変更が、ツリーの上方へと波及する変化を発生させるようすを示しています。
ただし、Layout
クラスは、子のサイズ変更の影響を、ページのレイアウトに制約しようとします。 レイアウトのサイズに制約がある場合、子のサイズ変更は、ビジュアル ツリーの親レイアウトより上方には影響しません。 ただし、通常、レイアウトのサイズが変更されると、レイアウトの子の配置方法に影響します。 このため、レイアウトのサイズを変更すると、そのレイアウトのレイアウト サイクルが開始され、レイアウトは、その OnMeasure
メソッドと LayoutChildren
メソッドへの呼び出しを受け取ります。
Layout
クラスは、InvalidateMeasure
メソッドと同様の目的を持つ InvalidateLayout
メソッドも定義します。 InvalidateLayout
メソッドは、レイアウトで子の位置とサイズをどのように設定するかに影響を与える変更が行われるたびに呼び出す必要があります。 たとえば、レイアウトに対する子の追加や削除のたびに、Layout
クラスが InvalidateLayout
メソッドを呼び出します。
レイアウトの子による Measure
メソッドの繰り返し呼び出しを最小限に抑えるために、InvalidateLayout
をオーバーライドして、キャッシュを実装できます。 InvalidateLayout
メソッドをオーバーライドすると、レイアウトに対する子の追加や削除が行われたときに通知が発行されます。 同様に、OnChildMeasureInvalidated
メソッドをオーバーライドして、レイアウトの子の 1 つがサイズを変更したときに通知を提供できます。 どちらのメソッドのオーバーライドでも、カスタム レイアウトはそれに応答してキャッシュをクリアする必要があります。 詳細については、「レイアウト データの計算とキャッシュ」を参照してください。
カスタム レイアウトを作成する
カスタム レイアウトを作成するプロセスは次のとおりです。
Layout<View>
クラスから派生するクラスを作成します。 詳細については、「WrapLayout を作成する」を参照してください。[省略可能] レイアウト クラスで設定する必要があるパラメーターに対して、バインド可能プロパティでサポートされるプロパティを追加します。 詳細については、「バインド可能プロパティでサポートされるプロパティを追加する」を参照してください。
レイアウトのすべての子に対して
Measure
メソッドを呼び出して、レイアウトの要求されたサイズを返すように、OnMeasure
メソッドをオーバーライドします。 詳細については、「OnMeasure メソッドをオーバーライドする」を参照してください。レイアウトのすべての子に対して
Layout
メソッドを呼び出すように、LayoutChildren
メソッドをオーバーライドします。 レイアウト内のそれぞれの子に対するLayout
メソッドの呼び出しに失敗すると、子が正しいサイズや位置を受け取らないため、その子がページに表示されなくなります。 詳細については、「LayoutChildren メソッドをオーバーライドする」を参照してください。Note
OnMeasure
オーバーライドとLayoutChildren
オーバーライド内の子を列挙するときは、IsVisible
プロパティがfalse
に設定されている子をすべてスキップしてください。 これにより、非表示の子があるために、カスタム レイアウトに空白ができることを防ぎます。[省略可能] レイアウトに対する子の追加や削除時に通知を受け取るように、
InvalidateLayout
メソッドをオーバーライドします。 詳細については、「InvalidateLayout メソッドをオーバーライドする」を参照してください。[省略可能] レイアウトの子の 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
を示しています。
各行の列数は、写真のサイズ、画面の幅、デバイスに依存しない単位あたりのピクセル数によって異なります。 Image
要素は写真を非同期的に読み込むため、各 Image
要素が読み込まれた写真に基づいて新しいサイズを受け取るたびに、WrapLayout
クラスがその LayoutChildren
メソッドへの呼び出しを頻繁に受け取ります。