.NET MAUI で複数の ContentView を組み合わせると、最初の更新だけ値が反映され、その後は子ビューに届かない――そんな「一回目だけ動く」問題に悩まされることがあります。本記事では、実際に起こりやすい原因をコード付きで分解し、INotifyPropertyChanged・BindableProperty・可視状態(IsVisible)の扱いまで、現場でそのまま使える実装指針とチェックリストを提示します。MAUI 9 前提ですが、8 以前のプロジェクトでも応用可能です。
ContentView 間の文字列バインディングが 1 回目しか更新されない問題
症状
- NewContent1.xaml の
InputStringを、ChildContentView.xaml のMyStringへ OneWay バインド。 - 最初のクリックでは子ビューに表示されるが、2 回目以降の更新が子側に反映されない。
まず押さえるポイント
.NET MAUI の ContentView は BindableObject を継承しているため「内部的に INotifyPropertyChanged を持ちます」。しかし、通常の CLR プロパティをバインドのソースとして使う場合、set で OnPropertyChanged(nameof(Property)) を呼ばない限り、変更通知は飛びません。
つまり、インターフェイスの実装有無よりも、「変更通知を正しく発火しているか」が本質です。
よくある原因と対処のまとめ
| 原因 | なぜ起きるか | 対策の要点 |
|---|---|---|
| ① INotifyPropertyChanged を未発火 | CLR プロパティ InputString の setter で OnPropertyChanged を呼んでいない。 | OnPropertyChanged(nameof(InputString)) を必ず呼ぶ。自動化したいなら CommunityToolkit.Mvvm を利用。 |
| ② 子ビュー側の BindableProperty で自己代入ループ | propertyChanged: コールバックで再度 MyString を 自分に代入し、Setter → Callback → Setter… の循環に陥る。 | コールバックで Setter を呼ばない。プロパティは「純粋なストア」にして、表示は XAML のバインドやコールバック内で UI に直接反映。 |
NG 例(再現コード)
NewContent1.xaml(間違い例:通知しない)
<ContentView x:Class="Sample.NewContent1"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Sample">
<VerticalStackLayout x:Name="Self">
<Entry Text="{Binding InputString, Source={x:Reference Self}}" />
<Button Text="更新" Clicked="OnClicked"/>
<local:ChildContentView MyString="{Binding InputString, Source={x:Reference Self}}" />
</VerticalStackLayout>
</ContentView>
NewContent1.xaml.cs(間違い例:通知しない)
public partial class NewContent1 : ContentView
{
private string _inputString;
public string InputString
{
get => _inputString;
set { _inputString = value; } // ← 通知がない
}
void OnClicked(object sender, EventArgs e)
{
InputString = DateTime.Now.ToString("T");
}
}
ChildContentView.xaml.cs(間違い例:自己代入ループ)
public partial class ChildContentView : ContentView
{
public static readonly BindableProperty MyStringProperty =
BindableProperty.Create(
nameof(MyString), typeof(string), typeof(ChildContentView), default(string),
propertyChanged: (b, o, n) =>
{
// ❌ Setter を再び叩くのはダメ(ループ)
((ChildContentView)b).MyString = (string)n;
});
public string MyString
{
get => (string)GetValue(MyStringProperty);
set => SetValue(MyStringProperty, value);
}
public ChildContentView() => InitializeComponent();
}
OK 例(修正コード:通知+循環除去)
NewContent1.xaml.cs(正しい:OnPropertyChanged を発火)
public partial class NewContent1 : ContentView
{
private string _inputString = "Ready";
public string InputString
{
get => _inputString;
set
{
if (_inputString == value) return;
_inputString = value;
// ContentView は BindableObject 由来で PropertyChanged を持つ
OnPropertyChanged(nameof(InputString));
}
}
public NewContent1()
{
InitializeComponent();
}
void OnClicked(object sender, EventArgs e)
{
// 複数回押しても子に届く
InputString = DateTime.Now.ToString("T");
}
}
ChildContentView.xaml.cs(正しい:純粋ストア)
public partial class ChildContentView : ContentView
{
public static readonly BindableProperty MyStringProperty =
BindableProperty.Create(
nameof(MyString), typeof(string), typeof(ChildContentView), default(string));
// propertyChanged を付けず「純粋なストア」にする
public string MyString
{
get => (string)GetValue(MyStringProperty);
set => SetValue(MyStringProperty, value);
}
public ChildContentView() => InitializeComponent();
}
ChildContentView.xaml(正しい:表示は XAML でバインド)
<ContentView x:Class="Sample.ChildContentView"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Name="root">
<Grid>
<Label
Text="{Binding Source={x:Reference root}, Path=MyString}"
FontSize="16"
LineBreakMode="WordWrap"/>
</Grid>
</ContentView>
補足:INotifyPropertyChanged を自力で実装するか?
ContentView は既に変更通知を持つため、CLR プロパティ側で OnPropertyChanged を呼ぶだけで十分です。インターフェイスをクラス宣言に重ねて実装する必要は基本的にありません(追加しても害はありませんが、二重管理のリスクが増えます)。
CommunityToolkit.Mvvm で通知を自動化する
通知の書き忘れをゼロにするには、CommunityToolkit.Mvvm の自動実装を使います。NewContent1ViewModel.cs(Toolkit を使った例)
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class NewContent1ViewModel : ObservableObject
{
[ObservableProperty]
private string inputString = "Ready"; // setter で自動的に通知が入る
[RelayCommand]
private void Refresh()
{
InputString = DateTime.Now.ToString("T");
}
}
NewContent1.xaml(ViewModel を BindingContext に)
<ContentView ... xmlns:vm="clr-namespace:Sample"> <ContentView.BindingContext> <vm:NewContent1ViewModel /> </ContentView.BindingContext>
デバッグの即効テクニック
- setter ブレークポイント:
InputStringのsetに止め、OnPropertyChangedが呼ばれているか確認。 - PropertyChanged を監視:一時的に
PropertyChanged += (s,e) => Debug.WriteLine(e.PropertyName);。 - バインディング・デバッグ:XAML に
x:Nameを付けSource={x:Reference x:Name}で対象を明確化。 - Fallback 値:
TargetNullValueやFallbackValueを設定し、解決不能を視覚化。
アンチパターン早見表
| アンチパターン | なぜ危険か | 代替策 |
|---|---|---|
propertyChanged で自分の Setter を呼ぶ | 無限ループ・スタックオーバーフローの温床 | Setter 呼び出し禁止。必要なら UI 要素に直接反映か XAML バインドで表現 |
| バインド ソースが CLR プロパティなのに通知しない | 初回は OneTime 解決でたまたま表示されるが、その後は無反応 | OnPropertyChanged を必ず呼ぶ/Toolkit で自動化 |
| 子ビューで TwoWay を安易に使う | 所有権が曖昧になり意図せぬ上書きが発生 | 原則 OneWay。双方向が必要なら契約(イベント・コールバック)を明示 |
ContentView の表示・非表示(IsVisible)変化を検知できない問題
IsVisible の変化を OnPropertyChanged(nameof(IsVisible)) で拾おうとしても、状況によっては発火しない/拾えないことがあります。複合レイアウト・ナビゲーション・タブ切替・テンプレート再適用などで UI ツリーの接続が変化するためです。ここでは壊れにくい代替を提示します。
| アプローチ | 実装の肝 | メリット / 注意点 |
|---|---|---|
| ① Loaded / Unloaded(.NET 8+) | ツリーへ追加/除去の瞬間を確実に捕捉 | 表示ライフサイクルを素直に追える。ページ遷移・再テンプレート化にも強い |
| ② 独自の可視プロパティ | IsMyViewVisible を BindableProperty で用意し、変化を確実に捕捉 | 既知の IsVisible 依存を避けられる/親から明示制御できる |
| ③ 親ページ主導 | ページの OnAppearing / OnDisappearing で子へ通知 | ナビゲーション単位で必ず通る。責務の分離が明確 |
| ④ SizeChanged ベース | Width > 0 && Height > 0 になった瞬間を可視とみなす | 簡易・汎用。ただしアニメ中の多重発火にケアが必要 |
実装例:Loaded / Unloaded を使う
public partial class ChildContentView : ContentView
{
public ChildContentView()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
private void OnLoaded(object sender, EventArgs e)
{
// ここでタイマ開始・サブスクライブ等を行う
System.Diagnostics.Debug.WriteLine("Child loaded.");
}
private void OnUnloaded(object sender, EventArgs e)
{
// 後始末:イベント解除・タイマ停止・DI 取得したサービスの破棄など
System.Diagnostics.Debug.WriteLine("Child unloaded.");
Loaded -= OnLoaded;
Unloaded -= OnUnloaded;
}
}
実装例:独自の可視プロパティで検知
public partial class ChildContentView : ContentView
{
public static readonly BindableProperty IsMyViewVisibleProperty =
BindableProperty.Create(
nameof(IsMyViewVisible), typeof(bool), typeof(ChildContentView), false,
propertyChanged: (b, o, n) =>
{
var view = (ChildContentView)b;
view.OnMyVisibleChanged((bool)n);
});
public bool IsMyViewVisible
{
get => (bool)GetValue(IsMyViewVisibleProperty);
set => SetValue(IsMyViewVisibleProperty, value);
}
private void OnMyVisibleChanged(bool isVisible)
{
// 表示・非表示に応じた処理
// 例:アニメ開始/停止、データ購読の開始/解除など
}
}
親側でこのプロパティを制御します:
<local:ChildContentView
IsMyViewVisible="{Binding IsDetailsVisible}" />
実装例:親ページ主導
public interface IVisibilityAware
{
void OnAppeared();
void OnDisappeared();
}
// 子ビュー
public partial class ChildContentView : ContentView, IVisibilityAware
{
public void OnAppeared()
{
// 表示に入った
}
public void OnDisappeared()
{
// 非表示に入った
}
}
// 親ページ
public partial class HostPage : ContentPage
{
public HostPage() => InitializeComponent();
protected override void OnAppearing()
{
base.OnAppearing();
(child as IVisibilityAware)?.OnAppeared();
}
protected override void OnDisappearing()
{
(child as IVisibilityAware)?.OnDisappeared();
base.OnDisappearing();
}
}
実装例:SizeChanged で推測
public ChildContentView()
{
InitializeComponent();
SizeChanged += (_, __) =>
{
if (Width > 0 && Height > 0)
{
// 実質的に「描画されうる状態」になった
}
};
}
可視状態に関する追加 Tips
- アニメと IsVisible:
FadeToなどのアニメ終了後にIsVisibleを切り替えるとチラつきやイベント順序のズレが減ります。 - 「見えない」の定義:
Opacity = 0やInputTransparent = trueは「不可視」ではありません。ロジック上の可視判定と UI の見た目を混同しないように。 - メモリリーク対策:
Loaded/Unloaded・イベント購読は必ず対で解除。
MVVM ライブラリを活用した実装簡素化(補足)
CommunityToolkit.Mvvm の ObservableObject と [ObservableProperty] を使うと、プロパティ変更通知とバッキング フィールドを自動生成できます。さらに [RelayCommand] でコマンドも最小記述に。
| 観点 | Code-behind | MVVM(Toolkit) |
|---|---|---|
| 変更通知の書き忘れ | 起こりやすい(手書き) | 起こりにくい(自動生成) |
| テスト容易性 | 低い(UI に依存) | 高い(VM 単体テスト可) |
| 保守性 | ロジックが分散しがち | 責務分離で見通しが良い |
最小ユニットテスト例(ViewModel)
[Fact]
public void InputString_Changes_RaisesNotification()
{
var vm = new NewContent1ViewModel();
var raised = false;
vm.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(vm.InputString)) raised = true; };
vm.InputString = "Hello";
Assert.True(raised);
}
実践チェックリスト(貼って使える)
- バインド元が CLR プロパティなら、setter で
OnPropertyChangedを確実に呼んでいるか。 - 子ビューの BindableProperty は「純粋ストア」設計か(コールバックで自分の Setter を呼んでいないか)。
- Label 等の表示は
RelativeSource/x:Referenceで 所有者のプロパティを直接バインドしているか。 - 双方向更新が必要なら、契約を明示(イベント、コマンド、コールバック)しているか。
IsVisible検知は Loaded/Unloaded・独自プロパティ・親ページ主導のいずれかで堅牢化しているか。- イベント購読は 必ず解除(メモリリーク防止)。
- デバッグ時は Fallback/TargetNullValue を設定して解決不能を可視化。
参考:完全ミニマルな構成(コピペ用)
このサンプルは「複数回の更新が必ず子ビューに届く」「自己代入ループなし」「Loaded/Unloaded 対応」の 3 点を満たします。
ChildContentView.xaml
<ContentView x:Class="Sample.ChildContentView"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Name="root">
<Frame Padding="8" HasShadow="False" BorderColor="{StaticResource Gray400}">
<Label Text="{Binding Source={x:Reference root}, Path=MyString}"
FontSize="18" />
</Frame>
</ContentView>
ChildContentView.xaml.cs
public partial class ChildContentView : ContentView
{
public static readonly BindableProperty MyStringProperty =
BindableProperty.Create(nameof(MyString), typeof(string), typeof(ChildContentView), default(string));
public string MyString
{
get => (string)GetValue(MyStringProperty);
set => SetValue(MyStringProperty, value);
}
public ChildContentView()
{
InitializeComponent();
Loaded += (_, __) => Debug.WriteLine("Child loaded.");
Unloaded += (_, __) => Debug.WriteLine("Child unloaded.");
}
}
NewContent1.xaml
<ContentView x:Class="Sample.NewContent1"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Sample"
x:Name="Self">
<VerticalStackLayout Spacing="12">
<Entry Placeholder="Type here..."
Text="{Binding InputString, Source={x:Reference Self}}" />
<Button Text="更新(現在時刻)" Clicked="OnClicked" />
<local:ChildContentView
MyString="{Binding InputString, Source={x:Reference Self}}" />
NewContent1.xaml.cs
public partial class NewContent1 : ContentView
{
private string _inputString = "Ready";
public string InputString
{
get => _inputString;
set
{
if (_inputString == value) return;
_inputString = value;
OnPropertyChanged(nameof(InputString)); // 🔔 通知
}
}
public NewContent1() => InitializeComponent();
void OnClicked(object sender, EventArgs e)
{
InputString = $"Updated: {DateTime.Now:T}";
}
}
よくある質問(Q&A)
Q. 子ビューの propertyChanged では何をして良い?
自分の Setter を呼ぶのは NG。代わりに「UI への反映」など 副作用のみに限定しましょう。より安全なのは XAML の相対バインドで UI に渡すことです。
Q. OnPropertyChanged を呼んでいるのに更新されないことがある?
バインドの Source が意図したオブジェクトになっていない(BindingContext が切り替わった、RelativeSource の対象が変わった等)可能性があります。x:Reference を使い、確定的に同一インスタンスを指すようにしましょう。
Q. 初回だけ表示されて二回目以降ダメなのはなぜ?
初回は XAML の静的解決(OneTime に近い挙動)で値が入るため「たまたま見えている」ことがあります。以後の差分は通知が無い限り伝搬せず、更新が止まります。
Q. IsVisible ではなく Opacity で管理しても良い?
目的次第です。ユーザー操作不可にしたい・レイアウトから除外したい場合は IsVisible を、視覚だけ隠したいなら Opacity を。ロジックの可視判定に Opacity を使うのは避けましょう。
まとめ
- バインドが更新されないときは、まず
OnPropertyChangedの発火有無を確認。CommunityToolkit.Mvvm で自動化するとミスが減ります。 - 子ビューの
BindablePropertyは「純粋ストア」に徹し、自己代入ループを作らない。表示は XAML の相対バインドで受ける。 - 可視状態検知は Loaded/Unloaded・独自プロパティ・親ページ主導へ寄せると壊れにくい。
- イベント購読の解除・責務の分離・テスト容易性の確保で、MAUI 9 における複数 ContentView の安全なデータ連携と状態管理が実現できます。

コメント