C# WPF аналог Window.ShowDialog() или разбираемся с DispatcherFrame

Постановка задачи


В рамках разработки одного приложения потребовалось реализовать такую схему:


  1. Асинхронный метод запрашивает данные
  2. Пользователь вводит данные с клавиатуры
  3. Метод получает результат ввода как результат выполнения функции и продолжает с того же места

Дополнительные требование: Не создавать дополнительных окон.


Казалось бы, просто? Как оказалось, действительно просто. Но обо всём по порядку.


Решение


Первая попытка сделать это в лоб и без поиска в интернете привела к блокировке основного потока, и, следовательно, ни к чему хорошему. И я уже собирался использовать ShowDialog, как наткнулся на статью. Автор заглянул в то, как сделан ShowDialog в WPF. То, что нужно!


В своей статье он предлагает создать собственную имплементацию метода ShowDialog


[DllImport("user32")]
internal static extern bool EnableWindow(IntPtr hwnd, bool bEnable);

public void ShowModal()
{
    IntPtr handle = (new WindowInteropHelper(Application.Current.MainWindow)).Handle;
    EnableWindow(handle, false);

    DispatcherFrame frame = new DispatcherFrame();

    this.Closed += delegate
    {
        EnableWindow(handle, true);
        frame.Continue = false;
    };

    Show();
    Dispatcher.PushFrame(frame);
}

Мне же не требуется блокировка окна, так как всё показывается в одном окне, а так же требуется возвращаемое значение. Убираем немного лишнего, добавляем нужное...


        public string GetInput()
        {
            var frame = new DispatcherFrame();

            ButtonClicked += () => { frame.Continue = false; };

            Dispatcher.PushFrame(frame);
            return Text;
        }

Dispatcher.PushFrame(frame) предотвращает выход из метода GetInput() до тех пор, пока frame.Continue не станет false. Когда новый фрейм запушен, главный цикл приостанавливается и запускается новый. Этот цикл обрабатывает системные сообщения, в то время как точка выполнения в главном цикле не движется дальше. Когда мы выходим из текущего фрейма (frame.Continue = false), главный цикл продолжает работу с того же места.


Теперь осталось лишь проверить работоспособность.


В MainWindow создадим кнопку и повесим на нее обработчик, который запустит таск, в котором мы и обратимся к вводу с клавиатуры.


Код обработчика:


 public RelayCommand ButtonClick => new RelayCommand(() =>
        {
            Task.Factory.StartNew(() =>
            {
                // имитация работы
                Thread.Sleep(1000);

                // создадим контрол-обработчик ввода
                var control = new PopupControlModel();

                // вызов метода, который останавливает выполнение главного цикла
                Result = control.GetInput();

                // имитация дальнейшей работы
                Thread.Sleep(2000);
            });
        });
    }

Я использовал это решение для ввода пользователем капчи и дополнительного кода при двухфакторной авторизации. Но применений может быть огромное количество.


! В коде примера содержатся нарушения принципа mvvm, и не бейте сильно отсутствует дизайн


Исходный код на github: Proof of concept


Полезные ссылки


Статья "Кастомный ShowDialog"
Скудное описание класса DispatcherFrame с применением машинного перевода
Ожидание завершения через await приведено в этой статье

  • +11
  • 2,1k
  • 3
Поделиться публикацией

Комментарии 3

    +1
    // ... 
        EnableWindow(handle, false);
    
        DispatcherFrame frame = new DispatcherFrame();
    
        this.Closed += delegate
        {
            EnableWindow(handle, true);
    // ...
    


    Плохой подход. Я не увидел (в C# не силен, может где-то неявно?), что фрейм дочерний по отношению к WindowInteropHelper(Application.Current.MainWindow). Это значит, что если вдруг этот маленький диалог попадет по Z-Order за MainWindow, то вернуть его оттуда будет сложно, т.к. MainWindow неактивно.
    И второй момент с EnableWindow: нельзя так делать! Ниже правильный код:
    BOOL fIsWindowEnabled = ! EnableWindow( handle, false );
    // ...
    EnableWindow( handle, fIsWindowEnabled );
    


    Но это скорее к автору на CodeProject
      0
      Может я не понял цели данного проекта,
      но ИМХО ваш код не работает:
      Я просто создал на вашей форме еще одну кнопку и отодвинул диалог выровняв область его контента по верху окна.

      Во время диалога — ничего не мешает пользоваться остальным UI (т.е. это уже не диалог)
      В теории пользователь может обойти вашу капчу простым нажатием tab-tab — enter, пусть ему и не будут видны фокусы и сами элементы под диалогом, а есть еще Snoop который позволит снять IsEnabled с кнопки, который там не нужен:
      — Кнопку блокируете через дополнительный IsEnabled="{Binding ButtonEnabled}",
      хотя у вас же есть Command="{Binding ButtonClick}" (команда сама устанавливает видимость кнопки когда активна, а когда нет, если правильно установить область видимости команды, Snoop не поможет «взломщику», при таком сценарии т.к. даже если он снимет IsEnabled, команда всё равно будет не активной)

      Т.е. по факту у вас обычный ContentPresenter поверх UI в который вы грузите UserControl.
        0

        Кажется, вы сделали не совсем то что задумывалось.


        Вызов Dispatcher.PushFrame, конечно же, запускает новый цикл — вот только он запускается в текущем потоке. А вы только что перешли в пул потоков вызовом Task.Factory.StartNew! В итоге у вас работают два цикла сообщений, причём второй цикл работает полностью вхолостую, ведь к нему не привязано ни одного окна. И это вам ещё повезло что никаких проблем с многопоточностью не словили...


        Вторая ошибка — вы каждый раз подписываетесь на ButtonClicked, а кто отписываться будет?


        Правильнее для таких задач использовать асинхронность и TaskCompletionSource:


        public async Task<string> GetInput()
        {
            var tcs = new TaskCompletionSource<string>();
        
            Action handler = () => tcs.SetResult(Text);
        
            try 
            {
                ButtonClicked += handler;
                return await tcs.Task;
            }
            finally
            {
                ButtonClicked -= handler;
            }
        }
        
        public RelayCommand ButtonClick => new RelayCommand(async () => 
        {
            await Task.Run(() => {
                        // имитация работы
                        Thread.Sleep(1000);
            });
        
            var control = new PopupControlModel();
            Result = await control.GetInput();
        
            await Task.Run(() => {
                        // имитация дальнейшей работы
                        Thread.Sleep(1000);
            });
        });

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое