1
votes

I've been wrestling with this problem for some time and I haven't found a workable answer, and I'm sure it has a lot to do with my lack of understanding of WPF.

The basic architecture of my program will be similar to Visual Studio, with blocks of tabs that can be arranged various ways. For simplicity, I currently have a single block of tabs that cannot be arranged in any way - the form contains a custom control that contains a tab control.

Now, the content of each tab page will be a single custom control that is a view for a document. The tab pages may have different documents, and each document has a specific custom control and document view model pairing.

My major issue is that I cannot figure out how to reference the tab page's view model in the tab page control's code-behind.

Here is the code for the TabPane - the custom control that is the block of tabs. It includes code I pieced together from various web sites for adding a close button to the tabs (I may add more buttons later).

<UserControl x:Class="MyApp.TabPane"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
         xmlns:local="clr-namespace:MyApp"
         DataContext="{Binding RelativeSource={RelativeSource Self}}"
         mc:Ignorable="d" 
         d:DesignHeight="300" d:DesignWidth="300">
<DockPanel LastChildFill="True" >
    <TabControl ItemsSource="{Binding Path=ViewModel.TabPages}" SelectedItem="{Binding Path=ViewModel.ActivePage}" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
        <TabControl.Resources>
            <DataTemplate DataType="{x:Type local:NowPlayingViewModel}" >
                <local:NowPlayingControl />
            </DataTemplate>
            <DataTemplate DataType="{x:Type local:TypeEditorDocumentViewModel}" >
                <local:TypeEditorControl />
            </DataTemplate>
        </TabControl.Resources>
        <TabControl.ItemContainerStyle>
            <Style TargetType="TabItem">
                <Setter Property="HeaderTemplate" >
                    <Setter.Value>
                        <DataTemplate>
                            <StackPanel Orientation="Horizontal" Margin="0" Height="22">
                                <TextBlock VerticalAlignment="Center" Text="{Binding Path=Caption}" />
                                <local:LibraryTabHeaderButton Name="tabPageCloseButton" Width="20" Height="19" Margin="6,0,0,0" Padding="0" HorizontalAlignment="Center" VerticalAlignment="Center"
                                    Focusable="False" 
                                    Visibility="{Binding Path=AllowClose, Converter={local:BooleanToVisibilityConverter}}"
                                    Click="tabPageCloseButton_Click">
                                    <Path Data="M1,9 L9,1 M1,1 L9,9" Stroke="Black" StrokeThickness="2" />
                                </local:LibraryTabHeaderButton>
                            </StackPanel>
                        </DataTemplate>
                    </Setter.Value>
                </Setter>
            </Style>
        </TabControl.ItemContainerStyle>
    </TabControl>
</DockPanel>

This is the code-behind for the TabPane. The TabPane is assigned a view model by the application so that the view model can provide a pre-existing set of tabs (there will be some tabs that can only exist in one TabPane and cannot be closed). The close button will need to tell the view model which tab is closing. Any closable tab may be closed, regardless of whether or not the tab is active.

    public partial class TabPane : UserControl
{
    private TabPaneViewModel viewModel = null;

    public TabPane()
    {
        viewModel = ApplicationManager.GetLibraryViewModel().GetNewTabPaneViewModel();

        InitializeComponent();
    }

    public TabPaneViewModel ViewModel
    {
        get { return viewModel; }
    }

    private void tabPageCloseButton_Click(object sender, EventArgs e)
    {
        //Button button = (Button)sender;
    }

The TabPaneViewModel is pretty straight forward. It basicly holds a collection of tab pages.

    public class TabPaneViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private readonly int id;
    private ObservableCollection<ILibraryDocumentViewModel> tabPages = new ObservableCollection<ILibraryDocumentViewModel>();
    private ILibraryDocumentViewModel activePage = null;

    public TabPaneViewModel(int id)
    {
        this.id = id;
        tabPages.CollectionChanged += tabPages_CollectionChanged;
    }

    private void tabPages_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if( !isDisposed && (e.Action == NotifyCollectionChangedAction.Add) )
            ActivePage = (ILibraryDocumentViewModel)e.NewItems[0];
    }

    public ObservableCollection<ILibraryDocumentViewModel> TabPages
    {
        get { return tabPages; }
    }

    public ILibraryDocumentViewModel ActivePage
    {
        get { return activePage; }
        set
        {
            activePage = value;
            OnNotifyPropertyChanged("ActivePage");
        }
    }

    private void OnNotifyPropertyChanged(string propertyName)
    {
        if( !isDisposed && (PropertyChanged != null) )
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

The LibraryTabHeaderButton is a simple custom control with a click event. I've found that this header button has no idea which tab it is associated with, so somehow I need to tell it. I have not figured out how to do this yet.

    public partial class LibraryTabHeaderButton : UserControl
{
    public event EventHandler Click;

    public LibraryTabHeaderButton()
    {
        InitializeComponent();
    }

    public int TabPageId { get; set; }

    private void OnClick(object sender, RoutedEventArgs args)
    {
        LibraryTabHeaderButtonClickEventArgs newArgs = new LibraryTabHeaderButtonClickEventArgs();

        newArgs.TabPageId = TabPageId;
        if( Click != null )
            Click(sender, newArgs);
    }
}

The LibraryViewModel is the source of the documents. It receives an instruction to create or open a document, does so, and adds it to a TabPane.

    public class LibraryViewModel
{
    private List<TabPaneViewModel> tabPanes = new List<TabPaneViewModel>();
    private static int nextTabPaneId = 1;
    private TabPaneViewModel activeTabPane = null;
    private NowPlayingViewModel nowPlayingViewModel = null;
    private static int nextTabPageId = 1;

    public TabPaneViewModel GetNewTabPaneViewModel()
    {
        TabPaneViewModel tabPane = new TabPaneViewModel(nextTabPaneId);

        nextTabPaneId++;
        tabPanes.Add(tabPane);

        if( activeTabPane == null )
            activeTabPane = tabPane;

        if( nowPlayingViewModel == null )
        {
            // Show the now playing tab on this pane
            nowPlayingViewModel = new NowPlayingViewModel(nextTabPageId);
            nextTabPageId++;
            tabPane.TabPages.Add(nowPlayingViewModel);
        }

        return tabPane;
    }

    public void DisplayDocument(LibraryDocumentType documentType)
    {
        if( activeTabPane != null )
        {
            ILibraryDocumentViewModel document = null;

            switch( documentType )
            {
                case LibraryDocumentType.TypeEditor:
                    document = new TypeEditorDocumentViewModel(nextTabPageId);
                    nextTabPageId++;
                    break;
            }

            activeTabPane.TabPages.Add(document);
        }
    }

}

Here is the ILibraryDocumentViewModel.

    public interface ILibraryDocumentViewModel
{
    int TabPageId { get; }
    LibraryDocumentType DocumentType { get; }
    string Caption { get; }
    bool AllowClose { get; }
    bool IsChanged { get; }
}

This is part of the TypeEditorControl code-behind. I have tried various methods to access the view model from here, but it is always null.

    public partial class TypeEditorControl : UserControl
{
    private TypeEditorDocumentViewModel viewModel = null;

    public TypeEditorControl()
    {
        object v = this.ViewModel;
        viewModel = DataContext as TypeEditorDocumentViewModel;

        InitializeComponent();
        viewModel = DataContext as TypeEditorDocumentViewModel;

        UpdateEditMode();
    }

    public TypeEditorDocumentViewModel ViewModel
    {
        get { return viewModel; }
    }

Here is part of the TypeEditorControl xaml.

<UserControl x:Class="MyApp.TypeEditorControl"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
         xmlns:local="clr-namespace:MyApp"
         mc:Ignorable="d" 
         d:DesignHeight="630">
<Grid Margin="0" Background="#FFF0F0F0">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <Label x:Name="categoryListLbl" Content="Type Categories" Grid.Column="0" HorizontalAlignment="Left" Margin="10,5,10,0" Grid.Row="0" VerticalAlignment="Top"/>
    <ListView x:Name="categoryListLvw" Margin="10,0" Grid.Row="1" Grid.ColumnSpan="2" ItemsSource="{Binding Path=ViewModel.TypeCategories}" SelectionChanged="categoryListLvw_SelectionChanged" SelectionMode="Single" >
        <ListView.View>
            <GridView>

I think that's enough code... Now, the program runs and I can bring up various documents, but the documents don't display data from a view model.

Because I create the document view model and add it to the TabPages ObservableCollection, I cannot get a reference to the document view model in that document control's code behind. The TabPane automatically generates a TypeEditorControl when I add a TypeEditorViewModel, but I can't seem to access the link between them. My current guess is that I don't have the DataContext properly set in the TypeEditorControl xaml (I've tried no DataContext and one referencing Self, neither worked and I don't know what else to try). Maybe my architecture won't support what I'm trying to do. Maybe I'm asking the wrong question.

I'm at a loss of what to try next. Thank you to anyone who reads this enormous posting.

2

2 Answers

1
votes

A small but effective sample:

enter image description here

Code-behind: definition of a simple view model

namespace WpfApplication10
{
    public partial class MainWindow
    {
        public MainWindow()
        {
            InitializeComponent();
            var appViewModel = new AppViewModel
            {
                HelloFromApp = "Hello from app VM!",
                Tab1ViewModel = new Tab1ViewModel {HelloFromTab1 = "Hello from tab 1 VM !"},
                Tab2ViewModel = new Tab2ViewModel {HelloFromTab2 = "Hello from tab 2 VM !"}
            };
            DataContext = appViewModel;
        }
    }

    internal abstract class ViewModel
    {
    }

    internal class AppViewModel : ViewModel
    {
        public string HelloFromApp { get; set; }
        public Tab1ViewModel Tab1ViewModel { get; set; }
        public Tab2ViewModel Tab2ViewModel { get; set; }
    }

    internal class Tab1ViewModel : ViewModel
    {
        public string HelloFromTab1 { get; set; }
    }

    internal class Tab2ViewModel : ViewModel
    {
        public string HelloFromTab2 { get; set; }
    }
}

XAML: here I show you how to display content in Tab1 from different sources

<Window x:Class="WpfApplication10.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:wpfApplication10="clr-namespace:WpfApplication10"
        Title="MainWindow"
        Width="525"
        Height="350"
        d:DataContext="{d:DesignInstance wpfApplication10:AppViewModel}"
        mc:Ignorable="d">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <TextBlock Text="{Binding HelloFromApp}" />
        <TabControl Grid.Row="1">
            <TabItem Header="Tab1">
                <StackPanel>
                    <TextBlock Text="{Binding HelloFromApp, StringFormat='{}Some content from AppViewModel: {0}'}" />
                    <TextBlock Text="{Binding Tab1ViewModel.HelloFromTab1, StringFormat='{}Some content from AppViewModel.Tab1ViewModel: {0}'}" />
                    <TextBlock DataContext="{Binding Tab2ViewModel}" Text="{Binding HelloFromTab2, StringFormat='{}Some content from AppViewModel.Tab2ViewModel: {0}'}" />
                </StackPanel>
            </TabItem>
            <TabItem Header="Tab2" />
        </TabControl>
    </Grid>
</Window>

As you can see, I never set the DataContext except in last example for a simpler syntax (compare Text binding with the one above it). What guys did at MS is pretty good, by default DataContext inherits parent value which as you can see is really helpful.

For docking tabs: https://avalondock.codeplex.com/

1
votes

I happened to be poking around in the overrides for my TypeEditorControl and I found the OnApplyTemplate method. I read a bit about it and experimented with it to find that in this override, the DataContext is set to the object I want. This gave me a chance to catch a reference to it. Following is the updated code for TypeEditorControl.

    public partial class TypeEditorControl : UserControl
{
    private TypeEditorDocumentViewModel viewModel = null;

    public TypeEditorControl()
    {
        InitializeComponent();
    }

    public override void OnApplyTemplate()
    {
        viewModel = DataContext as TypeEditorDocumentViewModel;

        base.OnApplyTemplate();

        UpdateEditMode();
    }

    public TypeEditorDocumentViewModel ViewModel
    {
        get { return viewModel; }
    }

Then I wondered if the same may be true for the tab page header. Sure enough, it worked there, too. Here is the new code for LibraryTabHeaderButton. I started using tab page ID and document ID interchangeably, so I do appologize for that.

    public partial class LibraryTabHeaderButton : UserControl
{
    public event LibraryTabHeaderButtonClickEventHandler Click;

    private int documentId = 0;

    public LibraryTabHeaderButton()
    {
        InitializeComponent();
    }

    public override void OnApplyTemplate()
    {
        ILibraryDocumentViewModel viewModel = this.DataContext as ILibraryDocumentViewModel;

        if( viewModel != null )
            documentId = viewModel.DocumentId;

        base.OnApplyTemplate();
    }

    private void button_Click(object sender, RoutedEventArgs args)
    {
        LibraryTabHeaderButtonClickEventArgs newArgs = new LibraryTabHeaderButtonClickEventArgs();

        newArgs.TabPageId = documentId;
        if( Click != null )
            Click(sender, newArgs);
    }
}

The TabPane header close event is updated to send the request to the TabPaneViewModel.

    public partial class TabPane : UserControl
{
    ...

    private void tabPageCloseButton_Click(object sender, LibraryTabHeaderButtonClickEventArgs args)
    {
        viewModel.CloseTabPage(args.TabPageId);
    }
}

    public class TabPaneViewModel : INotifyPropertyChanged
{
    private ObservableCollection<ILibraryDocumentViewModel> tabPages = new ObservableCollection<ILibraryDocumentViewModel>();

    ...

    public void CloseTabPage(int documentId)
    {
        ILibraryDocumentViewModel document = tabPages.First(
            entry => entry.DocumentId == documentId);

        if( document != null )
            tabPages.Remove(document);
    }

    ...
}

I'm sure it's not the best solution, but now I have access to the view model in the tab page's code behind and I can close inactive tab pages.