.NET MAUIチャットUIの吹き出しを左右に分ける最適解|CollectionViewとBorderのHorizontalOptionsが効かない原因とGrid/DataTriggerでの完全対策

チャットUIを.NET MAUIで作ると、「自分の吹き出しは右、相手は左」にしたいのに、すべて左へ寄ってしまう――そんな現象は珍しくありません。原因はXAMLの指定ミスではなく、レイアウトの親子関係にあります。本稿では、仕組みを理解したうえで確実に左右へ振り分ける実装と、運用で差が出るスタイル設計・パフォーマンス最適化まで実務目線で解説します。

目次

.NET MAUIの吹き出しが左右に分かれない「本当の理由」

「CollectionView の DataTemplate に Border を置き、HorizontalOptions="End"(自分)と Start(相手)を切り替えたのに右へ寄らない」。これは Border 直上に幅を持つレイアウトコンテナが無いため、HorizontalOptions の意図が親に伝わらず、配置が効かないのが原因です。

CollectionView は各アイテムを内部コンテナ(ContentPresenter 相当)に載せますが、このコンテナは中身の望むサイズをそのまま採用しがちです。Border 単体を直下に置くと、行の横幅を占有せず、右寄せ・左寄せの余白が生まれません。結果、自然と左に貼り付いたように見えます。

症状背景根本原因
全て左側に表示されるBorder直上に幅を満たすレイアウトがないHorizontalOptionsは親レイアウトが解釈するが、親が幅いっぱいを確保していない
右寄せ指定でも動かないコンテンツの実サイズ=アイテムの表示幅寄せ先(右側の余白)を作る「キャンバス」が存在しない

最小の解決策:Borderを「薄いコンテナ」で包む

最もシンプルで再利用しやすいのが、1枚レイアウトコンテナ(例:GridやHorizontalStackLayout)で Border を包む方法です。コンテナが行の横幅を占有するため、Border.HorizontalOptions が効くようになります。

Gridで包む(推奨)

<DataTemplate x:DataType="local:FireMessage">
  <!-- 1行分のアイテムコンテナ:幅いっぱい -->
  <Grid Margin="6,4" HorizontalOptions="Fill">
    <Border x:Name="MessageBorder"
            Style="{StaticResource BubbleBorderStyle}">
      <VerticalStackLayout Spacing="4">
        <Label Text="{Binding SenderName}" FontSize="12" Opacity=".7" />
        <Label Text="{Binding Text}" FontSize="14" LineBreakMode="WordWrap" />
        <Label Text="{Binding CreatedAt, StringFormat='{}{0:HH:mm}'}"
               FontSize="11" HorizontalOptions="End" Opacity=".5" />
      </VerticalStackLayout>
    </Border>
  </Grid>
</DataTemplate>

このときの鍵は、Grid が HorizontalOptions="Fill" で行全体の幅を確保していること。ここで初めて Border.HorizontalOptions が 開始側(左)/終了側(右) の余白を使えるようになります。

スタイルとDataTriggerで左右・色を切替

左右の寄せと外観(背景色・角丸など)は Style でまとめると保守が容易です。

<Style x:Key="BubbleBorderStyle" TargetType="Border">
  <Setter Property="Padding" Value="10,8" />
  <Setter Property="MaximumWidthRequest" Value="320" />
  <Setter Property="StrokeThickness" Value="0" />
  <Setter Property="HorizontalOptions" Value="Start" /> <!-- 既定:相手 -->
  <Setter Property="Background" Value="{AppThemeBinding Light=LightBlue, Dark=#223A5E}" />
  <Setter Property="StrokeShape">
    <Setter.Value>
      <RoundRectangle CornerRadius="16" />
    </Setter.Value>
  </Setter>
  <Style.Triggers>
    <DataTrigger TargetType="Border" Binding="{Binding IsCurrentUser}" Value="True">
      <Setter Property="HorizontalOptions" Value="End" />
      <Setter Property="Background" Value="{AppThemeBinding Light=#DCF8C6, Dark=#133E2D}" />
    </DataTrigger>
  </Style.Triggers>
</Style>

これだけで「自分=右/相手=左」の基本はクリアできます。

位置決めをさらに堅牢に:Gridの「Auto,*,Auto」三列テクニック

見出しの方法はシンプルですが、幅広い端末や多言語環境で常に確実に意図通りの位置に置きたいなら、三列グリッドが鉄板です。左右に Auto、中央に * を置き、吹き出しは左列(Auto)または右列(Auto)に配置します。中央の * が余白となり、視覚的に左右へ張り付きます。

<DataTemplate x:DataType="local:FireMessage">
  <Grid Margin="6,4" ColumnDefinitions="Auto,*,Auto">
    <Border x:Name="MessageBorder"
            Grid.Column="0" <!-- 既定:左列 -->
            Style="{StaticResource BubbleBorderStyle}">
      <VerticalStackLayout Spacing="4">
        <Label Text="{Binding SenderName}" FontSize="12" Opacity=".7" />
        <Label Text="{Binding Text}" FontSize="14" LineBreakMode="WordWrap" />
        <Label Text="{Binding CreatedAt, StringFormat='{}{0:HH:mm}'}" FontSize="11" HorizontalOptions="End" Opacity=".5" />
      </VerticalStackLayout>
    </Border>
    <!-- IsCurrentUser=true のとき Border を右列へ移動 -->
    <Grid.Triggers>
      <DataTrigger TargetType="Grid" Binding="{Binding IsCurrentUser}" Value="True">
        <Setter TargetName="MessageBorder" Property="Grid.Column" Value="2" />
      </DataTrigger>
    </Grid.Triggers>
  </Grid>
</DataTemplate>

三列法は HorizontalOptions への依存を下げ、「列」という物理的な位置で制御できるため、表示のブレが起きにくいのが利点です。

コピペで動く最小構成(ViewModel & XAML)

モデル

public sealed class FireMessage
{
    public string SenderId { get; init; } = "";
    public string SenderName { get; init; } = "";
    public string Text { get; init; } = "";
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now;
    public bool IsCurrentUser { get; init; }
}

ViewModel

public sealed class ChatViewModel : INotifyPropertyChanged
{
    public ObservableCollection<FireMessage> Messages { get; } = new();
public ChatViewModel()
{
    // ダミーデータ
    Messages.Add(new FireMessage{ SenderId=&quot;u2&quot;, SenderName=&quot;Mary&quot;, Text=&quot;こんにちは!&quot;, IsCurrentUser=false });
    Messages.Add(new FireMessage{ SenderId=&quot;u1&quot;, SenderName=&quot;You&quot;,  Text=&quot;やあ、元気?&quot;, IsCurrentUser=true  });
    Messages.Add(new FireMessage{ SenderId=&quot;u2&quot;, SenderName=&quot;Mary&quot;, Text=&quot;今からMAUI触るの?&quot;, IsCurrentUser=false });
    Messages.Add(new FireMessage{ SenderId=&quot;u1&quot;, SenderName=&quot;You&quot;,  Text=&quot;うん、吹き出し直してる&quot;, IsCurrentUser=true  });
}

public event PropertyChangedEventHandler? PropertyChanged;

}

ページ(CollectionView + 三列Grid法)

<ContentPage
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:YourApp"
    x:Class="YourApp.ChatPage">











  &lt;CollectionView.ItemsLayout&gt;
    &lt;LinearItemsLayout Orientation=&quot;Vertical&quot; ItemSpacing=&quot;6&quot; /&gt;
  &lt;/CollectionView.ItemsLayout&gt;

  &lt;CollectionView.ItemTemplate&gt;
    &lt;DataTemplate x:DataType=&quot;local:FireMessage&quot;&gt;
      &lt;Grid Margin=&quot;6,0&quot; ColumnDefinitions=&quot;Auto,*,Auto&quot;&gt;
        &lt;Border x:Name=&quot;Bubble&quot; Grid.Column=&quot;0&quot; Style=&quot;{StaticResource BubbleBorderStyle}&quot;&gt;
          &lt;VerticalStackLayout Spacing=&quot;4&quot;&gt;
            &lt;Label Text=&quot;{Binding SenderName}&quot; FontSize=&quot;12&quot; Opacity=&quot;.7&quot; /&gt;
            &lt;Label Text=&quot;{Binding Text}&quot; FontSize=&quot;14&quot; LineBreakMode=&quot;WordWrap&quot; /&gt;
            &lt;Label Text=&quot;{Binding CreatedAt, StringFormat='{}{0:MM/dd HH:mm}'}&quot;
                   FontSize=&quot;11&quot; HorizontalOptions=&quot;End&quot; Opacity=&quot;.5&quot; /&gt;
          &lt;/VerticalStackLayout&gt;
        &lt;/Border&gt;

        &lt;Grid.Triggers&gt;
          &lt;DataTrigger TargetType=&quot;Grid&quot; Binding=&quot;{Binding IsCurrentUser}&quot; Value=&quot;True&quot;&gt;
            &lt;Setter TargetName=&quot;Bubble&quot; Property=&quot;Grid.Column&quot; Value=&quot;2&quot; /&gt;
          &lt;/DataTrigger&gt;
        &lt;/Grid.Triggers&gt;
      &lt;/Grid&gt;
    &lt;/DataTemplate&gt;
  &lt;/CollectionView.ItemTemplate&gt;
