読者です 読者をやめる 読者になる 読者になる

SIN@SAPPOROWORKSの覚書

C#を中心に、夜な夜な試行錯誤したコードの記録です。

Xamarin.Forms バルーンビューでLine風のレイアウトを作成してみました(機種依存コードなし)

【 Xamarin 記事一覧 】

f:id:furuya02:20140816201853p:plain:w148:leftf:id:furuya02:20140816201850p:plain:w150:leftf:id:furuya02:20140816201852p:plain:w155:left

Lineでチャットをする際に使用されているようなバルーン表示を作成してみました。

Xamarin.Formsでは、角丸をうまく表現出来るコントロールがありませんが、画像(Image)を使用することで共有プロジェクトのみのコーディングで実装可能となりました。

本記事の対象は、見た目(UI)のみであり、送受信の機能などは一切ありませんので、あらかじめご了承ください。

画面設計

バルーンの形成

f:id:furuya02:20140816165311p:plain:w400:left
角丸のバルーンは、縦長と横長の2つのBoxViewで十字を書き、その四隅に扇方の画像を回転させて配置することで表現しました。
吹き出しのノズル部分も画像で表現しています。

使用した画像は、扇方(sectorLime.pngとsectorWhite.png)と、吹き出しのノズル部分(nozzleLime.pngとnozzWhite.png)が、それぞれ2色分の4枚です。


画像及びBoxViewの配置(座標)

f:id:furuya02:20140816203225p:plain:w400:left
各コントロールの配置座標は、図のとおり計算されています。
基準となるのは、r(扇の半径)です。rを元にフォントのサイズを決定し、1行の文字数(最大値は固定)で横幅がきまります。また、メッセージの長さで、行数が計算され、そこから縦のサイズが決定されます。

なお、左右の余白(lm及びrm)は、吹き出しの方向によて、どちらかが0になるようになっています。

メッセージの表示

f:id:furuya02:20140816204023p:plain:w400:left
メッセージはLabelで表現されますが、横幅を適切に設定することで改行させています。
また、表示開始位置は、図のとおりですが、r(基準値)の半分だけずれた位置となっています。

コンストラクタ初期化によるバルーンビュー

まず、手始めに、ContentViewを継承してバルーンビュー(オリジナルビュー)を作成してみます。
f:id:furuya02:20140816165430p:plain:w149:leftf:id:furuya02:20140816165427p:plain:w150:leftf:id:furuya02:20140816165428p:plain:w156:left

バルーンを表示するために必要な元データは、「表示テキスト」と「吹き出しの方向(スピーカの位置)」の2つとし、コンストラクタで受け取る仕様にしました。(吹き出しの色や1行の最大文字数などは、定数となっています)

コードはやや長くなりますが、先の画面設計をそのままコードに落としたものです。クラスBalloonが、バルーンビューの全てです。

public class App{
    public static Page GetMainPage() {
        return new ContentPage() {
            Padding = new Thickness(0, Device.OnPlatform(20, 0, 0), 0, 0),//iPhone用パディング
            BackgroundColor = Color.Aqua,//背景色
            Content = new StackLayout {
                Children ={
                    new Balloon(Mouth.Left,"始めまして!、こればバルーンビューなんですね!"),
                    new Balloon(Mouth.Right,"そうなんです。話す人の位置(右・左)とメッセージを指定するだけで、このようなバルーンが表示されるんです。"),
                    new Balloon(Mouth.Left,"いいですね")
                }
            }
        };
    }
}

//スピーカの位置
enum Mouth {Left,Right}
    
//バルーンビュー
class Balloon : ContentView{

    //アブソレートレイアウトの生成
    readonly AbsoluteLayout _absoluteLayout = new AbsoluteLayout();

    public Balloon(Mouth mouth,String text) {

        //AbsoluteLayoutに各種のビューを配置する
        InitLayout(mouth,text);

        //バルーンビューとしてアブソレートレイアウトのみを返す
        Content = _absoluteLayout;
    }

