Edited at
XamarinDay 15

Xamarin.Forms for Windows Forms で特殊な PageRenderer の実装をしようとしてはまった話


はじめに

この記事は Xamarin Advent Calendar 2018 の 15 日目の記事です。

昨年のアドカレ記事 "Xamarin.Forms で独自の Platform を実装する" の続き的な記事です。

私は Windows Forms をターゲットにした Xamarin.Forms.Platform 実装をしています。当初はそこまでちゃんと実装するつもりはなかったのですが、方向転換をしてフル実装に近いレベルを目標に実装を進めているところです (最近はほったらかしにしていてあんまり進んでいませんが・・・) 。

今回の記事ではその実装の過程で得られたはまりポイントをまとめていきたいと思います。


Xamarin.Forms におけるコンポーネントのレイアウト調整の動作

基本的には ネイティブコンポーネントの実体化やコンポーネントのサイズ変更 をトリガーとして、自身が持つ Child Element のレイアウトを調整していく (レイアウト調整はサイズ変更も含むので必然的に再帰的に処理が行われていく) 形になります。

詳細を追っていくと面倒になってしまいますが、大ざっぱに見ると次のような感じになります。


  1. VisualElement の Layout, InvalidateMeasure メソッド等が呼ばれる。

  2. (必要であれば) 自身に設定されている Padding 値を Layout に対して加えて補正する。

  3. (必要であれば) 自身の各 Child Element にそれぞれ対して 2. で補正した Layout を元に次の処理をする。


    1. (必要であれば) Measure で大きさを計測し、結果を元に渡された Layout を補正する。

    2. Margin 値を加えて Layout メソッドを実行する (再帰的に 2. が呼ばれる)



  4. 最終的に上記までの各処理で求めた結果を元に自身の Layout を決定する。

この条件下では原則的に Xamarin.Forms.Core の抽象レイヤー内での処理となっていて計算結果も抽象レイヤーでの論理的な値になっています。 Platform 実装へはこのネイティブ値が通知される (X, Y, Width, Height の各プロパティを監視しておく) のでその値を元にネイティブコンポーネントでのレイアウト処理やレンダリングを行えばよいのですが、そうはいかないケースがあります。


抽象レイヤーでのレイアウトとネイティブとで辻妻が合わなくなる例

ここでは Windows Forms 実装での例を挙げます。


TabbedPage (TabbedPageRenderer)

181111_1.png

TabbedPageRenderer は Windows Forms の TabControl で実装しています。各ページは TabPage になります、というか TabPage である必要があります。

TabPage はタブのつまみの部分だけ実際に使えるだけの領域が狭くなり、 抽象レイヤーでのレイアウト調整結果と合わない (合わせられない) ことになります。


CarouselPage (CarouselPageRenderer)

181111_2.png

・・・CarouselPage じゃないという突っ込みは受け付けません。

CarouselPage はそれに相当する Windows Forms のコントロールは存在しないのでカスタムコントロールを作って対応しています。スワイプは (まあがんばればできるかもしれませんが) 実装できないので、左右にボタンを配置してそのボタンでページ切り替えをするようにしています。

CarouselPage も同様に左右のボタンの幅の分だけ Child Page に使える領域は狭くなっているため TabbedPage と同様にレイアウト調整の結果と合わせる事ができなくなっています。


Platform (Native) 実装から Core のレイアウト調整に介入する


IPlatform.GetNativeSize の実装

これは介入する、というかどちらかというと標準の動作ではありますが。

VisualElement が Measure を呼ばれた際、自身のサイズを求めるのに IPlatform.GetNativeSize を呼び出してネイティブでのサイズを取得します。典型的な実装 (Platform 実装はインターフェースや抽象クラスによる強制ではないのですが、どの実装でも暗黙的に同じような実装をしなくてはいけない雰囲気があります・・・) では IVisualElementRenderer.GetDesiredSize を定義し、 VisualElementRenderer に実装したものを Platform クラスの GetNativeSize から呼び出す形で実装をします。


任意のタイミングで VisualElement.Layout を呼ぶ

Page クラス (Layout を持つもの) は IPlatform.GetNativeSize を呼ばないようです。よって PageRenderer クラスなどにカスタマイズした GetDesiredSize を実装しても対応できません。

こういった場合、直接 VisualElement.Layout を呼ぶと、自身とその Child Element が指定のパラメーターに則ってレイアウト調整が行われるので、ネイティブコンポーネントの SizeChanged イベントなどをトリガーとして VisualElement.Layout を呼ぶとうまくいきます。

Control.SizeChanged += OnSizeChanged;

void OnSizeChanged(object sender, System.EventArgs e)
{
// Control のサイズと VisualElement の論理サイズが異なっているので Control のサイズに合わせる
Element.Layout(new Rectangle(0, 0, Control.Width, Control.Height));
}

基本的に SizeChanged イベントによる調整で大概は問題ないのですが、それでうまくいかない事もあったので例えば VisibleChanged で表示がされたタイミングで Layout を実行する、といった事もしています。


VisualElement に対して異なる複数の VisualElementRenderer を実装したい

TabbedPageRenderer を実装する時にかなり頭を抱えた案件。

TabbedPageRenderer は Windows Forms で実現するならまあ普通に TabControl を使うと考えます。 TabControl の各ページは TabPage というクラスである必要があります。対して Xamarin.Forms の MulitPage 系コントロールの Child はなんでも Page です。

Xamarin.Forms の Platform 実装には数少ない縛りがありまして、


  • VisualElement に対する Platform の VisualElementRenderer の実装は一つだけ

  • VisualElementRenderer はパラメーターなしでインスタンス化できる必要がある

というものがあります。 Windows Forms の場合、


  • 普通の Xamarin.Forms.Page は System.Windows.Forms.Panel に対応させる。

  • TabbedPage に持たせる Page は System.Windows.Forms.TabPage に対応させる。

と状況に応じて VisualElementRenderer の中身のネイティブコントロールを切り替えたいのですが、上記の制約のおかげで対応できません。

これは Core が持っている VisualElement と VisualElementRenderer のマッチングをするユーティリティクラス (Xamarin.Forms.Internals.Registrar) がそうなっているからで、実際はこれも自前で対応すれば制約ではなくなります。が、この仕組みは割と根幹で全ての Platform 実装がこれを使っていますし、こんなところまで独自実装はあまりしたくないところです。

ということで試行錯誤の末、次のように実装しました。


Platform.CreateRenderer を拡張する

通常、 Platform.CreateRenderer は引数に VisualElement を指定し、それと対応する VisualElementRenderer を生成します。この定義を次のようにしました。

public interface IVisualElementRenderer : IRegisterable, IDisposable

{
// その他略

IVisualElementRenderer CreateChildRenderer(VisualElement element);
}

public static IVisualElementRenderer CreateRenderer(VisualElement element, IVisualElementRenderer parent)
{
if (element == null)
throw new ArgumentNullException(nameof(element));

IVisualElementRenderer renderer = parent?.CreateChildRenderer(element);

if (renderer == null)
{
renderer = Registrar.Registered.GetHandlerForObject<IVisualElementRenderer>(element) ?? new DefaultRenderer();
}
renderer.SetElement(element);
return renderer;
}

VisualElementRenderer に自身に対する Child VisualElementRenderer を生成する機能 (デフォルトは null 返し) を定義し、 Platform.CreateRendrerer で parent を指定した場合は通常のテーブル引きではなく parent の child を生成できるようにしました。

CreateRenderer (及びその拡張メソッド版の GetOrCreateRenderer) を呼んでいるところは実際のところそんなにありません。


  • a) Platform.SetPage (画面のルートとなる Page を設定するところ)

  • b) VisualElement に対する VisualElementRenderer が生成された時 (すでにその時点で登録されていた Child Element に対する VisualElementRenderer を生成する)

  • c) VisualElement.ChildAdded イベントのタイミングで VisualElement が追加されるところ

このうち a) は一番のルートとなるので親なしです。 b) と c) は自身に対する子を生成する形なので自身が親になります。のでそのように実装します。


(Xamarin.Forms for WinForms では a) は Platform.cs 、 b) と c) は VisualElementPackager.cs に実装されています)


TabbedPage 内用の PageRenderer を実装する

通常と TabbedPage 用の宣言違いはこのような感じ。

using WForms = System.Windows.Forms;

public class PageRenderer : VisualElementRenderer<Page, WForms.Panel>
{
// 略
}

public class TabbedInternalPageRenderer : VisualElementRenderer<Page, WForms.TabPage>
{
// 略

void OnSizeChanged(object sender, System.EventArgs e)
{
Element.Layout(new Rectangle(0, 0, Control.Width, Control.Height));
}
}

※実際の実装とは若干異なっていますが、本質的なところは同じです。

どちらも Xamarin.Forms.Page を VisualElement としていますが、 Windows Forms 側 Control が異なっています。また、 AssemblyInfo.cs に記述している ExportRendererAttribute も Page に対しては PageRenderer 、となっています。

また、 TabbedInternalPageRenderer は前項で説明した "VisualElement.Layout によるレイアウト調整" を行うコードを実装しています。これは先に説明した通り、 TabPage はタブの部分だけ元々の論理サイズより実際のネイティブサイズが小さくなっているためです。この処置を入れることで論理サイズと実サイズの差異を解消しています。


TabbedPageRenderer.CreateChildRenderer を実装する

public override IVisualElementRenderer CreateChildRenderer(VisualElement element)

{
if (element is Page)
{
return new TabbedInternalPageRenderer();
}
return base.CreateChildRenderer(element);
}

指定の VisualElement が Page だったら TabbedInternalPageRenderer のインスタンスを生成します。これで Windows Forms の TabControl に登録できる VisualElementRenderer のインスタンス化ができるようになりました。


おわりに

おそらくほとんどの人には役に立たたないネタでまとめてみました。もしかしたら Xamarin.Forms の Custom Control, Custrom Renderer を実装する時に何か参考になるかもですが。

Platform 実装をする場合、情報が皆無なので既存実装を参考に進めていくことになりますが、 Windows Forms みたいに今時のモダンプラットフォームと大分異なった構造だと既存実装と同じ構造にしたくてもできなかったりするのでなかなか大変だったりします。どこまで崩していいのか、どこは合わせなくてはいけないのかを見切るのが面白いところではありますけど。

Platform 実装をする、という方向から見ていくのも勉強になると思いますので興味がありましたらやってみてください。