チャット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="u2", SenderName="Mary", Text="こんにちは!", IsCurrentUser=false });
Messages.Add(new FireMessage{ SenderId="u1", SenderName="You", Text="やあ、元気?", IsCurrentUser=true });
Messages.Add(new FireMessage{ SenderId="u2", SenderName="Mary", Text="今からMAUI触るの?", IsCurrentUser=false });
Messages.Add(new FireMessage{ SenderId="u1", SenderName="You", Text="うん、吹き出し直してる", 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">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="6" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="local:FireMessage">
<Grid Margin="6,0" ColumnDefinitions="Auto,*,Auto">
<Border x:Name="Bubble" 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:MM/dd HH:mm}'}"
FontSize="11" HorizontalOptions="End" Opacity=".5" />
</VerticalStackLayout>
</Border>
<Grid.Triggers>
<DataTrigger TargetType="Grid" Binding="{Binding IsCurrentUser}" Value="True">
<Setter TargetName="Bubble" Property="Grid.Column" Value="2" />
</DataTrigger>
</Grid.Triggers>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!-- 入力欄(参考) -->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Padding="8" BackgroundColor="{AppThemeBinding Light=#F7F7F7, Dark=#1E1E1E}">
<Entry x:Name="MessageEntry" Grid.Column="0" Placeholder="メッセージを入力" />
<Button Grid.Column="1" Text="送信" />
</Grid>
このままでも十分実用的ですが、次章からは運用で効く調整ポイントを掘り下げます。
表示を安定させるためのチューニング
| 項目 | 推奨設定 | 理由/効果 |
|---|---|---|
| 吹き出し最大幅 | 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 = "me",
SenderName = "You",
Text = text,
IsCurrentUser = true,
CreatedAt = DateTimeOffset.Now
});
MessageEntry.Text = string.Empty;
// スクロール(名前付き要素として CollectionView に x:Name="ChatList" を付与しておく)
Device.BeginInvokeOnMainThread(() =>
{
if (vm.Messages.Count > 0)
ChatList.ScrollTo(vm.Messages[^1], position: ScrollToPosition.End, animate: true);
});
}
このとき ScrollTo はアイテム生成が完了していないと無視されることがあるため、BeginInvokeOnMainThread でフレーム後にキューイングするのが安全です。
運用時のアクセシビリティ配慮
- 意味付け:吹き出しに
SemanticProperties.Descriptionを与える(例:「あなたからのメッセージ」「相手からのメッセージ」)。 - コントラスト:ダークテーマで背景と本文色の輝度差を確保。
- フォント倍率:
LabelにFontAutoScalingEnabled="True"(既定true)を活かす。
トラブルシューティング:それでも寄せが効かないとき
- 直上がレイアウトか?:
Borderの親がGrid/HorizontalStackLayoutか確認。もしContentViewのままなら さらに外側にラッパーを追加。 - 幅を満たしているか?:ラッパーに
HorizontalOptions="Fill"またはWidthRequest未指定を確認。不要なHorizontalOptions="Start"が邪魔していないか。 - 余白の衝突:
Border.MarginとコンテナのPaddingの重複で見かけがずれていないか。 - RTL影響:
FlowDirectionを明示するか、三列Grid方式に切り替え。 - テンプレート再利用時の命名衝突:
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(中央は*の余白) |
| 最大幅 | MaximumWidthRequest | 300〜360 |
| 角丸 | StrokeShape | RoundRectangle で16前後 |
| 行間 | ItemSpacing, Margin | 6〜10(端末により微調整) |

コメント