0

I'm using Visual Studio 2015 and MVVM Light Toolkit to build a WPF app. When the user clicks an employee in a DataGrid, we show the record's details to allow editing. This details area consists of two tabs: Demographics and Tests. The Tests tab displays a ListView of the tests for this person.

Here's the structure:

MainWindow.xaml:

<DataTemplate x:Key="EmployeeSearchTemplate">
    <view:EmployeeSearchView />
</DataTemplate>

<ContentControl ContentTemplate="{StaticResource EmployeeSearchTemplate}" />

EmployeeSearchView.xaml:

<UserControl.DataContext>
    <viewModel:EmployeeSearchViewModel />
</UserControl.DataContext>

<ContentControl Content="{Binding SelectedEmployee}"
                ContentTemplate="{StaticResource EmployeeViewTemplate}" .../>

When the user selects the Tests tab, we search the db and return the tests for this employee, if any.

EmployeeView.xaml:

<DataTemplate x:Key="TestsViewTemplate">
    <views:TestsView />
</DataTemplate>

<TabControl SelectedIndex="{Binding SelectedTabIndex}">
    <TabItem>
        <!-- Demographic details of record here -->
    </TabItem>
    <TabItem>
        <!-- Employee test info here. When user selects this tab, search db 
             and return tests for this employee, if any -->
        <ContentControl Content="{Binding TestsVm}"
                        ContentTemplate="{StaticResource TestsViewTemplate}" /> 
    </TabItem>
</TabControl>   

Here are the constructor and some properties for EmployeeViewModel.cs:

private TestsViewModel _testsVm;
private int _selectedTabIndex;

public EmployeeViewModel ()
{
    // Other initialization code...

    _selectedTabIndex = 0;

    this.PropertyChanged += (o, e) =>
    {
        if (e.PropertyName == nameof(SelectedTabIndex))
        {
            // If tab 1 selected, the person is on the Tests tab
            // Perform search and populate the TestsVM object's Tests
            // by executing the RelayCommand on it
            if (SelectedTabIndex.Equals(1))
            {
                TestsVm = new TestsViewModel
                {
                    SelectedEmployeeId = EmployeeId
                };
                TestsVm.SearchTestsRelayCommand.Execute(null);
            }
        }
    };
} 

public TestsViewModel TestsVm
{
    get { return _testsVm; }
    set
    {
        if (Equals(value, _testsVm)) return;
        _testsVm = value;
        RaisePropertyChanged();
    }
}

public int SelectedTabIndex
{
    get { return _selectedTabIndex; }
    set
    {
        if (value == _selectedTabIndex) return;
        _selectedTabIndex = value;
        RaisePropertyChanged();
    }
}   

Here's the ListView in TestsView.xaml:

<ListView ItemsSource="{Binding Tests}"
          Visibility="{Binding HasTests,
                               Converter={helpers:BooleanToVisibilityConverter WhenTrue=Visible,
                                                                               WhenFalse=Hidden}}">
    <ListView.View>
        <GridView>
            <!-- GridView columns here -->
        </GridView>
    </ListView.View>
</ListView>

Here's code from TestsViewModel.cs:

private ObservableCollection<TestViewModel> _tests;
private int _selectedEmployeeId;
private bool _hasTests;

public TestsViewModel()
{
    SearchTestsRelayCommand = new RelayCommand(CallSearchTestsAsync);

    this.PropertyChanged += (o, e) =>
    {
        if (e.PropertyName == nameof(Tests))
        {
            HasTests = !Tests.Count.Equals(0);
        }
    };
}  

public RelayCommand SearchTestsRelayCommand { get; private set; }

private async void CallSearchTestsAsync()
{
    await SearchTestsAsync(SelectedEmployeeId);
}

private async Task SearchTestsAsync(int employeeId)
{
    ITestDataService dataService = new TestDataService();

    try
    {
        Tests = await dataService.SearchTestsAsync(employeeId);
    }
    finally
    {
        HasTests = !Tests.Count.Equals(0);
    }
}   

public ObservableCollection<TestViewModel> Tests
{
    get { return _tests; }
    set
    {
        if (Equals(value, _tests)) return;
        _tests = value;
        RaisePropertyChanged();
    }
}

public bool HasTests
{
    get { return _hasTests; }
    set
    {
        if (value == _hasTests) return;
        _hasTests = value;
        RaisePropertyChanged();
    }
}

public int SelectedEmployeeId
{
    get { return _selectedEmployeeId; }
    set
    {
        if (value == _selectedEmployeeId) return;
        _selectedEmployeeId = value;
        RaisePropertyChanged();
    }
}

The HasTests property is not changing and thus not hiding the ListView when it's empty. Note that I also tried the following for the ListView visibility, pointing to its own HasItems to no avail:

Visibility="{Binding HasItems,
   RelativeSource={RelativeSource Self},
   Converter={helpers:BooleanToVisibilityConverter WhenTrue=Visible,
                                                   WhenFalse=Hidden}}"  

I've used the same BooleanToVisibilityConverter successfully elsewhere, so it's something with my code. I'm open to your suggestions. Thank you.

Update: Here's the XAML for TestView.xaml:

<UserControl x:Class="DrugComp.Views.TestsView"
             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:helpers="clr-namespace:DrugComp.Helpers"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             xmlns:viewModel="clr-namespace:DrugComp.ViewModel"
             xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
             d:DesignHeight="300"
             d:DesignWidth="300"
             mc:Ignorable="d">
    <UserControl.Resources />
    <Grid Width="Auto"
          Height="700"
          Margin="5,7,5,5"
          HorizontalAlignment="Left">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="Auto" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="32" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="32" />
            <RowDefinition Height="32" />
            <RowDefinition Height="32" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <TextBlock Grid.Row="0"
                   Grid.ColumnSpan="2"
                   HorizontalAlignment="Left"
                   Style="{StaticResource Instruction}"
                   Text="{Binding Instructions}" />
        <ListView Grid.Row="1"
                  Grid.ColumnSpan="2"
                  Width="Auto"
                  Margin="5"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Top"
                  AlternationCount="2"
                  ItemContainerStyle="{DynamicResource CustomListViewItemStyle}"
                  ItemsSource="{Binding Tests}"
                  SelectedItem="{Binding SelectedTest}">
            <ListView.Style>
                <Style TargetType="{x:Type ListView}">
                    <Setter Property="Visibility" Value="Visible" />
                    <Style.Triggers>
                        <Trigger Property="HasItems" Value="False">
                            <!-- If you want to save the place in the layout, use 
                Hidden instead of Collapsed -->
                            <Setter Property="Visibility" Value="Collapsed" />
                        </Trigger>
                    </Style.Triggers>
                </Style>
            </ListView.Style>
            <ListView.View>
                <GridView>
                    <GridViewColumn Width="50"
                                    DisplayMemberBinding="{Binding TestId}"
                                    Header="Test ID" />
                    <GridViewColumn Width="90"
                                    DisplayMemberBinding="{Binding EmployeeId}"
                                    Header="Employee ID" />
                    <GridViewColumn Width="90"
                                    DisplayMemberBinding="{Binding OrderedDate,
                                                                   StringFormat='MM/dd/yyyy'}"
                                    Header="Ordered Date" />
                    <GridViewColumn Width="119"
                                    DisplayMemberBinding="{Binding ValidReasonForTest.Description}"
                                    Header="Reason" />
                    <GridViewColumn Width="129"
                                    DisplayMemberBinding="{Binding OrderedByWhom}"
                                    Header="Ordered By" />
                    <GridViewColumn Width="90"
                                    DisplayMemberBinding="{Binding ScheduledDate,
                                                                   StringFormat='MM/dd/yyyy'}"
                                    Header="Scheduled Date" />
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</UserControl>
Alex
  • 34,699
  • 13
  • 75
  • 158

2 Answers2

3

As Joe says, you're not getting the notifications. And if you need HasTests for some reason other than hiding this ListView, his answer will help. But that's not the way to do this in a view in XAML.

Update:

A cleaner, simpler way than the answer below.

<!-- In the view's Resources -->
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />

<!-- ... -->

<ListView 
    Visibility="{Binding HasItems, 
      RelativeSource={RelativeSource Self}, 
      Converter=BooleanToVisibility}" />

The (second) cleanest, simplest, easiest way is with a trigger in a style, like this:

<ListView>
    <ListView.View>
        <GridView>
            <!-- GridView columns here -->
        </GridView>
    </ListView.View>
    <ListView.Style>
        <Style 
            TargetType="{x:Type ListView}" 
            BasedOn="{StaticResource {x:Type ListView}}">
            <Style.Triggers>
                <Trigger Property="HasItems" Value="False">
                    <!-- If you want to save the place in the layout, use 
                    Hidden instead of Collapsed -->
                    <Setter Property="Visibility" Value="Collapsed" />
                </Trigger>
            </Style.Triggers>
        </Style>
    </ListView.Style>
</ListView>

Just note that you can't set the Visibility attribute in the XAML like so, because that's a "local" value which will supersede anything the Style does:

<ListView Visibility="Visible" ...>

That's desirable behavior when you want to override styling for a specific instance of a control, but it bites you a lot when you write triggers.

In this specific case I can't imagine any reason you'd do that, but it's a pervasive "gotcha" with styles and triggers in XAML. If you want to set a specific initial value for a property that'll be driven by a trigger, you can do that in a non-triggered Setter in the Style:

        <Style 
            TargetType="{x:Type ListView}" 
            BasedOn="{StaticResource {x:Type ListView}}">
            <Setter Property="Visibility" Value="Visible" />
            <Style.Triggers>
                <Trigger Property="HasItems" Value="False">
                    <!-- If you want to save the place in the layout, use 
                    Hidden instead of Collapsed -->
                    <Setter Property="Visibility" Value="Collapsed" />
                </Trigger>
            </Style.Triggers>
        </Style>

Then it's all one style thing or another, and the trigger will work.

Any descendant of ItemsControl will support the HasItems property: ListBox, ComboBox, MenuItem, you name it. Pretty much any native WPF control that's designed to present a dynamic collection of items (third party control vendors like DevExpress will often ignore this and use their own, often ill-considered, class hierarchy). It's idea for this sort of thing because it's always there, it's very easy to use, and it doesn't matter where the items come from. Whatever you do to put items in that thing, it will hide itself when there aren't any.

  • Thanks, @EdPlunkett. It doesn't like the `BasedOn`: `The resource "{x:Type ListView}" could not be resolved.` – Alex Apr 14 '16 at 13:30
  • @Alex Just remove the `BasedOn` attribute. It just means to "inherit" from whatever existing style there is for that control -- if in your case there isn't one, then it's unnecessary. – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 13:31
  • Got past that error. But it's still showing an empty `ListView`, so somehow the `HasItems` isn't getting updated. How is that possible? – Alex Apr 14 '16 at 13:35
  • Wait, just added the `Style` directly into the `ListView` instead of in a style file and it's now throwing this exception: `Items collection must be empty before using ItemsSource.` – Alex Apr 14 '16 at 13:37
  • Could that be this issue? http://stackoverflow.com/a/683943/424129 -- anything inside the ListView that isn't a `` tag would be interpreted as an item that you're populating it with. You can add items in XAML or via `ItemsSource`, but not both. – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 13:40
  • @Alex Oh right, is the Style inside a `` tag, or did you just toss it in there? I bet that's it. – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 13:41
  • I was missing the `` tag. Now back to the issue of the style not doing anything; I'm getting an empty `ListView` on employees with no tests. – Alex Apr 14 '16 at 13:43
  • @Alex Can you add the entire current XAML for your ListView to the question? – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 13:44
  • @Alex If you still have `Visibility` bound to `HasTests` in the XAML, that'll supersede anything the `Style` does to `Visibility`. – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 13:47
  • Please see updated question above for the entire XAML. – Alex Apr 14 '16 at 13:50
  • 1
    @Alex The XAML you just provided works for me. Is it possible you've got something in codebehind changing the visibility? I'm at a loss. There's some other piece to the puzzle here. I hope Joe can help you get the HasTests thing working. One thing to try (and this is generally useful), is to put a trace on the bindings: https://msdn.microsoft.com/en-us/library/system.diagnostics.presentationtracesources.tracelevel(v=vs.110).aspx -- also you could try that RelativeSource=Self binding with a breakpoint in the valueconverter's Convert method. – 15ee8f99-57ff-4f92-890c-b56153 Apr 14 '16 at 14:13
  • Thanks, @EdPlunkett. I'm going to rip out the TestsView.xaml and just put the ListView into the EmployeeView; simplify as much as I can and see if this thing will work. I'm wondering if the async search somehow affects all this. Arrrgggh! Painful – Alex Apr 14 '16 at 14:16
1

Your code to update HasTests:

this.PropertyChanged += (o, e) =>
{
    if (e.PropertyName == nameof(Tests))
    {
        HasTests = !Tests.Count.Equals(0);
    }
};

Will only fire when the whole property Tests is changed (i.e. assigned to a new ObservableCollection). Presumably, you're not doing this, instead using Clear, Add or Remove to change the content of Tests.

As a result, your HasTests never get's updated. Try also updating on the Tests.CollectionChange event to catch adding/removing.

Edit: Something like this

        this.PropertyChanged += (o, e) =>
        {
            if (e.PropertyName == nameof(Tests))
            {
                HasTests = !Tests.Count.Equals(0);
                //also update when collection changes:
                Tests.CollectionChanged += (o2, e2) =>
                {
                    HasTests = !Tests.Count.Equals(0);
                };
            }
        };
Joe
  • 6,773
  • 2
  • 47
  • 81
  • Thanks, @Joe. Where do I add this and how? – Alex Apr 14 '16 at 13:51
  • Almost exactly what you have but using CollectionChange... I'll add an edit – Joe Apr 14 '16 at 13:58
  • Added your code. I just used a `TextBlock` on the view to see what `HasTests` equals -- it's staying true for all records. I'm switching from one record to another and it doesn't change. – Alex Apr 14 '16 at 14:06
  • Is neither the PropertyChange or CollectionChange events getting hit if you put a breakpoint in them? Does a break-point hit in the setter for Tests? – Joe Apr 14 '16 at 16:12
  • Thanks, @Joe. Got it figured out. Issue on pulling the data... you need to do `Clear()` on the `Tests` and then populate it manually using `Add()`; this ensures the events fire properly. – Alex Apr 14 '16 at 16:14
  • 1
    Hmm, strange, I'd expect it to get set on whole property changes in the first "HasTests =...". Oh well. What works works. – Joe Apr 14 '16 at 16:15