    //AbsoluteLayoutに各種のビューを配置する
    private void InitLayout(Mouth mouth, String text) {
        //******************************************************
        //変数の初期化
        //******************************************************
        const int r = 15; //扇形のサイズ(フォントサイズの基準ともなる)
        var fontSize = GetFontSize(r); //フォントサイズ
        var cols = GetCols(text); //1行の文字数
       var rows = GetRows(text, cols); //行数
        var h = GetH(r, rows); //ビューの高さ
        var w = GetW(cols, fontSize, r); //ビューの幅
        var lm = GetLm(mouth, r); //左余白
        var rm = GetRm(mouth, r); //右余白

        //******************************************************
        //扇型画像を生成してアブソレートレイアウトに配置する
        //******************************************************
        _absoluteLayout.Children.Add(CreateSector(r, 0, mouth), new Point(lm, 0)); //左上
        _absoluteLayout.Children.Add(CreateSector(r, 90, mouth), new Point(w - r - rm, 0)); //右上
        _absoluteLayout.Children.Add(CreateSector(r, 180, mouth), new Point(w - r - rm, h - r)); //右下
        _absoluteLayout.Children.Add(CreateSector(r, 270, mouth), new Point(lm, h - r)); //左下

        //******************************************************
        //BoxViewを生成してアブソレートレイアウトに配置する
        //******************************************************
        //縦長のBoxView
        var color = mouth == Mouth.Left ? Color.White : Color.Lime;
        var boxView1 = new BoxView{Color = color};
        _absoluteLayout.Children.Add(boxView1, new Rectangle(lm + r - 1, 0, w - r*3 + 1, h)); //左右を1ドットずつ広げる
        //横長のBoxView
        var boxView2 = new BoxView{Color = color};
        _absoluteLayout.Children.Add(boxView2, new Rectangle(lm, r - 1, w - r, h - r*2 + 1)); //上下を1ドットずつ広げる

        //******************************************************
        //吹き出し口画像を生成してアブソレートレイアウトに配置する
        //******************************************************
        var point = mouth == Mouth.Left ? new Point(r/4, r/2) : new Point(w - r - r/4, r/2);
        _absoluteLayout.Children.Add(CreateNozzle(r, mouth), point);

        //******************************************************
        //メッセージを生成してアブソレートレイアウトに配置する
        //******************************************************
        var body = new Label();
        body.TextColor = Color.Black;
        body.Font = Font.SystemFontOfSize(fontSize);
        body.Text = text;
        body.WidthRequest = cols*fontSize + Device.OnPlatform(0, r, 0);
        //1行の分の文字数を超えと改行するように幅を指定する(Androidだけちょっと幅が足りないので追加)
        _absoluteLayout.Children.Add(body, new Point(lm + r/2, r/2));
    }

    //フォントサイズ
    int GetFontSize(int r) {
        return r - 2;//基準の半径よりの小さくサイズとする
    }
  
    //1行の文字数
    int GetCols(string text) {
        const int max = 15;//1行の文字数は最大15文字
        //もし、テキストが最大文字数以下の場合は、その文字数となる
        return (text.Length < max) ? text.Length : max;
    }

    //行数
    int GetRows(string text, int cols) {
        if (text.Length == 0) {
            return 1; //0除算防止
        }
        //全文字数を1行の文字数で割ったもの、余りは1行となる
        return text.Length / cols + ((text.Length % cols) == 0 ? 0 : 1);
    }

    //ビューの高さ
    int GetH(int r, int rows) {
        //メッセージの行数と半径で決定される
        return r * rows + r;
    }

    //ビューの幅
    int GetW(int cols, int fontSize, int r) {
        //フォントサイズ×1行の文字数+半径+吹き出しサイズ
        return cols * fontSize + r + r;
    }

    //左余白
    int GetLm(Mouth mouth, int r) {
        //吹き出し分の余白(スピーカの位置によって変化する)
        return mouth == Mouth.Left ? r : 0;
    }

    //右余白
    int GetRm(Mouth mouth, int r) {
        //吹き出し分の余白(スピーカの位置によって変化する)
        return mouth == Mouth.Left ? 0 : r;
    }
        
    //扇形画像の生成 r:基準サイズ int:回転角度 Mouth:スピーカ位置
    Image CreateSector(int r, int rotation, Mouth mouth){
        return new Image(){
            //画像ファイルは、スピーカ位置によって緑と白を使い分ける
            Source = mouth == Mouth.Left ? "sectorWhite.png" : "sectorLime.png",
            Rotation = rotation, //配置に応じて画像を回転させる
            WidthRequest = r, //基準サイズで初期化される
            HeightRequest = r
        };
    }
    
