Silverlight Combobox Databinding race condition
Asked Answered
P

6

6

In my quest to develop a pretty data-driven silverlight app, I seem to continually come up against some sort of race condition that needs to be worked around. The latest one is below. Any help would be appreciated.

You have two tables on the back end: one is Components and one is Manufacturers. Every Component has ONE Manufacturer. Not at all an unusual, foreign key lookup-relationship.

I Silverlight, I access data via WCF service. I will make a call to Components_Get(id) to get the Current component (to view or edit) and a call to Manufacturers_GetAll() to get the complete list of manufacturers to populate the possible selections for a ComboBox. I then Bind the SelectedItem on the ComboBox to the Manufacturer for the Current Component and the ItemSource on the ComboBox to the list of possible Manufacturers. like this:

<UserControl.Resources>
    <data:WebServiceDataManager x:Key="WebService" />
</UserControl.Resources>
<Grid DataContext={Binding Components.Current, mode=OneWay, Source={StaticResource WebService}}>
    <ComboBox Grid.Row="2" Grid.Column="2" Style="{StaticResource ComboBoxStyle}" Margin="3"
              ItemsSource="{Binding Manufacturers.All, Mode=OneWay, Source={StaticResource WebService}}" 
              SelectedItem="{Binding Manufacturer, Mode=TwoWay}"  >
        <ComboBox.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <TextBlock Text="{Binding Name}" Style="{StaticResource DefaultTextStyle}"/>
                </Grid>
            </DataTemplate>
        </ComboBox.ItemTemplate>
    </ComboBox>
</Grid>

This worked great for the longest time, until I got clever and did a little client side caching of the Component (which I planned turn on for the Manufacturers as well). When I turned on caching for the Component and I got a cache hit, all of the data would be there in the objects correctly, but the SelectedItem would fail to Bind. The reason for this, is that the calls are Asynchronous in Silverlight and with the benefit of the caching, the Component is not being returned prior to the Manufacturers. So when the SelectedItem tries to find the Components.Current.Manufacturer in the ItemsSource list, it is not there, because this list is still empty because Manufacturers.All has not loaded from the WCF service yet. Again, if I turn off the Component caching, it works again, but it feels WRONG - like I am just getting lucky that the timing is working out. The correct fix IMHO is for MS to fix the ComboBox/ ItemsControl control to understand that this WILL happen with Asynch calls being the norm. But until then, I need a need a way yo fix it...

Here are some options that I have thought of:

  1. Eliminate the caching or turn it on across the board to once again mask the problem. Not Good IMHO, because this will fail again. Not really willing to sweep it back under the rug.
  2. Create an intermediary object that would do the synchronization for me (that should be done in the ItemsControl itself). It would accept and Item and an ItemsList and then output and ItemWithItemsList property when both have a arrived. I would Bind the ComboBox to the resulting output so that it would never get one item before the other. My problem is that this seems like a pain but it will make certain that the race condition does not re-occur.

Any thougnts/Comments?

FWIW: I will post my solution here for the benefit of others.

@Joe: Thanks so much for the response. I am aware of the need to update the UI only from the UI thread. It is my understanding and I think I have confirmed this through the debugger that in SL2, that the code generated by the the Service Reference takes care of this for you. i.e. when I call Manufacturers_GetAll_Asynch(), I get the Result through the Manufacturers_GetAll_Completed event. If you look inside the Service Reference code that is generated, it ensures that the *Completed event handler is called from the UI thread. My problem is not this, it is that I make two different calls (one for the manufacturers list and one for the component that references an id of a manufacturer) and then Bind both of these results to a single ComboBox. They both Bind on the UI thread, the problem is that if the list does not get there before the selection, the selection is ignored.

Also note that this is still a problem if you just set the ItemSource and the SelectedItem in the wrong order!!!

Another Update: While there is still the combobox race condition, I discovered something else interesting. You should NEVER genrate a PropertyChanged event from within the "getter" for that property. Example: in my SL data object of type ManufacturerData, I have a property called "All". In the Get{} it checks to see if it has been loaded, if not it loads it like this:

public class ManufacturersData : DataServiceAccessbase
{
    public ObservableCollection<Web.Manufacturer> All
    {
        get
        {
            if (!AllLoaded)
                LoadAllManufacturersAsync();
            return mAll;
        }
        private set
        {
            mAll = value;
            OnPropertyChanged("All");
        }
    }

    private void LoadAllManufacturersAsync()
    {
        if (!mCurrentlyLoadingAll)
        {
            mCurrentlyLoadingAll = true;

            // check to see if this component is loaded in local Isolated Storage, if not get it from the webservice
            ObservableCollection<Web.Manufacturer> all = IsoStorageManager.GetDataTransferObjectFromCache<ObservableCollection<Web.Manufacturer>>(mAllManufacturersIsoStoreFilename);
            if (null != all)
            {
                UpdateAll(all);
                mCurrentlyLoadingAll = false;
            }
            else
            {
                Web.SystemBuilderClient sbc = GetSystemBuilderClient();
                sbc.Manufacturers_GetAllCompleted += new EventHandler<hookitupright.com.silverlight.data.Web.Manufacturers_GetAllCompletedEventArgs>(sbc_Manufacturers_GetAllCompleted);
                sbc.Manufacturers_GetAllAsync(); ;
            }
        }
    }
    private void UpdateAll(ObservableCollection<Web.Manufacturer> all)
    {
       All = all;
       AllLoaded = true;
    }
    private void sbc_Manufacturers_GetAllCompleted(object sender, hookitupright.com.silverlight.data.Web.Manufacturers_GetAllCompletedEventArgs e)
    {
        if (e.Error == null)
        {
            UpdateAll(e.Result.Records);
            IsoStorageManager.CacheDataTransferObject<ObservableCollection<Web.Manufacturer>>(e.Result.Records, mAllManufacturersIsoStoreFilename);
        }
        else
            OnWebServiceError(e.Error);
        mCurrentlyLoadingAll = false;
    }

}

Note that this code FAILS on a "cache hit" because it will generate an PropertyChanged event for "All" from within the All { Get {}} method which would normally cause the Binding System to call All {get{}} again...I copied this pattern of creating bindable silverlight data objects from a ScottGu blog posting way back and it has served me well overall, but stuff like this makes it pretty tricky. Luckily the fix is simple. Hope this helps someone else.

Paroicous answered 27/2, 2009 at 18:41 Comment(0)
P
7

Ok I have found the answer (using a lot of Reflector to figure out how the ComboBox works).

The problem exists when the ItemSource is set after the SelectedItem is set. When this happens the Combobx sees it as a complete Reset of the selection and clears the SelectedItem/SelectedIndex. You can see this here in the System.Windows.Controls.Primitives.Selector (the base class for the ComboBox):

protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
    base.OnItemsChanged(e);
    int selectedIndex = this.SelectedIndex;
    bool flag = this.IsInit && this._initializingData.IsIndexSet;
    switch (e.Action)
    {
        case NotifyCollectionChangedAction.Add:
            if (!this.AddedWithSelectionSet(e.NewStartingIndex, e.NewStartingIndex + e.NewItems.Count))
            {
                if ((e.NewStartingIndex <= selectedIndex) && !flag)
                {
                    this._processingSelectionPropertyChange = true;
                    this.SelectedIndex += e.NewItems.Count;
                    this._processingSelectionPropertyChange = false;
                }
                if (e.NewStartingIndex > this._focusedIndex)
                {
                    return;
                }
                this.SetFocusedItem(this._focusedIndex + e.NewItems.Count, false);
            }
            return;

        case NotifyCollectionChangedAction.Remove:
            if (((e.OldStartingIndex > selectedIndex) || (selectedIndex >= (e.OldStartingIndex + e.OldItems.Count))) && (e.OldStartingIndex < selectedIndex))
            {
                this._processingSelectionPropertyChange = true;
                this.SelectedIndex -= e.OldItems.Count;
                this._processingSelectionPropertyChange = false;
            }
            if ((e.OldStartingIndex <= this._focusedIndex) && (this._focusedIndex < (e.OldStartingIndex + e.OldItems.Count)))
            {
                this.SetFocusedItem(-1, false);
                return;
            }
            if (e.OldStartingIndex < selectedIndex)
            {
                this.SetFocusedItem(this._focusedIndex - e.OldItems.Count, false);
            }
            return;

        case NotifyCollectionChangedAction.Replace:
            if (!this.AddedWithSelectionSet(e.NewStartingIndex, e.NewStartingIndex + e.NewItems.Count))
            {
                if ((e.OldStartingIndex <= selectedIndex) && (selectedIndex < (e.OldStartingIndex + e.OldItems.Count)))
                {
                    this.SelectedIndex = -1;
                }
                if ((e.OldStartingIndex > this._focusedIndex) || (this._focusedIndex >= (e.OldStartingIndex + e.OldItems.Count)))
                {
                    return;
                }
                this.SetFocusedItem(-1, false);
            }
            return;

        case NotifyCollectionChangedAction.Reset:
            if (!this.AddedWithSelectionSet(0, base.Items.Count) && !flag)
            {
                this.SelectedIndex = -1;
                this.SetFocusedItem(-1, false);
            }
            return;
    }
    throw new InvalidOperationException();
}

Note the last case - the reset...When you load a new ItemSource you end up here and any SelectedItem/SelectedIndex gets blown away?!?!

Well the solution was pretty simple in the end. i just subclassed the errant ComboBox and provided and override for this method as follows. Though I did have to add a :

public class FixedComboBox : ComboBox
{
    public FixedComboBox()
        : base()
    {
        // This is here to sync the dep properties (OnSelectedItemChanged is private is the base class - thanks M$)
        base.SelectionChanged += (s, e) => { FixedSelectedItem = SelectedItem; };
    }

    // need to add a safe dependency property here to bind to - this will store off the "requested selectedItem" 
    // this whole this is a kludgy wrapper because the OnSelectedItemChanged is private in the base class
    public readonly static DependencyProperty FixedSelectedItemProperty = DependencyProperty.Register("FixedSelectedItem", typeof(object), typeof(FixedComboBox), new PropertyMetadata(null, new PropertyChangedCallback(FixedSelectedItemPropertyChanged)));
    private static void FixedSelectedItemPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
    {
        FixedComboBox fcb = obj as FixedComboBox;
        fcb.mLastSelection = e.NewValue;
        fcb.SelectedItem = e.NewValue;
    }
    public object FixedSelectedItem 
    {
        get { return GetValue(FixedSelectedItemProperty); }
        set { SetValue(FixedSelectedItemProperty, value);}
    }
    protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        base.OnItemsChanged(e);
        if (-1 == SelectedIndex)
        {
            // if after the base class is called, there is no selection, try
            if (null != mLastSelection && Items.Contains(mLastSelection))
                SelectedItem = mLastSelection;
        }
    }

    protected object mLastSelection = null;
}

All that this does is (a) save off the old SelectedItem and then (b) check that if after the ItemsChanged, if we have no selection made and the old SelectedItem exists in the new list...well...Selected It!

Paroicous answered 2/3, 2009 at 21:45 Comment(1)
This solution is a common one. For a long time, I've been looking for a more generic solution that covers all Selector controls; not just ComboBoxes, and does so without inheriting from any control. There is a way to do this with behaviors. This proposed solution also works in UWP, and probably WPF: #36004305Fala
T
2

I was incensed when I first ran into this problem, but I figured there had to be a way around it. My best effort so far is detailed in the post.

Link

I was pretty happy as it narrowed the syntax to something like the following.

<ComboBox Name="AComboBox" 
      ItemsSource="{Binding Data, ElementName=ASource}" 
      SelectedItem="{Binding A, Mode=TwoWay}" 
      ex:ComboBox.Mode="Async" />

Kyle

Thoughtout answered 9/7, 2010 at 19:54 Comment(2)
Thanks Kyle. All that I have tried so far is ex:ComboBox.Mode="AsyncEager" but it did removed the constraint that the SelectedItem needs to be set prior to the ItemsSource which seems to be the core of a lot of the problems described here. Do you know if ther will be a native solution coming in Silverlight 5?Tori
I don't know what will be in SL5, but I haven't heard anything along these lines.Thoughtout
H
1

I struggled through this same issue while building cascading comboboxes, and stumbled across a blog post of someone who found an easy but surprising fix. Call UpdateLayout() after setting the .ItemsSource but before setting the SelectedItem. This must force the code to block until the databinding is complete. I'm not exactly sure why it fixes it but I've not experienced the race condition again since...

Source of this info: http://compiledexperience.com/Blog/post/Gotcha-when-databinding-a-ComboBox-in-Silverlight.aspx

Horrorstruck answered 19/6, 2009 at 16:38 Comment(0)
M
0

It is not clear from your post whether you are aware that you must modify UI elements on the UI thread - or you will have problems. Here is a brief example which creates a background thread which modifies a TextBox with the current time.

The key is MyTextBox.Dispather.BeginInvoke in Page.xaml.cs.

Page.xaml:

<UserControl x:Class="App.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    Width="400" Height="300"
             Loaded="UserControl_Loaded">
    <Grid x:Name="LayoutRoot">
        <TextBox FontSize="36" Text="Just getting started." x:Name="MyTextBox">
        </TextBox>
    </Grid>
</UserControl>

Page.xaml.cs:

using System;
using System.Windows;
using System.Windows.Controls;

namespace App
{
    public partial class Page : UserControl
    {
        public Page()
        {
            InitializeComponent();
        }

        private void UserControl_Loaded(object sender, RoutedEventArgs e)
        {
            // Create our own thread because it runs forever.
            new System.Threading.Thread(new System.Threading.ThreadStart(RunForever)).Start();
        }

        void RunForever()
        {
            System.Random rand = new Random();
            while (true)
            {
                // We want to get the text on the background thread. The idea
                // is to do as much work as possible on the background thread
                // so that we do as little work as possible on the UI thread.
                // Obviously this matters more for accessing a web service or
                // database or doing complex computations - we do this to make
                // the point.
                var now = System.DateTime.Now;
                string text = string.Format("{0}.{1}.{2}.{3}", now.Hour, now.Minute, now.Second, now.Millisecond);

                // We must dispatch this work to the UI thread. If we try to 
                // set MyTextBox.Text from this background thread, an exception
                // will be thrown.
                MyTextBox.Dispatcher.BeginInvoke(delegate()
                {
                    // This code is executed asynchronously on the 
                    // Silverlight UI Thread.
                    MyTextBox.Text = text;
                });
                //
                // This code is running on the background thread. If we executed this
                // code on the UI thread, the UI would be unresponsive.
                //
                // Sleep between 0 and 2500 millisends.
                System.Threading.Thread.Sleep(rand.Next(2500));
            }
        }
    }
}

So, if you want to get things asynchronously, you will have to use Control.Dispatcher.BeginInvoke to notify the UI element that you have some new data.

Mccourt answered 28/2, 2009 at 18:1 Comment(1)
I am aware of the need to update the UI from the UI Thread. See me edit in the original question on this (space limited here and it seemed to warrant putting it there).Paroicous
V
0

Rather than rebinding the ItemsSource each time it would have been easier for you to bind it to an ObservableCollection<> and then call Clear() on it and Add(...) all elements. This way the binding isn't reset.

Another gotcha is that the selected item MUST be an instance of the objects in the list. I made a mistake once when I thought the queried list for the default item was fixed but was regenerated on each call. Thus the current was different though it had a DisplayPath property that was the same as an item of the list.

You could still get the current item's ID (or whatever uniquely defines it), rebind the control and then find in the bound list the item with the same ID and bind that item as the current.

Vernation answered 18/6, 2009 at 11:44 Comment(0)
D
0

In case you arrive here because you have a Combobox selection problem, meaning, nothing happens when you click on your item in the list. Note that the following hints might also help you:

1/ make sure you do not notify something in case you select an item

public string SelectedItem
        {
            get
            {
                return this.selectedItem;
            }
            set
            {
                if (this.selectedItem != value)
                {
                    this.selectedItem = value;
                    //this.OnPropertyChanged("SelectedItem");
                }
            }
        }

2/ make sure the item you select is still in the underlying datasource in case you delete it by accident

I made both errors ;)

Dozier answered 31/8, 2010 at 4:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.