Xamarin Formsでカスタムレイアウトを作ってみる

Xamarin Formsでは Grid / StackLayout / RelativeLayout / AbsoluteLayout などいくつものLayoutが用意されています。
これら以外に、開発者が独自に カスタムレイアウト を作成する事が出来ます。

という事で、カスタムレイアウトの勉強として「ShuffleLayout」というものを作ってみました。
仕様としては「追加された子要素(子View)をシャッフルして配置する。」レイアウトです。
以下のような画面での利用を想定しています。

f:id:daigo-knowlbo:20170205161658p:plain

上記は、出題された英単語の日本語訳を選択肢から答える画面になっています。
「選択肢」を表示している赤枠の部分が、今回作成する「ShuffleLayout」です。
選択肢は Button として ShuffleLayout の子要素として登録します。
ShuffleLayout は登録された選択肢子要素(Button)をランダムにシャッフルして表示します。

ShuffleLayout の実装

まず実装をシンプルにするため、以下のような仕様とします。

  • 子要素は配置処理の度にランダムでシャッフルする
  • 子要素は必ず縦に並べる
  • 子要素の幅(Width)は画面いっぱいのサイズとする

実装は以下の通り。

// ShuffleLayout.cs
using System;
using System.Linq;
using System.Collections.Generic;

using Xamarin.Forms;

namespace App1
{
  /// <summary>
  /// シャッフルレイアウトです。
  /// </summary>
  public class ShuffleLayout : Layout<View>
  {
    /// <summary>
    /// コンストラクタです。
    /// </summary>
    public ShuffleLayout() : base()
    {
    }

    /// <summary>
    /// Method that is called when a layout measurement happens.
    /// </summary>
    /// <param name="widthConstraint"></param>
    /// <param name="heightConstraint"></param>
    /// <returns></returns>
    protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
    {
      double lastY;
      var layout = NaiveLayout(widthConstraint, heightConstraint, out lastY);

      return new SizeRequest(new Size(widthConstraint, lastY));
    }

    /// <summary>
    /// Positions and sizes the children of a Layout.
    /// </summary>
    /// <param name="x"></param>
    /// <param name="y"></param>
    /// <param name="width"></param>
    /// <param name="height"></param>
    /// <remarks>
    /// Implementors wishing to change the default behavior of a Layout should override this method.
    /// It is suggested to still call the base method and modify its calculated results.
    /// </remarks>
    protected override void LayoutChildren(double x, double y, double width, double height)
    {
      double lastY;
      List<Tuple<View, Rectangle>> layout = NaiveLayout(width, height, out lastY);

      foreach (var t in layout)
      {
        var location = new Rectangle(t.Item2.X + x, t.Item2.Y + y, t.Item2.Width, t.Item2.Height);
        t.Item1.Layout(location);
      }
    }

    /// <summary>
    /// 子要素のレイアウトを行います。
    /// 子要素を配置する場所を決定し、場所をRectangleで表します。
    /// </summary>
    /// <param name="width"></param>
    /// <param name="height"></param>
    /// <param name="lastY"></param>
    /// <returns></returns>
    private List<Tuple<View, Rectangle>> NaiveLayout(double width, double height, out double lastY)
    {
      var result = new List<Tuple<View, Rectangle>>();

      double startY = 0;
      double right = width;
      double nextY = 0;

      lastY = 0;

      // シャッフル実行
      List<View> shuffleChildren = new List<View>(); 
      shuffleChildren.AddRange(Children.OrderBy(i => Guid.NewGuid()).ToArray());

      foreach (var child in shuffleChildren)
      {
        SizeRequest sizeRequest = child.Measure(double.PositiveInfinity, double.PositiveInfinity);

        var paddedHeight = sizeRequest.Request.Height;

        startY += nextY;

        result.Add(new Tuple<View, Rectangle>(child, new Rectangle(0, startY, width, sizeRequest.Request.Height)));

        lastY = Math.Max(lastY, startY + paddedHeight);

        nextY = Math.Max(nextY, paddedHeight);
      }

      return result;
    }
  }
}

Layout < View > を継承

カスタムレイアウトを実装する際の、各種基本実装を備えたクラスが「Xamarin.Forms.Layout」になります。
子要素リスト「Childrenプロパティ」なども Layoutクラス で実装されています。
更に Layoutの派生クラスとして「Xamarin.Forms.Layout < T > 」が用意されており、 < T > によって子要素として受け入れる型を明示する事が出来ます。
一般的にレイアウトは子要素としてUI要素を受け入れるので「Layout < View > 」を継承してカスタムレイアウトを定義します。

OnMeasure()メソッド

このレイアウト(ShuffleLayout)の「サイズ測定」が必要な場合に呼び出されます。
後述する NaiveLayout() により、子要素を含めたサイズを測定し SizeRequest オブジェクトを返却します。
引数 widthConstraint / heightConstraint には、親によって許容される width / height が設定されて呼び出されます。