    //吹き出し画像の生成 r:基準サイズ Mouth:スピーカ位置
    Image CreateNozzle(int r,Mouth mouth){
        return new Image{
            //画像ファイルは、スピーカ位置によって左右を使い分ける
            Source = mouth == Mouth.Left ? "nozzleWhite.png" : "nozzleLime.png",
            WidthRequest = r, //基準サイズで初期化される
            HeightRequest = r 
        };
    }
}


プロパティ初期化によるバルーンビュー

続いて、コンストラクタで受け取っていた2つのデータ( 表示テキスト/スピーカの位置 )を、プロパティとして実装し、バインディングが可能なように修正してみました。

作業は、次のとおりです。

(1) Mouth(スピーカ位置) 及び Text(表示テキスト) プロパティを追加
(2) コンストラクタ及びInitLayout()の引数を削除し、当該変数をプロパティに置き換え
(3) OnPropertyChangedをオーバーライドし、コンストラクタに置かれていた InitLayout(アブソレートレイアウトへの配置)をそこに移動

以上で、プロパティTextを設定したタイミングでレイアウトの再配置が行われるようになります。

//バルーンビュー
class Balloon : ContentView{
    
   public Balloon() { //コンストラクタのパラメータは無しになる
   
       // ・・・・・省略・・・・・・
   
    }


    protected override void OnPropertyChanged(string propertyName = null){
        base.OnPropertyChanged(propertyName);
            if (propertyName == "Text") {
                //Textプロパティがセットされた際に、レイアウトを再構築する
                _absoluteLayout.Children.Clear();
                //AbsoluteLayoutに各種のビューを配置する
                InitLayout();
            }
        }
    }

    //バインディング可能なプロパティ
    public static readonly BindableProperty TextProperty = BindableProperty.Create("Text", typeof(string), typeof(Balloon), default(string), BindingMode.OneWay);
    public String Text {
        get {return (String)GetValue(TextProperty);}
        set{SetValue(TextProperty, value);}
    }
    public static readonly BindableProperty MouthProperty = BindableProperty.Create("Mouth", typeof(Mouth), typeof(Balloon), default(Mouth), BindingMode.OneWay);
    public Mouth Mouth {
        get {return (Mouth)GetValue(MouthProperty);}
        set { SetValue(MouthProperty, value); }
    }
    
    //AbsoluteLayoutに各種のビューを配置する
    private void InitLayout() { //引数なしに変更

        //引数で受け取っていたtextとmouthは、プロパティに修正される   

        // ・・・・・省略・・・・・・
    }
    
    // ・・・・・省略・・・・・・

}

ListViewでの使用

f:id:furuya02:20140816165816p:plain:w149:leftf:id:furuya02:20140816165817p:plain:w150:leftf:id:furuya02:20140816165819p:plain:w155:left

バインディングが可能になったバルーンビューを、ListViewのセルに使用した例が次のとおりです。
SetBindingでデータが取得できていることを確認できます。

なお、サンプル簡略化のためにListViewの各セルの高さは固定にしました。

public class App{
    private class Msg{
        public Mouth Mouth { get; set; }
        public String Body { get; set; }
    }

    public static Page GetMainPage(){

        var ar = new ObservableCollection<Msg>{
            new Msg{Mouth = Mouth.Left, Body = "始めまして!、こればバルーンビューなんですね!"},
            new Msg{Mouth = Mouth.Right, Body = "そうなんです。話す人の位置(右・左)とメッセージを指定するだけで、このようなバルーンが表示されるんです。"},
            new Msg{Mouth = Mouth.Left, Body = "いいですね"}
        };

        var listView = new ListView();
        listView.RowHeight = 80;
        listView.BackgroundColor = Color.Aqua; //背景色
        listView.ItemsSource = ar;//データソース
        listView.ItemTemplate = new DataTemplate(() =>{//テンプレート
            var balloon = new Balloon();
            balloon.SetBinding(Balloon.MouthProperty, "Mouth");//データクラスのMouthから取得
            balloon.SetBinding(Balloon.TextProperty, "Body");//データクラスのBodyから取得
            return new ViewCell{
                View = balloon //バルーンビューのみを返す
            };
        });
        return new ContentPage(){
            Padding = new Thickness(0, Device.OnPlatform(20, 0, 0), 0, 0), //iPhone用パディング
            Content = listView
        };
    }
}

Line風UI

最後に、ここまでで作成してきたバルーンビューを使用して、Lineに似せたUIを作成してみました。
実行画面は、本記事の冒頭の画像です。単純に似せることだけが目的ですw

こちらの全コードはGitHUBに置きましたので、良かったらご参照ください。
furuya02/Xamarin.Forms.Balloon.Sample · GitHub


public class App{
    public static Page GetMainPage(){
        return new MyPage();
    }
}

//1つのメッセージを表現するクラス
internal class Msg{
    public string Name { get; set; }
    public String Text { get; set; }
    public DateTime CreatedAt { get; set; }
}

internal class MyPage : ContentPage{
    public MyPage(){
        var ar = new ObservableCollection<Msg>{
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 10, 0),Name = "Taro",Text = "始めまして!、これがバルーンビューなんですね!"},
            new Msg{ CreatedAt = new DateTime(2014, 08, 1, 10, 12, 0),Name = "Me",Text = "そうなんです。話す人の位置(右・左)とメッセージを指定するだけで、このようなバルーンが表示されるんです" },
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 14, 0), Name = "Hanako", Text = "いいです"},
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 15, 0), Name = "Me",Text = "長いメッセージは、横幅を超えると自動的に改行するのです"},
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 17, 0), Name = "Taro", Text = "なるほど"},
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 18, 0), Name = "Me", Text = "1行でも、短いメッセージは、バルーンの横幅も短くなってるでしょ"},
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 20, 0), Name = "Taro", Text = "ほんとだ(@@)"},
            new Msg{CreatedAt = new DateTime(2014, 08, 1, 10, 20, 0), Name = "Hanako", Text = "すごい!💛"},
        };
        BackgroundImage = "back.png";//背景画像
        Padding = new Thickness(0,Device.OnPlatform(20,0,0),0,0);//iPhone用パディング
        var mainLayout = new StackLayout();
        foreach (var a in ar){
            mainLayout.Children.Add(CreateOneMsg(a.Name, a.Text, a.CreatedAt.ToString("hh:mm")));
        }
        Content = mainLayout;
    }

    private StackLayout CreateOneMsg(string name, string text, string createdAt){
        var mainLayout = new StackLayout();
        mainLayout.Orientation = StackOrientation.Horizontal;//横に並べる
        if (name != "Me") { //自分ではない場合、左からの吹き出し(白)となる
            mainLayout.Children.Add(new Image() {
                Source = string.Format("{0}.png", name),
                WidthRequest = 40,
                HeightRequest = 40
            });
            var subLayout = new StackLayout();

            //ユーザ名
            subLayout.Children.Add(new Label {Text = name,Font=Font.SystemFontOfSize(15)});

            //バルーンメッセージ
            var baloon = new Balloon();
            baloon.Mouth = Mouth.Left;
            baloon.Text = text;
            subLayout.Children.Add(baloon);

            mainLayout.Children.Add(subLayout);

            //日付
            mainLayout.Children.Add(new Label{
                Text = createdAt,
                Font = Font.SystemFontOfSize(10),
                VerticalOptions = LayoutOptions.End,
            });
        } else {//自分のメッセージは、右からの吹き出しで緑になる
            //日付
            mainLayout.Children.Add(new Label{Text=createdAt,Font=Font.SystemFontOfSize(10),VerticalOptions = LayoutOptions.End});

            //バルーンメッセージ
            var baloon = new Balloon();
            baloon.Mouth = Mouth.Right;
            baloon.Text = text;
            mainLayout.Children.Add(baloon);

            mainLayout.HorizontalOptions = LayoutOptions.End;
        }
       return mainLayout;
    }
}


//バルーンビュー
class Balloon : ContentView{
    // ・・・・・省略・・・・・・
}


【 Xamarin 記事一覧 】