I couldn't think of a more descriptive title for the almost-un-Googlable problem I faced this week, so it will be a miracle if this article finds itself in the hands of somebody who will actually be facing the same problem. Regardless, in my opinion it's worth a read for anyone using Caliburn.Micro in their XAML-based project. This article will give you an understanding of how virtualized ItemsControls work and why Caliburn.Micro might behave in unexpected ways when using these containers with Caliburn.Micro Actions.
First a very brief description of the issue I was facing. I had an application with a Caliburn.Micro-instantiated view with an items control (for this example I will use a ListBox). Here's an example view:
Backed by ViewModels:
What we have is a ListBox displaying a bunch of buttons, one each for the corresponding ItemsViewModel items in MainViewModel.Items. The expected behaviour here is that as we click each of the buttons displayed through their DataTemplates in the ListBox, the viewmodel should have its DoThings() method called and the debug output will show the value that corresponds to the display on the button (the helpfully-named property S).
As you may expect, this doesn't work as intended. Depending on the size of the ListBox, the first few items work correctly but as we scroll through them the later ones start calling seemingly random earlier viewmodels. On my machine clicking the button labeled "10" displays debug output "4".
So what's going on here? The problem is UI virtualization, combined with my use of a DataTemplate for the ListBox ItemsTemplate. Essentially the ItemsControl will instantiate only as many of these DataTemplates as it needs to fill up what's currently visible on screen (plus a couple more to make scrolling smooth). As you scroll through the list the control will grab views (DataTemplate instances) that are no longer displayed on screen and re-use them to display more data.
According to this MSDN article (emphasis mine):
When you add an item to an ItemsControl, the item is wrapped in an item container. For example, an item added to a ListView is wrapped in a ListViewItem. Without UI virtualization, the entire data set is kept in memory and an item container is also created for each item in the data set. A ListView that's bound to a collection of 1000 items will also create 1000 ListViewItem containers that are stored in memory.
With UI virtualization, the data set is still kept in memory, but an item container is created only when the item is nearly ready to be shown in the UI. A ListView using UI virtualization might keep only 20 ListViewItem objects in memory.
The runtime will take the old views, strip out their DataContext property and replace it with the newly-required data from your ItemViewModel instances to be displayed next. The reason this is causing havoc with our method calls is this snippet of code:
Note that there is no binding happening on Message.Attach. All this does is set up an EventTrigger on the Tapped event with a TriggerAction of type Caliburn.Micro.ActionMessage. This happens only once for each view that is created! So when a DataTemplate instance is recycled by the ItemsControl, although the DataContext and all relevant bindings are updated (so the button displays the correct string S for example), the ActionMessage associated with this view is never set to target the new ItemViewModel.
I generally discourage the use of DataTemplates when using Caliburn.Micro. In the vast majority of cases you should do your templating inside new UserControls. Template your data for each individual ViewModel in a corresponding View and you'll find Caliburn.Micro much easier to use. In this case, create a new UserControl in your Views directory called ItemView and place everything inside your DataTemplate into this view. In our example case, ItemView looks something like:
When we don't specify a DataTemplate for an ItemsControl the following DataTemplate is added by Caliburn.Micro behind the scenes:
Because our data context is now databound to a DependencyProperty (Caliburn.Micro.View.Model), when the template's DataContext is changed this DependencyProperty is updated. This causes Caliburn.Micro to instantiate a new ItemView (the View.Model DepedencyProperty change handler calls the ViewLocator, which instantiates a new view if one doesn'r exist), where our ActionMessage is correctly bound.
A second possible solution is to use a non-virtualizing ItemsControl, such as StackPanel or VariableSizedWrapGrid. This will load all items in the ItemsControl with its own view eagerly. Here's an example with the ListBox control:
Note that if you are displaying large amounts of data then this is not ideal. You will increase both the load time and memory consumption of your view, and some ItemsPanels can take a long time to measure items. Some panels even need to re-measure when new items are added, so if your control is very dynamic then this solution can really suck with large data sets.
There is one more alleged workaround, but I couldn't get it to work. The Caliburn.Micro Action.TargetWithoutContext property is supposed to set the target for an action. I tried binding this property to the viewmodel from within the Button but I never did get it working as expected - everything behaved the same as before. If anyone knows why, I'd love to hear it.
Final note, I did all of my testing in a Windows 8 Store App (WinRT) with a brief look at WPF. As far as I can tell this affects all XAML-based development with Caliburn.Micro, although I haven't tested every possible scenario.