简体   繁体   中英

Async binding to SelectedItem in TabControl WPF issues

I have a panel with tabs. My view model for this panel contains ObservableCollection of view models for tabs, and a property for selected tab.

When some action requests to focus a tab, or a new tab is created, I change Selected and tab selection changes properly, well almost, because the content is valid, but all headers look like nothing is selected.

I found a solution that says to add IsAsync=True to my binding. This solved the problem but added a bunch of new issues.

First thing is that when I run program in debug mode, adding tabs with buttons works ok, tabs get switched and selected properly but when I try to click a tab to select it I get exception

The calling thread cannot access this object because a different thread owns it.

it is thrown while setting property representing currently selected tab:

private Tab selected;
public Tab Selected
{
    get { return Selected; }
    set { SetProperty(ref Selected, value); } // <<< here (I use prism BindableBase)
}

Other problem is that when I quickly switch tabs, it can come to a situation where I have Tab1 selected but it shows content of Tab2, switching tabs couple more times gets things back to work.

My question is, how can I solve this, ie have my tab headers selected (kind of highlighted) when Selected is changed, without having issues that assing IsAsync causes.

Edit

Here is the code that allows to reproduce issues. It uses prism 6.1.0

MainWindow.xaml

<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApplication1"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <DockPanel>
        <StackPanel DockPanel.Dock="Top"
            Orientation="Horizontal"
            Margin="0,5"
            Height="25">
            <Button
                Command="{Binding AddNewTabCommand}"
                Content="New Tab"
                Padding="10,0"/>
            <Button
                Command="{Binding OtherCommand}"
                Content="Do nothing"
                Padding="10,0"/>
        </StackPanel>
        <TabControl
            SelectedItem="{Binding Selected, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay, IsAsync=True}"  <!--remove IsAsync to break tab header selecting-->

            ItemsSource="{Binding Tabs}">
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}" Margin="5"/>
                </DataTemplate>
            </TabControl.ItemTemplate>
            <TabControl.ContentTemplate>
                <DataTemplate>
                    <TextBox Text="{Binding Text}"/>
                </DataTemplate>
            </TabControl.ContentTemplate>
        </TabControl>
    </DockPanel>
</Window>

Code behind:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        this.DataContext = new TabGroup();
    }
}

Tab.cs

public class Tab : BindableBase
{
    public Tab(string name, string text)
    {
        this.name = name;
        this.text = text;
    }

    private string name;
    public string Name
    {
        get { return name; }
        set { SetProperty(ref name, value); }
    }
    private string text;
    public string Text
    {
        get { return text; }
        set { SetProperty(ref text, value); }
    }
}

TabGroup.cs

public class TabGroup : BindableBase
{
    private Random random;

    public TabGroup()
    {
        this.random = new Random();
        this.addNewTabCommand = new Lazy<DelegateCommand>(() => new DelegateCommand(AddNewTab, () => true));
        this.otherCommand = new Lazy<DelegateCommand>(() => new DelegateCommand(Method, () => Selected != null).ObservesProperty(() => Selected));
        Tabs.CollectionChanged += TabsChanged;
    }


    private void Method()
    {

    }

    private void TabsChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        var newItems = e.NewItems?.Cast<Tab>().ToList();
        if (newItems?.Any() == true)
        {
            Selected = newItems.Last();
        }
    }

    private void AddNewTab()
    {
        Tabs.Add(new Tab(GetNextName(), GetRandomContent()));
    }

    private string GetRandomContent()
    {
        return random.Next().ToString();
    }

    private int num = 0;
    private string GetNextName() => $"{num++}";

    private Tab selected;
    public Tab Selected
    {
        get { return selected; }
        set { SetProperty(ref selected, value); }
    }

    public ObservableCollection<Tab> Tabs { get; } = new ObservableCollection<Tab>();


    private readonly Lazy<DelegateCommand> addNewTabCommand;
    public DelegateCommand AddNewTabCommand => addNewTabCommand.Value;

    private readonly Lazy<DelegateCommand> otherCommand;
    public DelegateCommand OtherCommand => otherCommand.Value;
}

Preparing this let me figure where does the exception come from. It is because the OtherCommand observes selected property. I still don't know how to make it right. Most important for me is to get tabs to be selected when they should be and so that selected tab won't desynchronize with what tab control shows.

Here is a github repo with this code

https://github.com/lukaszwawrzyk/TabIssue

I'll focus on your original problem, without the async part.

The reason why the tabs are not properly selected when adding a new tab is because you set the Selected value in the CollectionChanged event handler. Raising an event causes sequential invocation of handlers in order in which they were added. Since you add your handler in the constructor, it will always be the first one to be invoked, and what's important, it will always be invoked before the one that updates the TabControl . So when you set the Selected property in your handler, TabControl doesn't yet "know" that there's such a tab in the collection. More precisely, the header container for the tab is not yet generated, and it cannot be marked as selected (which causes the visual effect you're missing), moreover, it won't be when it's finally generated. TabControl.SelectedItem is still updated, so you see the content of the tab, but it also causes header container previously marked as selected to be unmarked, and you eventually end up with no tab visibly selected.

Depending on your needs, there are several ways to solve this problem. If the only way of adding new tabs is through the AddNewTabCommand , you could just modify the AddNewTab method:

private void AddNewTab()
{
    var tab = new Tab(GetNextName(), GetRandomContent());
    Tabs.Add(tab);
    Selected = tab;
}

In this case you should not set the Selected value in the CollectionChanged handler, because it will prevent PropertyChanged from being raised at the right time.

If AddNewTabCommand is not the only way of adding tabs, what I usually do is to create a dedicated collection which would do the required logic (this class is nested in TabGroup ):

private class TabsCollection : ObservableCollection<Tab>
{
    public TabsCollection(TabGroup owner)
    {
        this.owner = owner;
    }

    private TabGroup owner;

    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnCollectionChanged(e); //this will update the TabControl
        var newItems = e.NewItems?.Cast<Tab>()?.ToList();
        if (newItems?.Any() == true)
            owner.Selected = newItems.Last();
    }
}

Then simply instantiate the collection in the TabGroup constructor:

Tabs = new TabsCollection(this);

If this scenario appears in various places and you don't like repeating your code, you could create a reusable collection class:

public class MyObservableCollection<T> : ObservableCollection<T>
{
    public event NotifyCollectionChangedEventHandler AfterCollectionChanged;

    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnCollectionChanged(e);
        AfterCollectionChanged?.Invoke(this, e);
    }
}

and then subscribe to AfterCollectionChanged whenever you need to be sure that all CollectionChanged subscribers have been notified.

When you get the error "The calling thread cannot access this object because a different thread owns it." this means that you are trying to access an object on another concurrent thread. To show you how to resolve this i want to give an example. First you have to find every runtime objects, like listboxes and listviews and such. (Basically GUI controls). They run on a GUI thread. When you try to run them on another thread forexample a backgroundworker or an task thread, the error appears. So this is what you want to do:

//Lets say i got a listBox i want to update in realtime
//this method is for the purpose of the example running async(background)
public void method(){
   //get data to add to listBox1;
   //listBox1.Items.Add(item); <-- gives the error
   //what you want to do: 
   Invoke(new MethodInvoker(delegate { listBox1.Items.Add(item); }));  
   //This invokes another thread, that we can use to access the listBox1 on. 
   //And this should work
}

Hope it helps.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM