WPF Caliburn.Micro and TabControl with UserControls issue

Para picture Para · Jan 2, 2014 · Viewed 8.6k times · Source

I'm pretty sure this has been answered somewhere, but I can't seem to find it for the life of me.

I'm trying to use a TabControl to switch between UserControls (each tab is different, so not using Items)

Here's the breakdown: I have my mainview, and 3 usercontrols. Mainview has a tab control - each tab should display a different user control.

I could easily just set the tabcontrol contect to the usercontrol using But then it isn't bound to the viewmodel, only the view.

So I'm using Conductor in my VM, and ActivateItem. Here's where it starts to get weird / frustrating. Application starts with Tab0 selected, but Tab2 (last tab) content. Click on any other tab, loads the correct ViewModel for that tab. Click back to Tab0, loads the correct content there as well.

How do I get this to stop? Also, I'd really like it if switching tabs doesn't re-initialize the viewmodel again, clearing out fields that have already been entered.

Anyways, here's some of my source, I'm going to just drop this here and work on something else before I break my mouse.

View:

<TabControl HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Grid.Row ="1">
        <TabItem Header="PC Information">
            <Grid>
                <ContentControl x:Name="LoadRemoteInfo" cal:View.Model="{Binding ActiveItem}"/>
            </Grid>
        </TabItem>
        <TabItem Header="Remote Tools">
            <Grid>
                <ContentControl x:Name="LoadRemoteTools" cal:View.Model="{Binding ActiveItem}"/>
            </Grid>
        </TabItem>
        <TabItem Header="CHRemote">
            <Grid>
                <ContentControl x:Name="LoadCHRemote" cal:View.Model="{Binding ActiveItem}"/>
            </Grid>
        </TabItem>

    </TabControl>

and the ViewModel:

class MainViewModel : Conductor<object>
{
    RemoteInfoViewModel remoteInfo = new RemoteInfoViewModel();
    RemoteToolsViewModel remoteTools = new RemoteToolsViewModel();
    CHRemoteViewModel chRemote = new CHRemoteViewModel();

    public MainViewModel()
    {
        ActivateItem(remoteInfo);
    }

    public void LoadRemoteInfo()
    {
        ActivateItem(remoteInfo);
    }

    public void LoadRemoteTools()
    {
        ActivateItem(remoteTools);
    }

    public void LoadCHRemote()
    {
        ActivateItem(chRemote);
    }
}

Answer

Patryk Ćwiek picture Patryk Ćwiek · Jan 2, 2014

May I suggest a tad different route?

It's something that I have been successfully doing in master-details scenarios. Let's say you have a collection of child view models. I'll prepare a marker interface for all those items, of course you can add properties/methods you see fit if there are such methods that span all child view models:

public interface IMainScreenTabItem : IScreen
{
}

You can be quite sure that you want all your child models to be Screens (or, in case of nested scenarios, Conductors). It makes them have the full initialization/activation/deactivation cycle available.

Then, the child view models:

public sealed class ChRemoteViewModel : Screen, IMainScreenTabItem
{
    public ChRemoteViewModel()
    {
        DisplayName = "CH Remote";
    }
}

public sealed class PcInfoViewModel : Screen, IMainScreenTabItem
{
    public PcInfoViewModel()
    {
        DisplayName = "PC Info";
    }
}

public sealed class RemoteToolsViewModel : Screen, IMainScreenTabItem
{
    public RemoteToolsViewModel()
    {
        DisplayName = "Remote Tools";
    }
}

DisplayName will be displayed as a header text. It's a good practice to make those classes sealed, because DisplayName is a virtual property, and it's a big no-no to call virtual methods in a constructor of a class that's not sealed.

Then, you can add corresponding views and set your IoC container of choice registrations - you have to register your all child view models as classes implementing the IMainScreenTabItem and then:

public class MainViewModel : Conductor<IMainScreenTabItem>.Collection.OneActive
{
    public MainViewModel(IEnumerable<IMainScreenTabItem> tabs)
    {
        Items.AddRange(tabs);
    }
}

Where the MainView.xaml is just:

<TabControl Name="Items"/>

And it just works. It's also very nice and convenient solution if your child view models take multiple dependencies (e.g. database access, logger, validation mechanism etc), now you can have the IoC do all the heavy lifting instead of instantiating them by hand.

One thing here though: the tabs will be placed in the same order the classes are injected. If you want to have a control over the ordering, you can order them in MainViewModel constructor by either passing a custom IComparer<IMainScreenTabItem> or adding some property you can OrderBy or select to the IMainScreenTabItem interface. The default selected item will be the first one in the Items list.

Other option is to make the MainViewModel take three parameters:

public MainViewModel(ChRemoteViewModel chRemoteViewModel, PcInfoViewModel pcInfo, RemoteToolsViewModel remoteTools)
{
    // Add the view models above to the `Items` collection in any order you see fit
}

Although when you have more than 2 - 3 child view models (and you can easily get more), it's going to get messy quick.

About the 'clearing' part. The view models created by IoC confrom to the regular life-cycle: they're initialized at most once (OnInitialize), then deactivated each time they are navigated away from OnDeactivate(bool) and activated when they're navigated to (OnActivate). The bool parameter in OnDeactivate indicates whether the view model is just deactivated or completely 'closed' (e.g. when you close the dialog window and navigate away). If you completely close the view model, it will be re-initialized next time it's shown.

That means that any bound data will be retained between OnActivate calls and you'd have to explicitly clear it in OnDeactivate. What's more, if you keep the strong reference to your child view models, then even after you call OnDeactivate(true), the data will still be there on next initialization - that's because IoC injected view models are created once (unless you inject the factory function in a form of Func<YourViewModel>), and then initialized/activated/deactivated on demand.


EDIT

About the bootstrapper, I'm not quite sure what kind of IoC container you're using. My sample uses SimpleInjector, but you can do the same just as easily with e.g. Autofac:

public class AppBootstrapper : Bootstrapper<MainViewModel>
{
    private Container container;

    /// <summary>
    /// Override to configure the framework and setup your IoC container.
    /// </summary>
    protected override void Configure()
    {
        container = new Container();
        container.Register<IWindowManager, WindowManager>();
        container.Register<IEventAggregator, EventAggregator>();
        var viewModels =
            Assembly.GetExecutingAssembly()
                .DefinedTypes.Where(x => x.GetInterface(typeof(IMainScreenTabItem).Name) != null && !x.IsAbstract && x.IsClass);
        container.RegisterAll(typeof(IMainScreenTabItem), viewModels);
        container.Verify();
    }

    /// <summary>
    /// Override this to provide an IoC specific implementation.
    /// </summary>
    /// <param name="service">The service to locate.</param><param name="key">The key to locate.</param>
    /// <returns>
    /// The located service.
    /// </returns>
    protected override object GetInstance(Type service, string key)
    {
        if (service == null)
        {
            var typeName = Assembly.GetExecutingAssembly().DefinedTypes.Where(x => x.Name.Contains(key)).Select(x => x.AssemblyQualifiedName).Single();

            service = Type.GetType(typeName);
        }
        return container.GetInstance(service);
    }

    protected override IEnumerable<object> GetAllInstances(Type service)
    {
        return container.GetAllInstances(service);
    }

    protected override void BuildUp(object instance)
    {
        container.InjectProperties(instance);
    }
}

Note the viewModels registration in Configure.