Навигация по диалогам - QualitySolution/QSProjects GitHub Wiki

Механизм навигации по диалогам и сами диалоги тесно связанны. Мы реализуем паттерн MVVM.

Принцип работы

Каждый диалог должен состоять минимум из двух(чаще 3-х) классов(частей кода):

  • View - непосредственно описание, как диалог выглядит, эта часть полностью зависит от используемого тулкита. В ней не должно быть кода, кроме связывания свойств ViewModel с виджетами и контролами формы. Более менее программный код разрешается только в случае необходимости какой-то особой поддержки работы с со специфичными особенностями GUI.
  • ViewModel - здесь должна располагаться вся логика диалога, то есть весь тот код, который обрабатывает действия пользователя, а также формирует данные, которые необходимо пользователю показать в том или ином виде.
  • Model - самое размытое понятие во всем патерне. Так как это часто группа или совокупность классов, которые реализуют непосредственно бизнес логику работы с данными. Обычно это как минимум классы доменной модели, с которыми работает диалог, которые потом сохраняются в базу. Но в это понятие должны включатся также все классы, которые позволяют каким-то образом обрабатывать данные приложения, при этом код обработки этих данные не связан с одним конкретным диалогом, а вполне успешно может использоваться множеством диалогов.

Различным классам кода надо иметь возможность открывать новые диалоги, давать команды на закрытие, и другими способами взаимодействовать с диалогами. Для того, чтобы остальной код приложения ничего не знал про используемый GUI, но мог открыть новый диалог он должен обращаться к NagigationManager, который как раз организует работу с диалогами и посредством интерфейса INavigationManager и идущих с ним скрывает от бизнес кода конкретную реализацию отображения диалогов.

На текущий момент готово две реализации NagigationManager:

  • TdiNavigationManager - Менеджер навигации открывающий вкладки на базе старого Tdi вкладочного интерфейса для Gtk. В первую очередь, рассчитан на совместную работу и старого вкладочного интерфейса и новых диалогов, построенных на ViewModel, в режиме совместимости также умеет отрывать через себя обычные Tdi вкладки. Возможно после полного перехода приложении на ViewModel-и можно будет использовать другой, более простой внутри менеджер навигации, который не будет отягощен поддержкой работы с TDI.
  • GtkWindowsNavigationManager - Более простая реализация менеджера навигации, открывающая каждый диалог в своем окне Gtk.

Использование INavigationManager из кода приложения

NavigationManager обычно создается один на все приложение, чтобы он мог управлять всеми открытыми на текущий момент диалогами. Код диалогов обычно не должен иметь глобальную ссылку на NavigationManager, так как в базовом классе DialogViewModelBase, от которого наследуется большинство ViewModel менеджер необходимо передавать в конструктор, что у всех диалогов, он по умолчанию есть и они могут открывать другие диалоги, обращаясь к свойству NavigationManager в базовом классе.

Открытие ViewModel

Открыть новую ViewModel можно вызовом различных методов OpenViewModel. Все они создают и открывают ViewModel c соответствующей для них View, с различными настройками:

  • TViewModel - тип класса ViewModel-и который необходимо создать.
  • DialogViewModelBase master - мастер ViewModel в случае открытия подчиненной ViewModel, это поле обязательно для заполнения, так как создаваемая ViewModel будет считаться подчиненной. Если же режим открытия обычный, то новая вкладка просто откроется за мастер вкладкой.
  • OpenPageOptions - флаги открытия, могут использоваться совместно.
    • AsSlave - вкладка должна открыться в виде подчиненной, это означает что ее отдельное существование без основной вкладки бессмысленно. Например, она открывалась для того, чтобы пользователь выбрал что-то в журнале и результат вернулся в основной диалог.
    • IgnoreHash - Игнорировать Hash при открытии, читай раздел про Hash-и страниц.
  • addingRegistrations - опционально возможность передать метод, выполняющий дополнительные регистрации в scope вкладке, создаваемой через Autofac, это позволяет переопределить регистрацию каких-либо классов в скопе, создаваемой для новой вкладки, смотри раздел про Autofac. Вам в создаваемую ViewModel может понадобится передать какие-либо параметры. Их можно передавать несколькими способами:
  • Типизированными параметром, например если мы в во ViewModel хотим передать два аргумента int и string, мы должны совершить следующий вызов OpenViewModel<MyViewModel, int, string>(masterViewModel, 5, "текст"). Этот способ естественным образом ограничен количеством аргументов в сигнатурах, добавленных в интерфейс.
  • Передачей двух массивов с типами параметров и их значениями. Предыдущий пример выполняется с следующим кодом OpenViewModelTypedArgs<MyViewModel>(masterViewModel, new Type[]{typeOf(int), typeOf(string)}, new object[]{ 5, "текст"})
  • Передачей именованных параметров. То есть, Вы передаете не тип параметра в конструкторе его имя, чтобы библиотека могла найти нужную сигнатуру конструктора. Похожий с предыдущими примерами можно выполнить так OpenViewModelNamedArgs<MyViewModel>(masterViewModel, new Dictionary<string, object> {{"id", 5}, {"message", "текст"}})

