I was contacted by Simon Weaver via Twitter about a problem where the Bindings for MenuItem
s of a ContextMenu
on a DataGrid were acting as though they were associated with a different row than they really were. This seemed pretty weird until Yifung Lin, one of the original DataGrid
developers, suggested this might be due to the row recycling behavior DataGrid
uses to improve performance. It turns out he was right - but the rabbit hole goes much deeper than that...
Working from an internal reproduction of the problem Brian Braeckel created, I found that I was able to simplify the scenario significantly - to the point of removing ContextMenu
and DataGrid
entirely! Signs were pointing strongly to this being a bug in the Silverlight framework, but before I started crying wolf, I wanted someone to review my work to be sure I hadn't over-simplified things. Fortunately, RJ Boeke was around and not only confirmed the validity of my repro, but also pointed out further simplifications!
When all is said and done, the bug is pretty easy to describe:
If a Binding
points to an element via its ElementName or Source parameters and the DataContext
of a parent of that element changes, that change will NOT be propagated through the Binding
. However, if the DataContext of the element itself changes, the change will be propagated correctly.
RJ and I were both kind of surprised to find this bug in Silverlight 4, but when Sage LaTorra looked into this from the QA side, he reported the same problem with Silverlight 3. Which - in a weird kind of way - is actually good news because it means things haven't gotten worse with Silverlight 4 and existing applications won't break unexpectedly!
Unfortunately, the DataGrid
+ContextMenu
scenario relies on this exact behavior to work correctly... When a ContextMenu
is about to be displayed, a Popup is created, the menu UI is added to it, and it's shown. Because the Popup
isn't in the visual tree, the ContextMenu
won't see the right DataContext
by default - so it sets up a Binding
with its Source
set to the ContextMenu
's owner element. This works well and the DataContext
then "flows" into the Popup
where MenuItem
s can see it and bind to it successfully. The problem in the DataGrid
scenario arises when DataGrid
decides to recycle one of its rows by swapping one DataContext
for another. Though this is a perfectly legitimate thing to do, it runs afoul of this bug and breaks the scenario.
Fortunately, there's an easy workaround. Even better: it works for all known instances of the problem, not just the DataGrid
+ContextMenu
kind! Here's an example of code that demonstrates the bug when the DataContext
of a parent element is changed (which you can do in the sample application by clicking on the red or green background):
<!-- Simple example of the broken scenario -->
<Grid x:Name="SimpleBroken" Grid.Column="0" Grid.RowSpan="2" Background="Red">
<TextBlock Text="{Binding DataContext, ElementName=SimpleBroken}"/>
</Grid>
And here's what it looks like with the workaround applied:
<!-- Simple example of the workaround -->
<delay:DataContextPropagationGrid x:Name="SimpleWorking" Grid.Column="1" Grid.RowSpan="2" Background="Green">
<TextBlock Text="{Binding DataContext, ElementName=SimpleWorking}"/>
</delay:DataContextPropagationGrid>
Pretty similar, huh? The way this works is that the DataContextPropagationGrid
class derives from Grid, listens for DataContext
changes in the usual manner, then uses the fact that changes to the local value of DataContext
work to "re-broadcast" the change to any Binding
s targeting it via ElementName
or Source
. The important thing to note is that any Binding
broken because of the underlying platform bug needs to be pointed at the DataContextPropagationGrid
wrapper instead - and should then behave correctly.
Aside: I haven't historically considered Grid
for workaround scenarios like this. However, it seems like a good fit: Grid
can be wrapped around nearly anything without side-effects, it allows multiple children for scenarios where that might be necessary, it's familiar to developers and designers, and it keeps the workaround code simple!
Just to "close the loop", here's an example of a DataGrid
+ContextMenu
demonstrating the problem:
To reproduce it yourself, run the sample application, right-click on every row of the red DataGrid
(everything will be correct), then scroll to the end of the list and do the same - you'll quickly find a mis-match like I show above. The fix is as easy as introducing the DataContextPropagationGrid
class (as I did for the green DataGrid
) - the important thing is to be sure to attach the ContextMenu
to the DataContextPropagationGrid
so the workaround has a chance to do its thing:
<!-- DataGrid+ContextMenu example of the workaround -->
<sdk:DataGrid Grid.Column="1" Grid.Row="1" AutoGenerateColumns="False" Margin="10">
<sdk:DataGrid.Columns>
<sdk:DataGridTemplateColumn Header="Values">
<sdk:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<delay:DataContextPropagationGrid>
<toolkit:ContextMenuService.ContextMenu>
<toolkit:ContextMenu>
<toolkit:MenuItem Header="{Binding}"/>
</toolkit:ContextMenu>
</toolkit:ContextMenuService.ContextMenu>
<TextBlock Text="{Binding}"/>
</delay:DataContextPropagationGrid>
</DataTemplate>
</sdk:DataGridTemplateColumn.CellTemplate>
</sdk:DataGridTemplateColumn>
</sdk:DataGrid.Columns>
</sdk:DataGrid>
If you run into this Silverlight bug in the DataGrid
+ContextMenu
scenario, please apply the DataContextPropagationGrid
workaround and things should work properly. And if you happen run into the bug in some other scenario, the good news is that the DataContextPropagationGrid
workaround should work there, too! Just be mindful to point the Binding
's ElementName
or Source
at the DataContextPropagationGrid
element, and you're good to go.
[Click here to download the complete source code for the SilverlightDataContextBugWorkaround sample.]
PS - Here's the code for the workaround:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace Delay
{
/// <summary>
/// Class to help work around a Silverlight bug where DataContext changes to
/// an element aren't propagated through Bindings on child elements that use
/// ElementName or Source.
/// </summary>
public class DataContextPropagationGrid : Grid
{
/// <summary>
/// Initializes a new instance of the DataContextPropagationGrid class.
/// </summary>
public DataContextPropagationGrid()
{
// Create a Binding to keep InheritedDataContextProperty correct
SetBinding(InheritedDataContextProperty, new Binding());
}
/// <summary>
/// Identifies the InheritedDataContext DependencyProperty.
/// </summary>
public static readonly DependencyProperty InheritedDataContextProperty =
DependencyProperty.Register(
"InheritedDataContext",
typeof(object),
typeof(DataContextPropagationGrid),
new PropertyMetadata(null, OnInheritedDataContextChanged));
/// <summary>
/// Handles changes to the InheritedDataContext DependencyProperty.
/// </summary>
/// <param name="d">Instance with property change.</param>
/// <param name="e">Property change details.</param>
private static void OnInheritedDataContextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
DataContextPropagationGrid workaround = (DataContextPropagationGrid)d;
// Update local value of DataContext to prompt Silverlight to update problematic Bindings
workaround.DataContext = e.NewValue;
// Unset local value of DataContext so it will continue to inherit from the parent
workaround.ClearValue(FrameworkElement.DataContextProperty);
}
}
}