Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

For Avalonia, allow for view models that are in a different assembly from views #34

Open
SimonORorke opened this issue Mar 29, 2024 · 8 comments

Comments

@SimonORorke
Copy link

SimonORorke commented Mar 29, 2024

Summary

I would like to suggest an enhancement for the Avalonia UI implementation to allow for view models that are in a different assembly from views.

Use Case

In my .Net solution, views are in the Views folder of the top-level project/assembly, as in the Avalonia UI MVVM template solution. However, view models are in a separate view model project/assembly, which is referenced by the top-level project/assembly. This is my preference for MVVM applications, as it discourages me and my collaborators from adding references to the view model project that should only be required in the domain of views.

Current Behaviour

HanumanInstitute.MvvmDialogs.Avalonia currently assumes that views are in the same assembly as views. When this is not the case, a TypeLoadException is thrown when attempting to show the main window, like this:

System.TypeLoadException: Dialog view of type FalconProgrammer.Views.MainWindow for view model FalconProgrammer.ViewModel.MainWindowViewModel is missing.
Avalonia project template includes ViewLocator in the project base. You can customize it to map your view models to your views.
at HanumanInstitute.MvvmDialogs.Avalonia.ViewLocatorBase.Locate(Object viewModel)

I have been able to circumvent that by overriding ViewLocatorBase.GetViewName and ViewLocatorBase.Locate in ViewLocator. (The code is show below.) With that workaround implemented, an ArgumentException is thrown when DialogService attempts to launch a dialog, such as open folder, whose owning view model is a ContentControl, such as a UserControl. Like this:

System.ArgumentException: No view found with specified ownerViewModel of type FalconProgrammer.ViewModel.LocationsViewModel.
at HanumanInstitute.MvvmDialogs.DialogManagerBase1.<>c__DisplayClass32_01.<b__0>d.MoveNext()
--- End of stack trace from previous location ---
at HanumanInstitute.MvvmDialogs.DialogManagerBase1.ShowFrameworkDialogAsync[TSettings](INotifyPropertyChanged ownerViewModel, TSettings settings, Func2 resultToString)

I have been able to circumvent that by overriding ViewLocatorBase.CreateViewInstance in ViewLocator and DialogManager.FindViewByViewModel in a custom class derived from DialogManager. (The code is show below.)

Workaround

Implement ViewLocator like this. The GetViewName override may vary, depending on your view and view model naming standard:

using System;
using System.Reflection;
using Avalonia.Controls;
using HanumanInstitute.MvvmDialogs;
using HanumanInstitute.MvvmDialogs.Avalonia;

namespace FalconProgrammer;

/// <summary>
///	 Maps view models to views in Avalonia.
/// </summary>
/// <remarks>
///	 Allowance is made for view models being in a different assembly from views, which
///	 are assumed to be in this assembly.
/// </remarks>
public class ViewLocator : ViewLocatorBase {

	private Assembly? _thisAssembly;
	private string? _viewsNameSpace;
	private Assembly ThisAssembly => _thisAssembly ??= Assembly.GetExecutingAssembly();

	private string ViewsNameSpace => _viewsNameSpace ??=
		$"{ThisAssembly.GetName().Name!}.Views";

	internal ContentControl? ContentControlViewInstance { get; private set; }
	
	protected override object CreateViewInstance(Type viewType) {
		object result = base.CreateViewInstance(viewType);
		if (result is ContentControl contentControl) {
			ContentControlViewInstance = contentControl;
                } else {
                        ContentControlViewInstance = null;
		}
		return result;
	}

	protected override string GetViewName(object viewModel) {
		string viewModelShortName = viewModel.GetType().Name;
		string viewShortName = viewModelShortName.EndsWith("WindowViewModel")
			// E.g. view model MainWindowViewModel, view MainWindow. 
			? viewModelShortName.Replace("ViewModel", string.Empty)
			// E.g. view model LocationsViewModel, view LocationsView. 
			: viewModelShortName.Replace("ViewModel", "View");
		string result = $"{ViewsNameSpace}.{viewShortName}";
		return result;
	}

	/// <inheritdoc />
	public override ViewDefinition Locate(object viewModel) {
		// This override code is the same as in the base method, except that the base method
		// won't work for us because it assumes the view is in the same assembly as the view
		// model.
		string viewName = GetViewName(viewModel);
                // This is the line that differs from the base method.
		var viewType = ThisAssembly.GetType(viewName);
		return viewType != null
					 && (typeof(Control).IsAssignableFrom(viewType) ||
							 typeof(Window).IsAssignableFrom(viewType) ||
							 typeof(IView).IsAssignableFrom(viewType))
			? new ViewDefinition(viewType, () => CreateViewInstance(viewType))
			: throw new TypeLoadException(
				"Dialog view of type " + viewName +
				" for view model " + viewModel.GetType().FullName +
				" is missing." + Environment.NewLine +
				"Avalonia project template includes ViewLocator in the project base. " +
				"You can customize it to map your view models to your views.");
	}
}

Derive a custom class from DialogManager, like this:

using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Threading;
using HanumanInstitute.MvvmDialogs;
using HanumanInstitute.MvvmDialogs.Avalonia;
using Microsoft.Extensions.Logging;

namespace FalconProgrammer;

/// <summary>
///	 DialogManager for Avalonia, customised for view models that are in a different
///	 assembly from views.
/// </summary>
public class CustomDialogManager(
	IViewLocator? viewLocator = null,
	IDialogFactory? dialogFactory = null,
	ILogger<DialogManager>? logger = null,
	IDispatcher? dispatcher = null,
	Control? customNavigationRoot = null)
	: DialogManager(viewLocator, dialogFactory, logger, dispatcher, customNavigationRoot) {

	/// <inheritdoc />
	public override IView? FindViewByViewModel(INotifyPropertyChanged viewModel) {
		var viewLocator = (ViewLocator)ViewLocator;
		return viewLocator.ContentControlViewInstance != null
			// E.g. view is a UserControl.
			? AsWrapper(viewLocator.ContentControlViewInstance)
			// Maybe view is a Window. But this method does not get executed for at least the
			// main window view model, which is currently the only window view model.
			: base.FindViewByViewModel(viewModel);
	}
}

Finally, Instantiate DialogService, usually for use in App.Initialize, to use the custom DialogManager and ViewLocator. Something like this:

public static IDialogService CreateDialogService(ILoggerFactory loggerFactory) {
		return new DialogService(
			new CustomDialogManager(
				new ViewLocator(),
				logger: loggerFactory.CreateLogger<DialogManager>()),
			x => Locator.Current.GetService(x));
}

Conclusion

It should be possible to enhance the Avalonia UI implementation to allow for view models that are in a different assembly from views. For this to work, if a view cannot be found in the same assembly as the view model (Assembly.GetExecutingAssembly), try looking in the calling assembly (Assembly.GetCallingAssembly).

@mysteryx93
Copy link
Owner

mysteryx93 commented Mar 31, 2024

If your naming convention isn't the default, then you create your ViewLocator, that's what it's there for. Overriding GetViewName should do it.

But why so much code? Is there a totally separate problem you're having? That's not very clear.

Also, are you working on Desktop or Mobile app, or both?

@SimonORorke
Copy link
Author

I can see I could have been clearer. The naming convention has nothing to do with the problem. If I had the same naming convention as is implemented in ViewLocatorBase.GetViewName, I would have omitted the override of GetViewName in ViewLocator. Let me provide further clarification.

Target Platforms

Desktop only. I'm developing my application in Windows, with a view to installation also on MacOS.

Summary of Problems

HanumanInstitute.MvvmDialogs.Avalonia does not support view models that are in a separate project/assembly from the views. I have found two specific problems in this context:

  1. The program will crash when DialogService attempts to open the main window.
  2. If problem 1 is fixed by overriding Locate in ViewLocator, as shown in the workaround in my previous post, the program will then crash when an attempt is made to open a a dialog, such as an open file.

