WPF/MVVMでUserControlのVisibilityをバインディングで切り替える方法とDataContextの落とし穴

WPF でログイン画面などの複数画面を切り替えるとき、「UserControl の Visibility を ViewModel にバインドして制御したい」というニーズはとてもよくあります。ところが、バインディングもプロパティも一見正しく見えるのに、なぜか UserControl が表示されない……というハマりどころも鉄板です。ここでは、実際によくある「DataContext を子 UserControl で new してしまった」ケースを題材に、原因の仕組みと最小限の修正方法、そしてもう一歩進んだ MVVM 的な改善案まで、実装例付きで丁寧に整理していきます。

目次

WPF/MVVMで「UserControl の Visibility を ViewModel で切り替えたい」シナリオ

今回の前提となる構成は、次のような典型的なログイン画面です。

  • 親ビュー:UserView.xaml
  • 子 UserControl:
    • サインイン用:signinUC(例:SignInView.xaml)
    • パスワード変更用:resetPwdUC(例:ResetPwdView.xaml)
  • 親 ViewModel:UserViewModel

要件はシンプルです。

  • 最初はサインイン画面(signinUC)を表示する
  • 認証後、「初期パスワードだった場合」はパスワード変更画面(resetPwdUC)を表示する
  • パスワードを変更したら、再びサインイン画面(signinUC)に戻す

これを MVVM で実装するために、多くの方が次のような構造を採用します。

  • UserViewModel に SignInView / ResetPwdView という Visibility 型プロパティを持たせる
  • 親ビューの XAML で、各 UserControl の Visibility をそのプロパティにバインドする
<controls:signinUC Visibility="{Binding SignInView}" />
<controls:resetPwdUC Visibility="{Binding ResetPwdView}" />

ところが実際に動かしてみると、

  • signinUC はちゃんと非表示になる
  • にもかかわらず resetPwdUC はいつまでたっても表示されない

という不可解な挙動に遭遇します。デバッグログでは ResetPwdView == Visible と出ているのに、画面はなにも変わらない……。この状態のまま数時間溶かした経験、ありませんか?

結論:子 UserControl での DataContext = new UserViewModel() が全ての元凶

この症状の原因はとてもシンプルで、かつ超頻出のパターンです。

子 UserControl(signinUC / resetPwdUC)のコンストラクタで、DataContext = new UserViewModel() を書いてしまっている ことが原因です。

// ありがちな NG パターン
public partial class SignInView : UserControl
{
    public SignInView()
    {
        InitializeComponent();
        DataContext = new UserViewModel(); // ← これが犯人
    }
}

public partial class ResetPwdView : UserControl
{
    public ResetPwdView()
    {
        InitializeComponent();
        DataContext = new UserViewModel(); // ← これも犯人
    }
}

Accepted Answer としては非常に簡単で、

  • 子 UserControl の DataContext = new UserViewModel() を全部削除する

これだけで、Visibility の切り替えが素直に動くようになります。

なぜそれだけで直るのか? DataContext の継承ルールを理解する

WPF の DataContext は、基本的に「親から子へ継承」されます。親の UserControl に ViewModel をセットすると、その下にぶら下がる要素が同じ ViewModel インスタンスを共有する、という仕組みです。

今回の正しい構成は次のようになります。

// 親 UserControl だけが DataContext を設定する
public partial class UserView : UserControl
{
    public UserView()
    {
        InitializeComponent();
        DataContext = new UserViewModel(); // ここで 1 回だけ生成
    }
}

そして、子 UserControl は DataContext を一切触らず、親からの継承に任せます。

// OK パターン:DataContext は何も設定しない
public partial class SignInView : UserControl
{
    public SignInView()
    {
        InitializeComponent();
    }
}

public partial class ResetPwdView : UserControl
{
    public ResetPwdView()
    {
        InitializeComponent();
    }
}

これで、

  • UserView が持っている UserViewModel のインスタンス
  • signinUC が参照する DataContext
  • resetPwdUC が参照する DataContext

のすべてが同じインスタンスになります。結果として、ViewModel の SignInView / ResetPwdView を更新すると、両方の UserControl の Visibility が同じ値を参照して正しく切り替わるわけです。

逆に子で new UserViewModel() してしまうと何が起こるか

子 UserControl で DataContext = new UserViewModel() を書いてしまうと、次のような状態になります。

場所保持している ViewModel インスタンス
親ビュー UserViewインスタンス A(new UserViewModel())
子ビュー signinUCインスタンス B(new UserViewModel())
子ビュー resetPwdUCインスタンス C(new UserViewModel())

