🌀

MaterialDesignInXamlToolkitのDialogHostとTabControlで発生する「2回目から操作不能」のバグ

に公開

はじめに

WPF + MaterialDesignInXamlToolkit を使用して、Microsoft Storeで公開中の個人開発アプリ(Keikaku)のダイアログを実装していたところ、「1回目に開いたときは正常なのに、2回目以降に開くとダイアログ内の要素にマウスでフォーカスが移せなくなる」という不可解な不具合に遭遇しました。

原因は DialogHost のフォーカス復元処理と TabControl の組み合わせにある既知の副作用で、2018年から未解決のまま残っている issue でした。同じ構成で消耗する人が出ないよう、原因と回避策を記録しておきます。

起きたこと

プロジェクト情報を編集するダイアログ画面で、項目が増えてきたので TabControl でセクション分けしようとしました。実装直後の動作確認では特に問題なく動きます。ところが、以下のような挙動が発生しました。

  1. ダイアログを開いて OK または Cancel で閉じる
  2. もう一度同じダイアログを開く
  3. ダイアログ内の TextBoxDatePicker をクリックしてもフォーカスが移らない(裏にある DataGrid などにフォーカスが残ったまま)

という状態です。1回目は再現しないため、実装直後の確認だけではまず気づけません。ダイアログを開閉するテストケースを意識的に通すことで初めて発覚するタイプの不具合です。

原因: DialogHostのフォーカス復元とTabItem.Focus()の副作用

materialDesign:DialogHost はダイアログを開く際、その時点でフォーカスを持っていた要素を FocusManager.GetFocusedElement(window) でウィンドウ単位の論理フォーカスとして記憶し、ダイアログを閉じるときにその要素へ .Focus() を呼び戻して「開く前の状態」に復元しようとします。

ここで重要なのは TabItem の挙動です。WPFでは TabItem に対して .Focus() を呼び出すと、単にキーボードフォーカスが移るだけでなく タブの選択状態を切り替える副作用 があります。ダイアログのコンテンツに TabControl が含まれていると、DialogHostが記憶・復元しようとするフォーカス対象が TabItem (またはその配下の要素)になり得るため、ウィンドウ全体の論理フォカスの記憶がこの副作用によって汚染されます。結果として、2回目以降にダイアログを開いたときにフォーカスの整合性が崩れ、マウス操作でダイアログ内へフォーカスを移せなくなります。

同種の問題は MaterialDesignInXamlToolkit 本体の issue でも報告されています。

この issue で報告されているのは「TabControl の中に DialogHost を置き、タブ切り替えのタイミングでダイアログを表示すると、ダイアログを閉じた瞬間に元のタブへ勝手に戻ってしまう」という、今回の症状とは逆向きの配置のケースです。ですが根本原因は共通していて、DialogHostが閉じる際に呼ぶ「フォーカス復元用の .Focus()」が TabItem に対して行われると、タブ選択のロジックが意図せずトリガーされてしまう点にあります。issue 内では _restoreFocusDialogClose がTabItemかどうかを判定してスキップする、といった回避案が挙がっていますが、正式な修正は本記事執筆時点でまだ取り込まれていません。

つまり DialogHostとTabControl(TabItem)の組み合わせは、どちら側にどちらを内包させても、フォーカス復元処理と干渉して壊れ得る と考えておくのが安全です。私から言えるのは、この組み合わせ自体を避けるのが最も確実な回避策と言えます。

回避策

ダイアログのコンテンツに TabControl を使うのをやめ、StackPanel + 見出し用の TextBlock (必要なら ScrollViewer でスクロール可能に)で、セクションを縦に並べる構成に変更しました。

セクションの見出しは TextBlock で代用しています。

<!-- TabControlはMaterialDesignInXamlToolkitの既知の不具合
     (DialogHostがフォーカス復元時にTabItem.Focus()を呼び出すと
     タブ選択状態が壊れる問題, GitHub Issue #1121)により
     ダイアログを2回目以降開いた際に操作不能になるため使用しない。
     セクションはStackPanelで縦に並べる。 -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
    <StackPanel>
        <TextBlock Text="基本情報" Style="{DynamicResource MaterialDesignSubtitle2TextBlock}" />
        <Grid><!-- 開始日・終了日・カレンダーの国 など --></Grid>

        <TextBlock Text="概要" Style="{DynamicResource MaterialDesignSubtitle2TextBlock}" />
        <StackPanel><!-- TextBoxなど --></StackPanel>
    </StackPanel>
</ScrollViewer>

まとめ

  • DialogHost は開閉時にウィンドウの論理フォーカスを記憶・復元する仕組みを持つ
  • TabItem.Focus() にはタブ選択を切り替える副作用があり、DialogHostのフォーカス復元と組み合わさるとフォーカス管理が壊れる
  • 症状は「1回目は正常、2回目以降に再現」という気づきにくい形で出るため、ダイアログの開閉を繰り返す動作確認が有効です。エッジケースのテストも重要になりますね。
  • 該当の Issue #1121 は2018年から未解決の状態です。
  • 対策は「DialogHost配下ではTabControlを使わず、StackPanelなどで代替する」ことです。なお通常のウィンドウ内(DialogHostの外)で使うTabControl、例えばFluent.RibbonのようなリボンUI内のタブには影響しないので安心してください。

今回の件は技術的に枯れてると思っていたので、思いもよらない挙動で焦りました。こうした既知の副作用があるコンポーネントの組み合わせには注意が必要ですね。

Discussion