How do I have a WPF Grid row be of Auto height without exceeding the window height?

Viewed 1352

I have a window that contains a ItemsControl that can have a variable number of controls inside. In order to account for the case where there are more than will fit in the window height, I wrapped it in a ScrollViewer, so that a scrollbar would be shown when the number of items was more than would fit in the height available.

Now, the problem is that sometimes there won't be anything to show in the ItemsControl and sometimes there will. Therefore, I set the grid row's height to Auto to allow the ItemsControl to disappear when empty, or grow when needed. However, this means that the row takes as much height as it needs, even if this exceeds the window height, and the vertical scrollbar is never shown.

Here is some XAML for a sample window that demonstrates the issue...

<Window x:Class="DuplicateCustomerCheck.TestScrollViewerWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Test Scroll Viewer Window"
        Height="450"
        Width="200">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto" />
      <RowDefinition Height="Auto" />
      <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>

    <TextBox Name="N"
             TextChanged="TextBoxBase_OnTextChanged"
             Grid.Row="0"
             Margin="3" />

    <Grid Margin="3"
          Grid.Row="1">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>
      <TextBlock Text="Possible duplicate of..."
                 Margin="3" />
      <ScrollViewer VerticalScrollBarVisibility="Visible"
                    Grid.Row="1">

        <ItemsControl Name="MatchingNames"
                      ItemsSource="{Binding MatchingNames, Mode=TwoWay}">
          <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
              <StackPanel Orientation="Vertical" />
            </ItemsPanelTemplate>
          </ItemsControl.ItemsPanel>

          <ItemsControl.ItemTemplate>
            <DataTemplate>
              <Button Content="{Binding Item}" />
            </DataTemplate>
          </ItemsControl.ItemTemplate>
        </ItemsControl>
      </ScrollViewer>
    </Grid>

    <TextBlock Grid.Row="2"
               Margin="3"
               Text="Stuff at the bottom" />
  </Grid>
</Window>

For demonstration purposes, here is the button's event handler that allows me to test different numbers of items (note that this is noddy code, so no error-checking etc)...

private void TextBoxBase_OnTextChanged(object sender, TextChangedEventArgs e) {
  MatchingNames.ItemsSource = Enumerable
    .Range(0, int.Parse(N.Text))
    .Select(n1 => new {
      Item = "Button " + n1
    });
}

If I change the second grid row's height to * then it works fine, but this means that the ItemsControl is permanently visible, which I don't want. It should only be shown when there are some items in it.

I tried the ScrollViewerMaxSizeBehavior behaviour from this blog post (code here), but it didn't make any difference.

Anyone any idea how I can allow the ItemsControl to take as much vertical space as it needs, including zero, but not grow taller than can fit in the window?

2 Answers

This situation is tricky to be solved with XAML only. I would solve it with some calculations…

<Grid x:Name="MyGrid">
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" MaxHeight="{Binding Row2MaxHeight}"/>
        <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>

    <TextBox Name="N" TextChanged="TextBoxBase_OnTextChanged" Grid.Row="0" Margin="3" />

    <Grid Margin="3" Grid.Row="1" x:Name="MyInnerGrid">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <TextBlock Text="Possible duplicate of..." Margin="3" />
        <ScrollViewer Grid.Row="1" MaxHeight="{Binding Row2MaxHeightInner}">
            <ItemsControl Name="MatchingNames" ItemsSource="{Binding MatchingNames, Mode=TwoWay}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Vertical" />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>

                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Button Content="{Binding Item}" />
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    </Grid>

    <TextBlock Grid.Row="2"
               Margin="3"
               Text="Stuff at the bottom" />
</Grid>

And Code:

public partial class MainWindow : Window, INotifyPropertyChanged {
    public MainWindow() {
        InitializeComponent();

        DataContext = this;
        SizeChanged += SizeWasChanged;
    }

    private void SizeWasChanged(object sender, SizeChangedEventArgs e) {
        OnPropertyChanged(nameof(Row2MaxHeight));
        OnPropertyChanged(nameof(Row2MaxHeightInner));
    }

    public double Row2MaxHeight => ActualHeight - MyGrid.RowDefinitions[0].ActualHeight - MyGrid.RowDefinitions[2].ActualHeight - 50; //50 or something is around the Size of the title bar of the window
    public double Row2MaxHeightInner => Row2MaxHeight - MyInnerGrid.RowDefinitions[0].ActualHeight - 6; //6 should match the margin of the scrollviewer

    private void TextBoxBase_OnTextChanged(object sender, TextChangedEventArgs e) {
        MatchingNames.ItemsSource = Enumerable
            .Range(0, int.Parse(N.Text))
            .Select(n1 => new {
                Item = "Button " + n1
            });
        OnPropertyChanged(nameof(Row2MaxHeight));
        OnPropertyChanged(nameof(Row2MaxHeightInner));
    }

    public event PropertyChangedEventHandler PropertyChanged;

    [NotifyPropertyChangedInvocator]
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

I found this question after I had posted a similar question. At least I think we're asking the same thing, although you mention "Therefore, I set the grid row's height to Auto to allow the ItemsControl to disappear when empty, or grow when needed." However, when I tried your sample, even when empty, the ScrollViewer is still showing its scrollbars and it's taking up space.

In any case, mami answered my question, and although their answer didn't exactly work for me, it along with Markus's answer led me to coming up with my own answer.

My answer doesn't quite work with your situation though, since mine assumes that the ItemsControl is the only thing in the Grid row, while you have the "Possible duplicate of..." TextBlock in the row too. I tweaked my answer to take the size of the TextBlock into account, but it's not as clean as I'd like it to be. As a possible optimization, when computing the total height of the ItemsControl's Items, you could probably exit early once the height gets "big enough" (e.g., larger than the Window's height). That way, if you have thousands of items and only a few dozen would realistically fit on screen, you can just get the height of the few dozen instead of the thousands.

In any case, perhaps it'll give you some ideas :)

XAML:

<Window x:Class="WpfApp1.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"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid ShowGridLines="True">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" MaxHeight="{Binding ItemMaxHeight,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=Window}}"/>
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <TextBox Name="N"
             TextChanged="TextBoxBase_OnTextChanged"
             Grid.Row="0"
             Margin="3" />

        <Grid Margin="3"
          Grid.Row="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <TextBlock Name="tb" Text="Possible duplicate of..."
                 Margin="3" />
            <ScrollViewer VerticalScrollBarVisibility="Visible"
                    Grid.Row="1">

                <ItemsControl Name="MatchingNames"
                      ItemsSource="{Binding MatchingNames, Mode=TwoWay}"
                      SizeChanged="MatchingNames_SizeChanged">
                    <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <StackPanel Orientation="Vertical" />
                        </ItemsPanelTemplate>
                    </ItemsControl.ItemsPanel>

                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <Button Content="{Binding Item}" />
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </ScrollViewer>
        </Grid>

        <TextBlock Grid.Row="2"
               Margin="3"
               Text="Stuff at the bottom" />
    </Grid>
</Window>

Code behind:

public partial class MainWindow : Window, INotifyPropertyChanged
{
    public MainWindow()
    {
        InitializeComponent();
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void TextBoxBase_OnTextChanged(object sender, TextChangedEventArgs e)
    {
        MatchingNames.ItemsSource = Enumerable
          .Range(0, int.Parse(N.Text))
          .Select(n1 => new
          {
              Item = "Button " + n1
          });
    }

    public double ItemMaxHeight
    {
        get
        {
            if (MatchingNames == null)
                return double.PositiveInfinity;

            double height = 0.0;
            var icg = MatchingNames.ItemContainerGenerator;
            for (int i = 0; i < MatchingNames.Items.Count; i++)
                height += (icg.ContainerFromIndex(i) as FrameworkElement).ActualHeight;

            return height 
                + tb.Margin.Top + tb.ActualHeight + tb.Margin.Bottom
                + 6.0; // 6 should match the margin of the scrollviewer
        }
    }

    private void MatchingNames_SizeChanged(object sender, SizeChangedEventArgs e)
    {
        if (e.HeightChanged)
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ItemMaxHeight"));
    }
}
Related