&lt;/CollectionView&gt;

&lt;!-- 入力欄(参考) --&gt;
&lt;Grid Grid.Row=&quot;1&quot; ColumnDefinitions=&quot;*,Auto&quot; Padding=&quot;8&quot; BackgroundColor=&quot;{AppThemeBinding Light=#F7F7F7, Dark=#1E1E1E}&quot;&gt;
  &lt;Entry x:Name=&quot;MessageEntry&quot; Grid.Column=&quot;0&quot; Placeholder=&quot;メッセージを入力&quot; /&gt;
  &lt;Button Grid.Column=&quot;1&quot; Text=&quot;送信&quot; /&gt;
&lt;/Grid&gt;


このままでも十分実用的ですが、次章からは運用で効く調整ポイントを掘り下げます。

表示を安定させるためのチューニング

項目推奨設定理由/効果
吹き出し最大幅MaximumWidthRequest="300~360"長文で横に間延びしない。折返しが安定し可読性向上。
行間と気泡間隔ItemSpacing=6~8、Marginを縦方向に付与視線誘導を補助。タップしやすいタッチターゲットを確保。
本文の折返しLineBreakMode="WordWrap"英単語やURLを含む場合の突き抜けを防止。
角丸RoundRectangle CornerRadius="16" から調整行頭/行末の見切れを防ぎつつ見た目の一貫性を保つ。
型安全バインディングx:DataType="local:FireMessage"コンパイル時検査でバインディングミスを早期発見。
測定戦略ItemSizingStrategy="MeasureFirstItem" もしくは既定端末差のある測定コストを抑制しつつ初回レイアウトを安定。

FlexLayoutを使う別解(Row/RowReverseで切替)

UI定義をシンプルにしたい場合は FlexLayout も有効です。Direction を Row/RowReverse に切り替えるだけで、左右の寄せを直感的に表現できます。

<DataTemplate x:DataType="local:FireMessage">
  <FlexLayout Margin="6,4" Direction="Row" AlignItems="Start" JustifyContent="Start">
    <Border x:Name="Bubble" Style="{StaticResource BubbleBorderStyle}">
      <Label Text="{Binding Text}" LineBreakMode="WordWrap" />
    </Border>
    <FlexLayout.Triggers>
      <DataTrigger TargetType="FlexLayout" Binding="{Binding IsCurrentUser}" Value="True">
        <Setter Property="Direction" Value="RowReverse" />
      </DataTrigger>
    </FlexLayout.Triggers>
  </FlexLayout>
</DataTemplate>

アイテム間スペースの緻密な調整は Grid のほうが容易ですが、「左右切替」自体は簡潔に表現できます。

見た目に差が出るUIテクニック

「尾(しっぽ)」の表現

アイコン的な“尾”を付けるとチャットらしさが増します。Border の横に小さな三角形(Polygon 相当)や、角丸を部分的に強めた小矩形を重ねる手が簡単です。

<Grid Margin="6,0" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
  <Border x:Name="LeftTail" Grid.Column="1" Background="LightBlue"
          WidthRequest="8" HeightRequest="12" Rotation="45" 
          TranslationX="-4" IsVisible="{Binding IsCurrentUser, Converter={StaticResource BoolInvertConverter}}"/>












アイコン画像で代用すればより自由度が増します。重ね順やスケーリングで角と自然に馴染ませましょう。

テーマ対応(ライト/ダーク)

AppThemeBinding を活用して背景色と本文色を切り替えると、OSのテーマ変更に自動追従します。

<Setter Property="Background" Value="{AppThemeBinding Light=#FFFFFF, Dark=#1F1F1F}" />
<Label TextColor="{AppThemeBinding Light=#222, Dark=#EEE}" />

パフォーマンス最適化(大量メッセージ・画像を想定)

  • テンプレートの再利用:CollectionView は既定で仮想化されます。スクロールが重いときは 長文や画像の測定が高コスト になっている可能性が高いので、テキスト行間・最大幅の見直しや、画像サムネイルの事前縮小を検討します。
  • 画像読み込み:Image を使う場合は Aspect="AspectFill" と固定サイズ(例:WidthRequest="160")で測定回数を削減。
  • バインディングの型安全化:x:DataType を可能な限り指定して JIT ではなく AOT/ネイティブ最適化が効く余地を作る。
  • EmptyView:メッセージが空のときの EmptyView を用意すると、測定対象が減るため初回負荷を軽減。
  • 時刻表示のフォーマット:StringFormat を使い、IValueConverter の乱用を避ける。

よくある落とし穴とチェックリスト

やりがちミス原因解決策
HorizontalOptions が効かない親が幅いっぱいを確保していない1枚ラッパー(Grid や HorizontalStackLayout)で包む
右寄せが端末によってズレる余白の作り方が不安定三列グリッド(Auto,*,Auto)で列移動に切替
長文でレイアウトが跳ねる最大幅未設定MaximumWidthRequest を設定し折返し前提に
RTL(右→左言語)で左右が逆転FlowDirection の影響列移動方式は影響が小さい。必要に応じてページで FlowDirection="LeftToRight" を固定
スクロールのカクつき測定コスト増、大きい画像画像の固定サイズ化、サムネイル化、ItemSpacing 調整

実装後は、以下のクイックチェックで仕上げを行いましょう。

  • 最長行(URL、英単語羅列、絵文字連続)でも横スクロールが出ないか
  • フォント倍率(アクセシビリティ設定)を上げても崩れないか
  • 縦横回転・分割画面で左右の寄せが保たれるか
  • テーマ切替(ライト/ダーク)でコントラストが十分か

入力・送信処理の実装メモ

送信ボタンでメッセージを追加する基本コードです。UIスレッドで ObservableCollection を操作し、直後に最下部へスクロールします。

// 例:コードビハインド
void OnSendClicked(object sender, EventArgs e)
{
    if (BindingContext is not ChatViewModel vm) return;
    var text = MessageEntry.Text?.Trim();
    if (string.IsNullOrEmpty(text)) return;
vm.Messages.Add(new FireMessage {
    SenderId = &quot;me&quot;,
    SenderName = &quot;You&quot;,
    Text = text,
    IsCurrentUser = true,
    CreatedAt = DateTimeOffset.Now
});

MessageEntry.Text = string.Empty;

// スクロール(名前付き要素として CollectionView に x:Name=&quot;ChatList&quot; を付与しておく)
Device.BeginInvokeOnMainThread(() =&gt;
{
    if (vm.Messages.Count &gt; 0)
        ChatList.ScrollTo(vm.Messages[^1], position: ScrollToPosition.End, animate: true);
});

}

このとき ScrollTo はアイテム生成が完了していないと無視されることがあるため、BeginInvokeOnMainThread でフレーム後にキューイングするのが安全です。

運用時のアクセシビリティ配慮

  • 意味付け:吹き出しに SemanticProperties.Description を与える(例:「あなたからのメッセージ」「相手からのメッセージ」)。
  • コントラスト:ダークテーマで背景と本文色の輝度差を確保。
  • フォント倍率:Label に FontAutoScalingEnabled="True"(既定true)を活かす。

トラブルシューティング:それでも寄せが効かないとき

  1. 直上がレイアウトか?:Border の親が Grid/HorizontalStackLayout か確認。もし ContentView のままなら さらに外側にラッパーを追加。
  2. 幅を満たしているか?:ラッパーに HorizontalOptions="Fill" または WidthRequest 未指定を確認。不要な HorizontalOptions="Start" が邪魔していないか。
  3. 余白の衝突:Border.Margin とコンテナの Padding の重複で見かけがずれていないか。
  4. RTL影響:FlowDirection を明示するか、三列Grid方式に切り替え。
  5. テンプレート再利用時の命名衝突:TargetName の参照先が正しいか(同一テンプレート内に限定される)。

まとめ:まずは「幅を持つ親」を作る、次に「列で制御」

吹き出しが左右に分かれない最大の理由は、Border単体の下に幅を占有する親がいないこと。対策は簡単で、薄いコンテナでBorderを包み、HorizontalOptions をトリガーで切り替えます。より堅牢にいくなら、Gridの三列(Auto,*,Auto)で列移動にすると端末や言語差に強い実装になります。最大幅、行間、テーマ、アクセシビリティ、パフォーマンスを併せて詰めれば、「自分=右/相手=左」の読みやすいチャットUIを安定して提供できます。

付録:最小再現と修正の比較(XAML抜粋)

NG例(親コンテナなし)

<DataTemplate x:DataType="local:FireMessage">
  <Border HorizontalOptions="End"> <!-- 効かない -->
    <Label Text="{Binding Text}"/>
  </Border>
</DataTemplate>

OK例(1枚Gridで包む)

<DataTemplate x:DataType="local:FireMessage">
  <Grid>
    <Border HorizontalOptions="End">
      <Label Text="{Binding Text}"/>
    </Border>
  </Grid>
</DataTemplate>

付録:プロパティ早見表

目的プロパティ推奨値例
左右の寄せHorizontalOptions(一枚ラッパーあり)Start/End を DataTrigger で切替
左右の堅牢配置Grid.Column(三列Grid)左=0、右=2(中央は*の余白)
最大幅MaximumWidthRequest300〜360
角丸StrokeShapeRoundRectangle で16前後
行間ItemSpacing, Margin6〜10(端末により微調整)

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次