Example

Have a look at the main branch of my Avalonia application Falcon Programmer. That contains the fix shown in the workaround in my previous post. The main window initially shows several browse buttons that open file or folder dialogs. It works.

Try backing out the fix in two stages.

First, comment out the FindViewByViewModel override in CustomDialogManager.
Result: When any of the Browse Buttons is clicked, the application crashes with ArgumentException.

Finally, comment out the Locate override in ViewLocator.
Result: On attempting to launch the application, it crashes with TypeLoadException.

@mysteryx93
Copy link
Owner

Ah you mean that it's checking for the supplied class name only in the executing assembly?

The class really could be in any linked assembly... checking in all of them would require looping through loaded assembly and would have a performance impact.

Unless adding a method to the ViewLocator to add the assemblies to look into and loop through those.

Also as a note, if you plan to use assembly trimming, you'll need to avoid Reflection and will need to use StrongViewLocator.

@SimonORorke
Copy link
Author

SimonORorke commented Apr 1, 2024

Ah you mean that it's checking for the supplied class name only in the executing assembly?

Yes, exactly.

The class really could be in any linked assembly

I'm not sure about that. I reckon that, if its not in the executing assembly, it must be in the calling assembly. As I suggested in my original post,

if a view cannot be found in the same assembly as the view model (Assembly.GetExecutingAssembly), try looking in the calling assembly (Assembly.GetCallingAssembly).

Also as a note, if you plan to use assembly trimming, you'll need to avoid Reflection and will need to use StrongViewLocator.

Thanks for the tip! I'd never heard of assembly trimming. I've just looked it up and it does not seem likely that it will become advantageous to implement it in my current project.

@SimonORorke
Copy link
Author

After further investigation, I decided to implement assembly trimming after all, and consequently converted ViewLocator to a StrongViewLocator, as you suggested. That also greatly simplifies getting round the limitation that ViewLocatorBase does not support view models that are not in the same assembly as views.

So that works for me. You may still wish to consider enhancing ViewLocatorBase.

@SimonORorke
Copy link
Author

It turns out StrongViewLocator does not work for me after all, due to a variant of the problem I originally reported. Registering a view/view model pair in ViewLocator like this

Register<LocationsViewModel, LocationsView, MainWindow>();

throws an InvalidCastException on attempting to show a dialog, like this:

Unable to cast object of type 'FalconProgrammer.ViewModel.LocationsViewModel' to type 'FalconProgrammer.ViewModel.MainWindowViewModel'.

Rather than try to find an additional workaround, I have backed out the use of 'Hanuman.MvvmDialogs' from my application, implementing different methods of dialog display and the strongly-typed creation of views required for assembly trimming.

@mysteryx93
Copy link
Owner

mysteryx93 commented Apr 6, 2024

Why is it trying to convert LocationsViewModel to MainWindowViewModel? The problem could be in your code rather than in the library itself... could you provide a minimal reproduction project?

Why do you say it's a variant of the same problem?

@SimonORorke
Copy link
Author

I have a couple of corrections to my previous comment:

  1. The application crashes on startup, not on showing a dialog.
  2. I agree, it does not look like a variant of the same problem.

I've made a cutdown version in the hanuman branch of my Avalonia application Falcon Programmer. As it is crashing on startup, I could not test simplifications to the XAML. So I've left the XAML intact and populated the GUI with hard-coded test data rather than accessing a model layer. There are still separate view and view model projects/assemblies.

If you get the cutdown version working, you should be able to show open dialogs by clicking the Browse buttons.

Message boxes won't show, as validation logic has been removed. However, an error message box could be shown with a simple code change. In GuiScriptProcessorViewModel, change if (soundBanks.Count == 0) to if (soundBanks.Count > 0).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants