Постановка задачи
В рамках разработки одного приложения потребовалось реализовать такую схему:
- Асинхронный метод запрашивает данные
- Пользователь вводит данные с клавиатуры
- Метод получает результат ввода как результат выполнения функции и продолжает с того же места
Дополнительные требование: Не создавать дополнительных окон.
Казалось бы, просто? Как оказалось, действительно просто. Но обо всём по порядку.
Решение
Первая попытка сделать это в лоб и без поиска в интернете привела к блокировке основного потока, и, следовательно, ни к чему хорошему. И я уже собирался использовать 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)
cyber_roach
17.05.2019 12:50Может я не понял цели данного проекта,
но ИМХО ваш код не работает:
Я просто создал на вашей форме еще одну кнопку и отодвинул диалог выровняв область его контента по верху окна.
Во время диалога — ничего не мешает пользоваться остальным UI (т.е. это уже не диалог)
В теории пользователь может обойти вашу капчу простым нажатием tab-tab — enter, пусть ему и не будут видны фокусы и сами элементы под диалогом, а есть еще Snoop который позволит снять IsEnabled с кнопки, который там не нужен:
— Кнопку блокируете через дополнительный IsEnabled="{Binding ButtonEnabled}",
хотя у вас же есть Command="{Binding ButtonClick}" (команда сама устанавливает видимость кнопки когда активна, а когда нет, если правильно установить область видимости команды, Snoop не поможет «взломщику», при таком сценарии т.к. даже если он снимет IsEnabled, команда всё равно будет не активной)
Т.е. по факту у вас обычный ContentPresenter поверх UI в который вы грузите UserControl.
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); }); });
loginsin
Плохой подход. Я не увидел (в C# не силен, может где-то неявно?), что фрейм дочерний по отношению к WindowInteropHelper(Application.Current.MainWindow). Это значит, что если вдруг этот маленький диалог попадет по Z-Order за MainWindow, то вернуть его оттуда будет сложно, т.к. MainWindow неактивно.
И второй момент с EnableWindow: нельзя так делать! Ниже правильный код:
Но это скорее к автору на CodeProject