dotnet / maui

.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.
https://dot.net/maui
MIT License
22.11k stars 1.73k forks source link

Adopt possibility to resolve navigation Pages by type #3622

Open marwalsch opened 2 years ago

marwalsch commented 2 years ago

Description

Currently, the framework requires instantiation of a ContentPage object in order to navigate. This is quite obstructive when using constructor injection, especially when using MVU or lightweight patterns relying mostly on logic inside the code-behind. This has been addressed in this discussion and @matt-goldman actually provided a very lightweight and straightforward solution . I suggest considering on adopting it into the framework's existing Navigation. Blazor fell back to property injection which might not be ideal but at least provided a feasible solution.

Public API Changes

Check the repository's README for reference.

Intended Use-Case

Currently:

await Navigation.PushModalAsync(new MyPage(/*requires dependencies*/));

Goldman's solution:

await Navigation.PushModalAsync<MyPage>(); // built-in container resolves dependencies

MhAllan commented 2 years ago

I am surprised this is a requirement from the community, I thought it is like that by default as they added dependency injection! so why did we add dependency injection if I still need to pass dependencies 😅. I also suggest an InstanceFactory.Create<T>() to create any object without dependencies

marwalsch commented 2 years ago

@MhAllan There is dependency injection, either through the default IServiceProvider from .NET6 used by Maui or the DependencyServideused by Xamarin earlier, though they do use different containers. The issue is that the overloads of the Push methods currently only take instances which hinders constructor injection.

MhAllan commented 2 years ago

@marwalsch your request should be for InstanceFactory.Create<T>() not for Navitation.Push<T>() because that dos not give you control on how many instances of the given page you want as it will create instance for every Push. while if you have InstanceFactory.Create<T> you are free to re-use the same page to push again or create pages in advance and also create any object not only pages. I am suggesting InstanceFactory but simply we can expose whatever Maui uses to create instances so we have access to it anywhere in the app

marwalsch commented 2 years ago

Yes it does: MauiApp.CreateBuilder().Services.AddSingleton<T>();

Calling CreateInstance explicitly to pass dependencies is actually an Anti-Pattern and forces you to have a Scoped/Transient lifetime.

MhAllan commented 2 years ago

@marwalsch that InstanceFactory.Create<T>() is not for creating instances of the dependencies, it is for creating the instance that you need, like a Page

marwalsch commented 2 years ago

@MhAllan That's IServiceProvider.GetService(). With the proposal's implementation there is no need to call for instances explicitly at all.

MhAllan commented 2 years ago

@marwalsch I think I understand your concern now, we can register pages as services so why do we need CreateInstace .. hmm, right

marwalsch commented 2 years ago

This might be occasion to reconsider how Page instantiation through the framework works. Features like default navigations currently instantiate through the parameterless constructor of a page, which worked with the legacy(?) DependencyService but not with constructor injection.

Markup like <FlyoutPageItem TargetType="{x:Type page:SomePage}" /> currently not only makes this impossible, but also is counter-intuitive as the Type should suffice.

bakerhillpins commented 2 years ago

Yes it does: MauiApp.CreateBuilder().Services.AddSingleton<T>();

Calling CreateInstance explicitly to pass dependencies is actually an Anti-Pattern and forces you to have a Scoped/Transient lifetime.

@marwalsch I agree with your sentiment in general, however, I'd still like to see a way to pass in constructor parameters (dependencies) to the "Create" method. It's useful for situations where you're binding to collections of objects/data and you're using a DataTemplateSelector to convert the collection bound items into Views with companion ViewModels. For example, any implementation of ItemsView.ItemsSource.

E.g. Without getting into specifics on the objects/data in a collection, consider the pattern where you're using a CollectionView and binding it's ItemSource property to that collection. When you specify a DataTemplateSelector implementation you can customize the view for each individual element of the collection based upon it's type. When that Template is returned from DataTemplateSelector.OnSelectTemplate the object/data value that was provided to to OnSelectTemplate is applied to the newly created View's BindingContext property. And there's the issue; we want to have a ViewModel wrapped around that object/data value for our View to reference, not the raw value. If we have access to the DI Resolver which supports a specified constructor parameter we can use Binding (with a Converter and ConverterParameter specified) or create a custom IMarkupExetension to support the creation of the ViewModel using THE object/data value from the collection in its constructor.

This pattern allows dynamic generation of ViewModels for object/data values in hierarchical data patterns in bound collections by eliminating lots of extra code work in a top level ViewModel. Which would otherwise be responsible for creating all the ViewModels up front in separate collections, and also managing any/all updates to the VM collections when data in the source collection changes.

Some rudimentary code might help illustrate:

        <StackLayout Margin="10">
            <CollectionView ItemTemplate="{x:Static views:SettingDataTemplateSelector.Instance}"
                            ItemsSource="{Binding Settings}" />
        </StackLayout>

the DataTemplateSelector:

    internal class SettingDataTemplateSelector : DataTemplateSelector
    {
        public static readonly DataTemplateSelector Instance =
            new Lazy<DataTemplateSelector>( () => new SettingDataTemplateSelector() ).Value; 

        private static readonly DataTemplate SwitchTemplate =
            new Lazy<DataTemplate>( () => new DataTemplate( () => new SwitchView() ) ).Value;

        private static readonly DataTemplate EntryTemplate =
            new Lazy<DataTemplate>(() => new DataTemplate(() => new EntryView() ) ).Value;

        protected override DataTemplate OnSelectTemplate( object item, BindableObject container )
        {
            switch ( item  )
            {
                default:
                case SwitchSetting _:
                    return SettingDataTemplateSelector.SwitchTemplate;
                case ValueSetting<int> _:
                case ValueSetting<double> _:
                case ValueSetting<byte> _:
                case StringSetting _:
                    return SettingDataTemplateSelector.EntryTemplate;
            }
        }
    }

and the TemplatedView, where the IValueConverter.Convert method creates the ViewModel viewModels:DataSpecificViewModel by passing the actual value into the DI Resolver to be used in the constructor of the type.

<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Hypertherm.Settings.Views.EntryView">

    <!-- Placing the Binding on BindingContext at the StackPanel level causes the binding to be applied BEFORE the
         data is propagated to the children. Thus the children only ever see the ViewModel in their BindingContext -->
    <StackPanel Padding="10, 0"
          BindingContext="{Binding Mode=OneTime, Converter={x:Static uxConverters:ViewModelActivator.Instance}, ConverterParameter={ x:Type viewModels:DataSpecificViewModel}}">

        <!-- All children see the dynamically created ViewModel -->

    </StackPanel>

</ContentView>

I've had lots of success with this pattern using DryIoc with Prism. In fact, in the Prism implementation I can supply 0 - N constructor arguments (dependencies) and DriIoc will resolve the remaining unspecified ones, if I've not specified them all. Thus I can have the best of both worlds, I can specify the Object/Data dependency for the ViewModel, and let the Ioc Container look up any services that VM might also require.