Page

Все методы открытия ViewModel возвращают класс, реализующий интерфейс IPage, то есть это дополнительная внешняя информация от открытой ViewModel, которой для внутреннего использования оперирует система навигации. В каждом конкретном менеджере навигации в этом классе может хранится разная информация, например для TdiNavigationManager там дополнительно хранится ссылка на TdiTab. А для GtkWindowsNavigationManager, хранятся ссылки на окно Gtk, в котором отображается View.

Но все страницы также реализуют некоторые общие свойства, пользовательский код, создавший страницу может получить ее ViewModel, подписаться на события закрытия, получить список подчиненных страниц, и внутренних(дочерних) ViewModel.

Обратите внимание, что менеджер навигации может работать с разными типами страниц, например TdiNavigationManager для открытия модальных ViewModel, то есть открытых в отдельном окне, использует в качестве страницы GtkWindowPage

Autofac

Обратите внимание, что при использовании фабрик ViewModel, завязанных на AutoFac, при передачи типизированных параметров, порядок параметров не важен, важен только тип самого параметра. AutoFac ищет самый подходящий тип конструктора, параметры которого по типам он может определить, через переданные вручную, а также из своих зарегистрированных классов.

Это означает, что используя типизированные параметры, невозможно например передать два разные параметра одного типа. Например, если в конструкторе будет указаны аргументы (int x, int y), то передавая несколько параметров int вы получите странный результат, все параметры int будут заполнены одним значением. Если нужно передавать в конструктор параметры одного типа, Вы должны использовать именованную передачу параметров.

Все текущие реализации фабрик ViewModel используют AutoFac для создания экземпляров. При этом для каждой странице они создают новый Lifetime Scope. Это означает, что для закрытия ресурсов, помимо реализации интерфейса IDisposable и ручного закрытия всех ресурсов в методе Dispose(), можно использовать жизненный цикл ViewModel, то есть все классы созданные через AutoFac внутри вкладки и для вкладки(передачей в конструктор), будут автоматически убиты вместе со ViewModel при закрытии.

Если внутри ViewModel необходимо через AutoFac создавать экземпляры классов, можно просто в конструктор ViewModel добавить аргумент ILifetimeScope и он автоматически заполнится текущим скопом, внутри которого создается вкладка.

Hash диалога

Для того чтобы пользователь не открывал несколько диалогов редактирования для одного и того же документа(объекта) или несколько однотипных журналов, в систему навигации добавлено такое понятие как hash-диалога. То есть, некая мета-информация, строка hash, позволяющая системе навигации при попытке открыть диалог повторно, вместо открытия копии просто переключить пользователя на уже открытый диалог. Используется именно строка, так как система навигации должна еще до создания ViewModel понять, что аналогичная ViewModel уже открыта. То есть, должен иметься механизм создания какого-то идентификатора ViewModel еще до ее создания, при этом допустим ViewModel редактирующая клиента с id 1 должна отличатся от ViewModel редактирующая клиента с id2.

На текущий момент имеется только одна реализация генератора Hash, базирующаяся на имени класса и специфичных параметров конструктора ClassNamesHashGenerator. Но легко можно выполнить свою реализацию. Данная реализация принимает в конструкторе перечисление IExtraPageHashGenerator, которое позволяет дополнять поведение генератора. В этом случае генератор сначала будет пытаться получить хеш от дополнительных генераторов, если не один из них не сработает hash будет создан основным способом. Такая реализация была добавлена для возможность размещать дополнительное поведение зависимое от классов проекта или необязательных библиотек.

WithParametersHashGenerator

В библиотеке предложен готовый вариант генератора хеша диалога, позволяющий учитывать любые из параметров переданных в конструктор диалога. Пример настройки:

var parametersHashGenerator = new WithParametersHashGenerator()
	//Здесь конфигурируем диалог которому передается id объекта в виде int, каждый вызов диалога со свои id считается отдельным диалогом. 
	.Configure<HistoryNotificationViewModel>().AddParameter<int>(id => id.ToString())
	//Здесь конфигурируем диалог которому передается сам объекта сотрудника EmployeeCard, в хеше используется id сотрудника вызов диалога со свои id считается отдельным диалогом.
	.Configure<SpecCoinsOperationsJournalViewModel>().AddParameter<EmployeeCard>(emp => emp.Id.ToString())
	.End();

В этот генератор можно как передавать несколько раз одну и туже ViewModel но с разным набором параметров. Каждый раз можно указывать не сколько параметров, будет использована та настройка в которой совпадут все настроенные параметры. Если хотя бы одного типа параметра не будет указано в конструкторе этот вариант настройки не сработает.

Примеры использования

Открытие ViewModel-и диалога редактирования сущности

Для новой сущности

NavigationManager.OpenViewModel<TEntityViewModel, IEntityUoWBuilder>(this, EntityUoWBuilder.ForCreate());

Для изменение существующей, с указанием id.

NavigationManager.OpenViewModel<TEntityViewModel, IEntityUoWBuilder>(this, EntityUoWBuilder.ForOpen(Id));

Открытие журнала

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

NavigationManager.OpenViewModel<NomenclatureJournalViewModel>(null);

Открытие журнала для выбора объекта, внутри другого диалога.

var selectPage = NavigationManager.OpenViewModel<NomenclatureJournalViewModel>(this, OpenPageOptions.AsSlave);
selectPage.ViewModel.SelectionMode = QS.Project.Journal.JournalSelectionMode.Multiple;
selectPage.ViewModel.OnSelectResult += Nomeclature_OnSelectResult;
  • this - текущий диалог.
  • OpenPageOptions.AsSlave - открытие в виде подчиненной вкладки. Главную вкладку нельзя будет закрыть без закрытия подчиненной.
  • JournalSelectionMode.Multiple - режим выбора объекта. В данном случае пользователю позволяется вбирать сразу несколько объектов через Ctrl или Shift
  • Nomeclature_OnSelectResult - метод обработчик события возникающего после выбора пользователем в журнале.

Открытие отчета RDL

var reportInfo = new ReportInfo {
	Title = String.Format("Документ №{0}", Entity.Id),
	Identifier = "MyDoc",
	Parameters = new Dictionary<string, object> {
		{ "id",  Entity.Id }
	}
};
NavigationManager.OpenViewModel<RdlViewerViewModel, ReportInfo>(this, reportInfo);

Основная статья: Печатные формы (отчеты)

Режим совместимости с TDI (ITdiCompatibilityNavigation)

Этот интерфейс специально создан на переходный период, позволяющий работать с проектами у которых есть старые Tdi диалоги и новые ViewModel и есть необходимость вызывать из новых диалогов старые и наоборот.

Интерфейс ITdiCompatibilityNavigation специально выделен отдельно, чтобы можно было четко понимать где еще используется старые диалоги. Его реализует только TdiNavigationManager.

Примеры использования

Открытие старого диалога TDi

var mainPage = navigation.OpenTdiTab<EmptyDlg>(null);

Открытие TDI диалога EmptyDlg без мастер вкладки.

Открытие справочника

var selectPage = tdiNavigationManager.OpenTdiTab<OrmReference, Type>(masterViewModel, typeof(Nomenclature), OpenPageOptions.AsSlave);

Здесь мы говорим что в конструктор OrmReference нужно передать параметр типа Type который будет иметь значение typeof(Nomenclature).

Открытие журнала Representation

var selectPage = tdiNavigationManager.OpenTdiTab<ReferenceRepresentation>(
				masterViewModel, 
				OpenPageOptions.AsSlave, 
				c => c.RegisterType<EmployeesVM>().As<IRepresentationModel>()
			);

Тут мы добавляем в регистрации Autofac только для скопа создаваемой вкладки, класс EmployeesVM и говорим что он реализует интерфейс IRepresentationModel. Виджет ReferenceRepresentation в конструкторе имеет аргумент IRepresentationModel, поэтому AutoFac автоматически создаст нам нужный EmployeesVM.

Открытие ViewModel-и внутри TDI диалога

Для работы внутри диалогов TDI в интерфейсе ITdiCompatibilityNavigation есть аналоги выше описанных методов с окончанием ...OnTdi. Эти методы позволяют использовать менеджера навигации внутри старого диалога TDI. Так как во все выше указанные методы в качестве мастер ViewModel нужно передавать текущую ViewModel, но в диалоге Tdi ее просто нет. Поэтому в эти методы нужно передавать текущую Tdi вкладку.

Пример ниже показывает открытие журнала ViewModel внутри диалога TDI

var selectJournal = MainClass.MainWin.NavigationManager.OpenViewModelOnTdi<NomenclatureJournalViewModel>(MyTdiDialog, QS.Navigation.OpenPageOptions.AsSlave);
selectJournal.ViewModel.SelectionMode = QS.Project.Journal.JournalSelectionMode.Multiple;
selectJournal.ViewModel.OnSelectResult += AddNomenclature_OnSelectResult;

Таким же способом через навигатор можно открыть диалог TDI внутри диалога TDI используя метод OpenTdiTabOnTdi. Он имеет смысл для начала переписывания старых диалогов на новый поход.

Пространства имен

Основные пространства имен для работы с диалогами:

  • QS.Navigation - Классы для управления диалогами NavigationManager и сопутствующие классы.
  • QS.ViewModels - Классы для работы с ViewModel-ями.
  • QS.ViewModels.Control - Базовые ViewModel-и контролов или виджетов.
  • QS.ViewModels.Dialog - Базовые ViewModel-и диалогов.
  • QS.ViewModels.Extension - Интерфейсы расширения для ViewModel-е, позволяющие им реализовывать дополнительный функционал.
  • QS.Views.Control - View контролов.
  • QS.Views.Dialog - Базовые View диалогов.
  • QS.Views.Resolve - Модуль позволяющий создать соответствующий View для ViewModel.

Настройка TdiNavigationManager

Ниже предлагается пример базовой настройки проекта для работы TdiNavigationManager, все основные классы которые необходимо зарегистрировать в AutoFac.

var builder = new ContainerBuilder();

#region Навигация
builder.RegisterType<ClassNamesHashGenerator>().As<IPageHashGenerator>();
builder.Register((ctx) => new AutofacViewModelsTdiPageFactory(AppDIContainer)).As<IViewModelsPageFactory>();
builder.Register((ctx) => new AutofacTdiPageFactory(AppDIContainer)).As<ITdiPageFactory>();
builder.Register((ctx) => new AutofacViewModelsGtkPageFactory(AppDIContainer)).AsSelf();
builder.RegisterType<TdiNavigationManager>().AsSelf().As<INavigationManager>().As<ITdiCompatibilityNavigation>().SingleInstance();
builder.RegisterType<BasedOnNameTDIResolver>().As<ITDIWidgetResolver>();
builder.Register(cc => new ClassNamesBaseGtkViewResolver(typeof(RdlViewerView), typeof(OrganizationView))).As<IGtkViewResolver>();
#endregion

#region ViewModels
builder.RegisterAssemblyTypes(System.Reflection.Assembly.GetAssembly(typeof(OrganizationViewModel)))
	.Where(t => t.IsAssignableTo<ViewModelBase>() && t.Name.EndsWith("ViewModel"))
	.AsSelf();
#endregion

AppDIContainer = builder.Build();

Поиск и регистрация View

Внутренние механизмы менеджера навигации в какой-то момент должны иметь возможность по ViewModel-и создать соответствующий View.

Gtk 2

Для поиска(резольва) view для Gtk используется интерфейс QS.Views.Resolve.IGtkViewResolver из библиотеки QS.Project.Gtk. Он имеет всего 1 метод получения Gtk.Widget для переданной ViewModel. Его легко реализовать самостоятельно. Но лучше использовать или несколько стандартных резольверов из библиотеки. Их объединение реализовано с помощью патерна Декоратор, когда они вкладываются друг в друга как матрешки при создании. И если резолвер более высокого уровня не может найти View, запрос передается на резолвер находящийся внутри.

Готовые реализации резольверов

  • RegisteredGtkViewResolver - Позволяет вручную связывать каждую viewModel с конкретной View
  • ClassNamesBaseGtkViewResolver - Автоматически находит View исходя и названия и пространства имен ViewModel-и по следующей схеме: {CoreNamespace}.ViewModels.{SubNamespaces}.{Name}ViewModel - для модели представления; {CoreNamespace}.Views.{SubNamespaces}.{Name}View - для gtk представления; В конструктор получает список Assembly, в которых необходимо проводить поиск.
⚠️ **GitHub.com Fallback** ⚠️