つまり、見た目は全部 UserViewModel ですが、実体は 3 つの別々のオブジェクトです。このとき、

  • 認証処理は親が持っているインスタンス A のプロパティを更新している
  • しかし signinUC はインスタンス B の SignInView を見ている
  • resetPwdUC はインスタンス C の ResetPwdView を見ている

……という破綻した状態になります。
結果として、

  • コンソールログ:インスタンス A の ResetPwdView が Visible になったことを表示
  • UI:インスタンス C の ResetPwdView は一度も変更されていないので表示が変わらない

という「コード上は正しく見えるのに画面が変わらない」状況が発生します。
「型」ではなく「インスタンス」が一致しているかどうかが非常に重要なポイントです。

最小修正例:子 UserControl の DataContext 設定を削除する

それでは、実際に最低限どこを直せばよいかをまとめます。

場所修正前(NG)修正後(OK)
親ビュー UserView.xaml.cspublic partial class UserView : UserControl { public UserView() { InitializeComponent(); DataContext = new UserViewModel(); } }// そのままで OK(親だけが DataContext を設定)
子ビュー SignInView.xaml.cspublic partial class SignInView : UserControl { public SignInView() { InitializeComponent(); DataContext = new UserViewModel(); // ← 削除対象 } }public partial class SignInView : UserControl { public SignInView() { InitializeComponent(); // DataContext はいじらない } }
子ビュー ResetPwdView.xaml.cspublic partial class ResetPwdView : UserControl { public ResetPwdView() { InitializeComponent(); DataContext = new UserViewModel(); // ← 削除対象 } }public partial class ResetPwdView : UserControl { public ResetPwdView() { InitializeComponent(); // DataContext はいじらない } }

XAML 側の Visibility バインディングはそのままで構いません。

<controls:signinUC Visibility="{Binding SignInView}" />
<controls:resetPwdUC Visibility="{Binding ResetPwdView}" />

これだけで、SignInView / ResetPwdView の値変更に合わせて UserControl の表示・非表示が切り替わるようになります。

補足:PasswordBox.PasswordChanged での ViewModel 参照はどうするか

ログイン画面では PasswordBox を使うことが多く、Password プロパティを直接バインドできないため、

  • PasswordChanged イベントハンドラで ViewModel にパスワードを流し込む

という実装もよく使われます。

<PasswordBox
    PasswordChanged="PasswordBox_OnPasswordChanged" />
private void PasswordBox_OnPasswordChanged(object sender, RoutedEventArgs e)
{
    var vm = this.DataContext as UserViewModel;
    if (vm == null) return;

    var passwordBox = sender as PasswordBox;
    vm.Password = passwordBox?.Password ?? string.Empty;
}

このコードは、子 UserControl であっても DataContext を自分で new しない限り、親から継承された UserViewModel インスタンスをちゃんと参照してくれます。
つまり、

  • 「新しい ViewModel を生成しない」ことさえ守れば、このイベントハンドラはそのまま利用可能

ということになります。

Visibility プロパティ設計のパターン整理

今回のように Visibility を直接 ViewModel のプロパティにする方法は、わかりやすい反面、やや扱いづらい部分もあります。代表的なパターンと特徴を整理してみます。

パターンプロパティ型特徴向いているケース
Visibility 直バインドVisibilityコード側で Visible/Collapsed を直接設定。XAML がシンプル。画面数が少なく、単純に出し分けしたい場合
bool + コンバーターboolBooleanToVisibilityConverter で変換。ロジック上は true/false のみ扱えば良い。ビジネスロジック的には「表示フラグ」の方が自然なとき
ContentControl + DataTemplate任意の型Content の型に応じてビューを自動切り替え。MVVM 的に最も柔軟。画面数が増える、状態が複雑になる、大規模な画面構成

特に、WPF らしさ・MVVM らしさを出したい場合は、次に紹介する ContentControl + DataTemplate パターンが強力です。

より MVVM らしく:ContentControl + DataTemplate で画面を切り替える

Visibility を個別に制御する代わりに、

  • ViewModel に「現在表示する画面」を表すプロパティ(例:CurrentView)を持たせる
  • 親ビューには ContentControl を 1 つ置くだけにする
  • 中身は DataTemplate で「型に応じて」自動的に切り替える

という構成にすると、画面が増えてもシンプルに保ちやすくなります。

ViewModel 側の例

public class UserViewModel : INotifyPropertyChanged
{
    private object _currentView;

    public object CurrentView
    {
        get => _currentView;
        set
        {
            if (_currentView == value) return;
            _currentView = value;
            OnPropertyChanged(nameof(CurrentView));
        }
    }

    public SignInViewModel SignInVM { get; } = new SignInViewModel();
    public ResetPwdViewModel ResetPwdVM { get; } = new ResetPwdViewModel();

    public UserViewModel()
    {
        // 初期状態はサインイン画面
        CurrentView = SignInVM;
    }

    public void OnSignInSucceeded(bool isInitialPassword)
    {
        if (isInitialPassword)
        {
            // 初期パスワードならパスワード変更画面へ
            CurrentView = ResetPwdVM;
        }
        else
        {
            // 通常ログインならそのまま
        }
    }

    public void OnPasswordResetCompleted()
    {
        // パスワード変更が終わったらサインイン画面に戻す
        CurrentView = SignInVM;
    }

    // INotifyPropertyChanged 実装は省略
}

ビュー側 XAML の例

親ビューでは ContentControl を 1 つ置き、Content を CurrentView にバインドします。

<UserControl
    x:Class="Sample.Views.UserView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:vm="clr-namespace:Sample.ViewModels"
    xmlns:views="clr-namespace:Sample.Views">

    <UserControl.Resources>
        <DataTemplate DataType="{x:Type vm:SignInViewModel}">
            <views:SignInView />
        </DataTemplate>

        <DataTemplate DataType="{x:Type vm:ResetPwdViewModel}">
            <views:ResetPwdView />
        </DataTemplate>
    </UserControl.Resources>

    <Grid>
        <ContentControl Content="{Binding CurrentView}" />
    </Grid>
</UserControl>

CurrentView に SignInViewModel をセットすれば自動的に SignInView が表示され、
ResetPwdViewModel をセットすれば自動的に ResetPwdView に切り替わります。

この方式のメリットは次の通りです。

  • Visibility の組み合わせを ViewModel で細かく管理する必要がない
  • 画面が増えても「ViewModel と View のペア」を追加するだけで拡張しやすい
  • View ごとに専用の ViewModel を用意できるため、責務分割が明確になる

特にログインフローが

  • サインイン
  • 2段階認証コード入力
  • 初回パスワード変更
  • 利用規約同意

のようにステップが増えていく場合、この方式にしておくとコードの見通しが大きく変わります。

Visibility を使う場合の実装パターン:bool + コンバーター

既存プロジェクトの方針などで「とりあえず Visibility プロパティ方式のまま行きたい」という場合でも、内部ロジックは bool を使い、XAML 側で Visibility に変換すると扱いやすくなります。

XAML にコンバーターを用意

<UserControl.Resources>
    <BooleanToVisibilityConverter x:Key="BoolToVis" />
</UserControl.Resources>

<Grid>
    <controls:signinUC Visibility="{Binding IsSignInVisible,
                                             Converter={StaticResource BoolToVis}}" />
    <controls:resetPwdUC Visibility="{Binding IsResetVisible,
                                             Converter={StaticResource BoolToVis}}" />
</Grid>

ViewModel 側のプロパティ

private bool _isSignInVisible = true;
public bool IsSignInVisible
{
    get => _isSignInVisible;
    set
    {
        if (_isSignInVisible == value) return;
        _isSignInVisible = value;
        OnPropertyChanged(nameof(IsSignInVisible));
    }
}

private bool _isResetVisible;
public bool IsResetVisible
{
    get => _isResetVisible;
    set
    {
        if (_isResetVisible == value) return;
        _isResetVisible = value;
        OnPropertyChanged(nameof(IsResetVisible));
    }
}

public void SwitchToResetPassword()
{
    IsSignInVisible = false;
    IsResetVisible = true;
}

bool であれば、「サインイン画面が表示されているか?」という論理をテストコードで扱いやすくなりますし、「Visible か Collapsed かは UI の都合」という責務分割にもなります。

イベントはコマンド化してテストしやすくする

MVVM では、ボタンのクリックなどは ICommand を通じて ViewModel に伝えるのが定番です。今回の例でいえば、

  • 「サインイン」ボタン押下 → SignInCommand
  • 「パスワード変更」ボタン押下 → ResetPasswordCommand

のようにコマンド化しておくと、次のような利点があります。

  • ViewModel 単体で「ログイン成功時に CurrentView が切り替わるか?」といったテストが書きやすい
  • 画面の挙動を XAML と ViewModel に閉じ込められる(コードビハインドが痩せる)
  • バインディングエラーが起きても XAML 側で把握しやすい

簡単な RelayCommand の例と、サインイン処理をコマンド化したサンプルを示します。

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Func<object?, bool>? _canExecute;

    public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;

    public void Execute(object? parameter) => _execute(parameter);

    public event EventHandler? CanExecuteChanged;
}
public class UserViewModel : INotifyPropertyChanged
{
    public ICommand SignInCommand { get; }

    public UserViewModel()
    {
        SignInCommand = new RelayCommand(_ => SignIn());
    }

    private void SignIn()
    {
        // 認証処理
        // var result = Authenticate(UserId, Password);

        bool isInitialPassword = true; // 仮
        if (/* 認証成功 */ true)
        {
            OnSignInSucceeded(isInitialPassword);
        }
    }

    // OnSignInSucceeded は前述の CurrentView 切替処理など
}
<Button Content="サインイン"
        Command="{Binding SignInCommand}" />

このように、画面切り替えの状態遷移を ViewModel に集約しておくことで、DataContext の設計ミス(今回のような new しすぎ問題)を早い段階で検出しやすくなります。

Visibility 切り替えでハマりがちなその他のポイント

最後に、Visibility を使ったビュー切り替えでありがちな落とし穴をまとめておきます。今回の DataContext 問題と併せてチェックしておくと、トラブルシューティングの効率がかなり良くなります。

ポイントありがちな症状対処のヒント
Hidden と Collapsed の違い見えないのに画面のレイアウトが崩れる / 余白だけ残るHidden はレイアウト上は場所を取る。基本的には Collapsed を使うと扱いやすい。
Grid 上の重なり順どちらも Visible にすると、どちらが表示されているのか分からない原則として「必ずどちらか一方のみ Visible」にする。重ねる場合は ZIndex も確認。
バインディングエラーデバッグ出力に BindingExpression path error が出るプロパティ名のスペルミスや DataContext の型不一致をチェック。nameof() で通知する癖をつける。
INotifyPropertyChanged の未実装初期表示は正しいが、その後の変更が UI に反映されないOnPropertyChanged(nameof(SignInView)) の呼び忘れを確認。
DataContext の上書き今回のように、一部の UserControl だけ挙動が異なる子 UserControl のコンストラクタや Loaded イベントで DataContext = ... していないか確認。

チェックリスト:本番前に確認しておきたい項目

最後に、今回のケースに直接関係するチェックポイントをリストアップしておきます。開発中・リファクタリング時のセルフレビューにそのまま使えます。

  • 子 UserControl(signinUC / resetPwdUC)に DataContext = new UserViewModel() が残っていないか
  • 親の UserView だけが DataContext を 1 度だけ設定しているか
  • SignInView と ResetPwdView の Visibility が「排他的」に設定されているか(両方 Visible になるパスがないか)
  • INotifyPropertyChanged の通知が nameof を使って安全に書かれているか
  • デバッグ出力にバインディングエラーが出ていないか(特にプロパティ名のスペルミス)
  • 可能であれば ContentControl + DataTemplate への移行を検討しているか

まとめ:WPF/MVVM での UserControl 切り替えは DataContext 設計がすべて

ここまで、ログイン画面を例に、

  • UserControl の Visibility を ViewModel のプロパティで切り替えたい
  • しかし、なぜか片方の画面が表示されない

という問題の原因と解決策を見てきました。ポイントを改めて整理すると、次の通りです。

  • 原因:子 UserControl が DataContext = new UserViewModel() で独自に ViewModel を生成していた
  • 結果:親と子で別々の ViewModel インスタンスを参照しており、親がプロパティを変えても子のバインディング先は変わらなかった
  • 最小解決策:子 UserControl の DataContext 設定を削除し、親 UserView が 1 つの ViewModel インスタンスを持つようにする
  • 推奨パターン:中長期的には ContentControl + DataTemplate を使った画面切り替えに移行すると、MVVM 的にすっきり保守しやすい

WPF は「同じ型の ViewModel を new しても意味が違う」世界です。画面をまたいで共有すべき情報は、必ず同じインスタンスを共有するように DataContext の設計を意識することが、MVVM アーキテクチャを長く保守していくうえでの重要なポイントになります。

もしあなたのプロジェクトでも「Visibility で切り替えているのに画面が出てこない」「ログイン後に別画面に遷移しない」といった症状が出ていたら、まずは子 UserControl のコンストラクタを開き、

「つい癖で書いてしまった DataContext = new ... が紛れ込んでいないか?」

を確認してみてください。それだけで、長年のモヤモヤが一瞬で解決するかもしれません。

この記事を書いた人

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

コメント

コメントする

目次