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


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


  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 приведено в этой статье

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


  1. loginsin
    17.05.2019 12:07
    +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


  1. cyber_roach
    17.05.2019 12:50

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

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

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


  1. mayorovp
    17.05.2019 14:20

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


    Вызов 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);
        });
    });