LayoutChildren()メソッド

レイアウト上に子要素を実際に配置する必要がある場合に呼び出されます。
LayoutChildren()メソッドが未実装だと、レイアウト上には何も配置(表示)されません。
NaiveLayout()メソッドの呼び出しにより、子要素の配置座標を計算します。
配置座標の計算結果は List<Tuple<View, Rectangle>> 型で返却されます。子要素分のリスト要素が作成され、1つの要素には「子要素オブジェクト」「配置座標を表す Rectangle」がタプルで格納されています。
引数 x / y / width / height には、子要素を配置可能な境界が設定されています。例えば ShuffleLayoutのPaddingプロパティが(20,20,20,20)と設定されていた場合、x と y は 20 になります。
各子要素は Layout()メソッド の呼び出しにより配置しています。
(子要素の VerticalOption / HorizontalOption を有効にした、配置を行うレイアウトを作成する場合には、Layout()メソッドではなく、Layout.LayoutChildIntoBoundingRegion()メソッドを利用します)

NaiveLayout()メソッド

各子要素の配置ポジションを計算しています。
同時にこのメソッド内で子要素のシャッフルを行っています。
子要素に対し「child.Measure()」を呼び出すことで、子要素が表示に必要とするサイズを取得しています。
本レイアウトでは width は「強制的に画面幅いっぱい」としているため、Measure()結果のheightのみ利用しています。

ShuffleLayoutの利用

問題と選択肢を表すモデルクラスを用意します。

// EnglishWordQA.cs
using System.Collections.Generic;

namespace App1
{
  /// <summary>
  /// 英単語問題クラス。
  /// </summary>
  public class EnglishWordQA
  {
    /// <summary>
    /// 問題の単語。
    /// </summary>
    public string QuestionWord { get; set; }

    /// <summary>
    /// 回答選択肢。
    /// </summary>
    public List<AnserChoice> AnswerChoices { get; set; } = new List<AnserChoice>();
  }

  /// <summary>
  /// 回答選択肢。
  /// </summary>
  public class AnserChoice
  {
    /// <summary>
    /// コンストラクタです。
    /// </summary>
    /// <param name="answerText"></param>
    /// <param name="isCorrect"></param>
    public AnserChoice(string answerText, bool isCorrect) {
      this.AnswerText = answerText;
      this.IsCorrect = isCorrect;
    }

    /// <summary>
    /// 選択肢。
    /// </summary>
    public string AnswerText { get; set; }

    /// <summary>
    /// 正解フラグ。
    /// </summary>
    public bool IsCorrect { get; set; }
  }
}

画面フォームは以下の通り。

<!-- MainPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 
  xmlns="http://xamarin.com/schemas/2014/forms"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  xmlns:local="clr-namespace:App1"
  x:Class="App1.MainPage">
  <StackLayout>
    <Button Text="次の問題" Clicked="nextButtonClicked" />
    <Label x:Name="message" TextColor="Red" HorizontalTextAlignment="Center" />
    <Label Text="単語の意味を答えてください。" HorizontalTextAlignment="Center" />
    <Label x:Name="questionWord" HorizontalTextAlignment="Center" FontAttributes="Bold"/>
    <local:ShuffleLayout x:Name="shuffleLayout" />
  </StackLayout>
</ContentPage>

画面フォームのコードビハインドは以下の通り。

// MainPage.xaml.cs
using System;
using System.Collections.Generic;
using Xamarin.Forms;

namespace App1
{
  public partial class MainPage : ContentPage
  {
    /// <summary>
    /// 英単語問題リスト
    /// </summary>
    private List<EnglishWordQA> englishWordQA = null;

    /// <summary>
    /// 現在回答中の問題のインデックス
    /// </summary>
    private int currentQaIndex = -1;

    /// <summary>
    /// コンストラクタです。
    /// </summary>
    public MainPage()
    {
      InitializeComponent();

      // 英単語問題を初期化
      this.englishWordQA = this.InitializeQuestions();

      // 
      this.SetNextQuestion();
    }

    /// <summary>
    /// 次の問題を表示します。
    /// </summary>
    private void SetNextQuestion()
    {
      this.message.Text = "";

      this.currentQaIndex++;
      if (this.currentQaIndex >= this.englishWordQA.Count)
      {
        this.message.Text = "おしまい";
        return;
      }

      this.shuffleLayout.Children.Clear();

      this.questionWord.Text = this.englishWordQA[this.currentQaIndex].QuestionWord;

      foreach (var qa in this.englishWordQA[this.currentQaIndex].AnswerChoices)
      {
        var button = new Button() { Text = qa.AnswerText };
        button.Clicked += (sender, e) => {
          if (qa.IsCorrect)
            this.message.Text = "正解";
          else
            this.message.Text = "不正解";
        };

        
        this.shuffleLayout.Children.Add(button);
      }
    }

    /// <summary>
    /// 「次の問題を表示」ボタンクリックイベントハンドラ。
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    public void nextButtonClicked(object sender, System.EventArgs e)
    {
      this.SetNextQuestion();
    }

    /// <summary>
    /// 問題を初期化します。
    /// </summary>
    /// <returns></returns>
    private List<EnglishWordQA> InitializeQuestions()
    {
      List<EnglishWordQA> result = new List<EnglishWordQA>();

      result.Add(new EnglishWordQA()
      {
        QuestionWord = "Book",
        AnswerChoices = {
          new AnserChoice("本", true),
          new AnserChoice("花", false),
          new AnserChoice("空", false),
          new AnserChoice("机", false)
        }
      });

      result.Add(new EnglishWordQA()
      {
        QuestionWord = "Language",
        AnswerChoices = {
          new AnserChoice("科学", false),
          new AnserChoice("墓", false),
          new AnserChoice("私", false),
          new AnserChoice("言語", true)
        }
      });

      result.Add(new EnglishWordQA()
      {
        QuestionWord = "Phone",
        AnswerChoices = {
          new AnserChoice("画面", false),
          new AnserChoice("電話", true),
          new AnserChoice("赤", false),
          new AnserChoice("電車", false)
        }
      });

      result.Add(new EnglishWordQA()
      {
        QuestionWord = "Pickle",
        AnswerChoices = {
          new AnserChoice("リンゴ", false),
          new AnserChoice("ピクルス", true),
          new AnserChoice("針", false),
          new AnserChoice("杭", false)
        }
      });

      return result;
    }
  }
}

回転したとき

スマホを回転させた場合の動きについて補足しておきます。
デフォルト表示を「縦」と想定します。
以下手順で順に操作を行った場合の動きについて見ていきます。


①初期起動時
「ShuffleLayout.OnMeasure()呼び出し → ShuffleLayout.LayoutChildren()呼び出し」が4回繰り返されます。
これは「4つの回答選択肢」が ShuffleLayout.Children.Add() される度に、再レイアウトが必要と判断され、OnMeasure() / LayoutChildren()が繰り返し呼び出されるためです。

②「縦」→「横」に回転させる
ShuffleLayout.OnMeasure() が呼び出され、続いて ShuffleLayout.LayoutChildren() が呼び出されます。

③「横」→「縦」に回転させる
「横」→「縦」に回転させます。
この場合、ShuffleLayout.LayoutChildren() のみが呼び出されます。

③「次の問題」をクリック
「ShuffleLayout.OnMeasure()呼び出し → ShuffleLayout.LayoutChildren()呼び出し」が8回繰り返されます。
これは既存の「4つの回答選択肢」の削除毎、及び、次の問題の「4つの回答選択肢の追加毎に、OnMeasure() / LayoutChildren()が繰り返し呼び出されるためです。

※ 上記から分かるように、冗長な処理が行われています。計算した子要素サイズをキャッシュしたり、バッチレンダリング処理を加えたり、パフォーマンスチューニングが必要な場合は、もう少し複雑な実装が必要になります。


上記で「③」において「ShuffleLayout.OnMeasure()」が呼び出されない事は少し不思議に思います。
Xamarin Formsのソースを確認すると、OnMeasure()は「VisualElement.GetSizeRequest(double widthConstraint, double heightConstraint)」内から呼び出されています。また、VisualElement.GetSizeRequest(..)の実装は以下のようになっています。

[Obsolete("Use Measure")]
 public virtual SizeRequest GetSizeRequest(double widthConstraint, double heightConstraint)  
 {  
   SizeRequest cachedResult;  
   var constraintSize = new Size(widthConstraint, heightConstraint);  
   if (_measureCache.TryGetValue(constraintSize, out cachedResult))  
   {  
     return cachedResult;  
   }  
   ...省略  
   SizeRequest result = OnMeasure(widthConstraint, heightConstraint);  
   ...省略  
   var r = new SizeRequest(request, minimum);
   if (r.Request.Width > 0 && r.Request.Height > 0)
   {
      _measureCache[constraintSize] = r;
   }
   ...省略  
 }  

「widthConstraint / heightConstraint」をキーとして _measureCacheフィールドにサイズ情報をキャッシュしています。
その為、対象レイアウトにおいて1度目の領域測定時のみ OnMeasure() が呼び出されます。

Github上の VisualElement.cs の実装は↓↓↓
Xamarin.Forms/VisualElement.cs at ae59382c9046501edb37882ad1c065aacce60319 · xamarin/Xamarin.Forms · GitHub

ただし、この辺りの実装は SizeRequest() が Obsolete であることを含め、修正が入りそうですね。

まとめ

カスタムレイアウトについてはあまり情報が見つからず、また、上記説明でも Invalidateフロー周りはスルーしているので、改めて整理できれば、と思っています。
また、英語情報では、Jason Smith氏がいろいろ説明してくれています。

developer.xamarin.com

xfcomplete.net