Search code examples
wpflistviewmvvmdatatemplatelistviewitem

ListView ItemsPanelTemplate with horizontal orientation, how to Identify ListViewItems on first row?


First of all I am working with MVVM / WPF / .Net Framework 4.6.1

I have a ListView configured with ItemsPanelTemplate in horizontal orientation that displays items from a DataTemplate. This setup allows me to fit as many items inside the Width of the ListView (the witdth size is the same from the Window), and behaves responsively when I resize the window.

So far everything is fine, now I just want to Identify what items are positioned on the first row, including when the window get resized and items inside the first row increase or decrease.

I merely want to accomplish this behavior because I would like to apply a different template style for those items (let's say a I bigger image or different text color).

enter image description here

Here below the XAML definition for the ListView:

<ListView x:Name="lv"  
          ItemsSource="{Binding Path = ItemsSource}"
          SelectedItem="{Binding Path = SelectedItem}">
    <ListView.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel Orientation="Horizontal"></WrapPanel>
        </ItemsPanelTemplate>
    </ListView.ItemsPanel>
    <ListView.ItemTemplate>
        <DataTemplate>
            <Grid Width="180" Height="35">
                <Grid.RowDefinitions>
                    <RowDefinition />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="auto"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Ellipse Grid.Column="0" Grid.Row="0" Height="32" Width="32" 
                         VerticalAlignment="Top" HorizontalAlignment="Left">
                    <Ellipse.Fill>
                        <ImageBrush ImageSource="{Binding IconPathName}" />
                    </Ellipse.Fill>
                </Ellipse>
                <TextBlock Grid.Column="1" Grid.Row="0" TextWrapping="WrapWithOverflow"
                           HorizontalAlignment="Left" VerticalAlignment="Top"
                           Text="{Binding Name}" />

            </Grid> 
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

BTW: I already did a work around where I am getting the Index from each ListViewItem and calculating against the Width of the Grid inside the DataTemplate that is a fixed value of 180, but unfortunately it did not work as I expected since I had to use a DependencyProperty to bind the ActualWidth of the of the ListView to my ViewModel and did not responded very well when I resized the window.

I know I am looking for a very particular behavior, but if anyone has any suggestions about how to deal with this I would really appreciate. Any thoughts are welcome even if you think I should be using a different control, please detail.

Thanks in advance!


Solution

  • You shouldn't handle the layout in any view model. If you didn't extend ListView consider to use an attached behavior (raw example):

    ListBox.cs

    public class ListBox : DependencyObject
    {
      #region IsAlternateFirstRowTemplateEnabled attached property
    
      public static readonly DependencyProperty IsAlternateFirstRowTemplateEnabledProperty = DependencyProperty.RegisterAttached(
        "IsAlternateFirstRowTemplateEnabled", 
        typeof(bool), typeof(ListView), 
        new PropertyMetadata(default(bool), ListBox.OnIsEnabledChanged));
    
      public static void SetIsAlternateFirstRowTemplateEnabled(DependencyObject attachingElement, bool value) => attachingElement.SetValue(ListBox.IsAlternateFirstRowTemplateEnabledProperty, value);
    
      public static bool GetIsAlternateFirstRowTemplateEnabled(DependencyObject attachingElement) => (bool)attachingElement.GetValue(ListBox.IsAlternateFirstRowTemplateEnabledProperty);
    
      #endregion
    
      private static void OnIsEnabledChanged(DependencyObject attachingElement, DependencyPropertyChangedEventArgs e)
      {
        if (!(attachingElement is System.Windows.Controls.ListBox listBox))
        {
          return;
        }
    
        if ((bool)e.NewValue)
        {
          listBox.Loaded += ListBox.Initialize;
        }
        else
        {
          listBox.SizeChanged -= ListBox.OnListBoxSizeChanged;
        }
      }
    
      private static void Initialize(object sender, RoutedEventArgs e)
      {
        var listBox = sender as System.Windows.Controls.ListBox;
        listBox.Loaded -= ListBox.Initialize;
    
        // Check if items panel is WrapPanel
        if (!listBox.TryFindVisualChildElement(out WrapPanel panel))
        {
          return;
        }
    
        listBox.SizeChanged += ListBox.OnListBoxSizeChanged;
        ListBox.ApplyFirstRowDataTemplate(listBox);
      }
    
      private static void OnListBoxSizeChanged(object sender, SizeChangedEventArgs e)
      {
        if (!e.WidthChanged)
        {
          return;
        }
        var listBox = sender as System.Windows.Controls.ListBox;
        ListBox.ApplyFirstRowDataTemplate(listBox);
      }
    
      private static void ApplyFirstRowDataTemplate(System.Windows.Controls.ListBox listBox)
      {
        double calculatedFirstRowWidth = 0;
        var firstRowDataTemplate = listBox.Resources["FirstRowDataTemplate"] as DataTemplate;
        foreach (FrameworkElement itemContainer in listBox.ItemContainerGenerator.Items
          .Select(listBox.ItemContainerGenerator.ContainerFromItem).Cast<FrameworkElement>())
        {
          calculatedFirstRowWidth += itemContainer.ActualWidth;
          if (itemContainer.TryFindVisualChildElement(out ContentPresenter contentPresenter))
          {
            if (calculatedFirstRowWidth > listBox.ActualWidth - listBox.Padding.Right - listBox.Padding.Left)
            {
              if (contentPresenter.ContentTemplate == firstRowDataTemplate)
              {
                // Restore the default template of previous first row items
                contentPresenter.ContentTemplate = listBox.ItemTemplate;
                continue;
              }
    
              break;
            }
    
            contentPresenter.ContentTemplate = firstRowDataTemplate;
          }
        }
      }
    }
    

    Helper Extension Method

    /// <summary>
    /// Traverses the visual tree towards the leafs until an element with a matching element type is found.
    /// </summary>
    /// <typeparam name="TChild">The type the visual child must match.</typeparam>
    /// <param name="parent"></param>
    /// <param name="resultElement"></param>
    /// <returns></returns>
    public static bool TryFindVisualChildElement<TChild>(this DependencyObject parent, out TChild resultElement)
      where TChild : DependencyObject
    {
      resultElement = null;
    
      if (parent is Popup popup)
      {
        parent = popup.Child;
        if (parent == null)
        {
          return false;
        }
      }
    
      for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
      {
        DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);
    
        if (childElement is TChild child)
        {
          resultElement = child;
          return true;
        }
    
        if (childElement.TryFindVisualChildElement(out resultElement))
        {
          return true;
        }
      }
    
      return false;
    }
    

    Usage

    <ListView x:Name="lv"  
              ListBox.IsAlternateFirstRowTemplateEnabled="True"
              ItemsSource="{Binding Path = ItemsSource}"
              SelectedItem="{Binding Path = SelectedItem}">
        <ListView.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel Orientation="Horizontal" />
            </ItemsPanelTemplate>
        </ListView.ItemsPanel>
        <ListView.Resources>
            <DataTemplate x:Key="FirstRowDataTemplate">
    
                <!-- Draw a red border around first row items -->
                <Border BorderThickness="2" BorderBrush="Red">
                    <Grid Width="180" Height="35">
                        <Grid.RowDefinitions>
                            <RowDefinition />
                        </Grid.RowDefinitions>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="auto"/>
                            <ColumnDefinition Width="*"/>
                        </Grid.ColumnDefinitions>
                        <Ellipse Grid.Column="0" Grid.Row="0" Height="32" Width="32" 
                             VerticalAlignment="Top" HorizontalAlignment="Left">
                            <Ellipse.Fill>
                                <ImageBrush ImageSource="{Binding IconPathName}" />
                            </Ellipse.Fill>
                        </Ellipse>
                        <TextBlock Grid.Column="1" Grid.Row="0" TextWrapping="WrapWithOverflow"
                               HorizontalAlignment="Left" VerticalAlignment="Top"
                               Text="{Binding Name}" />
    
                    </Grid> 
                </Border>
            </DataTemplate>
        </ListView.Resources>
        <ListView.ItemTemplate>
            <DataTemplate>
                <Grid Width="180" Height="35">
                    <Grid.RowDefinitions>
                        <RowDefinition />
                    </Grid.RowDefinitions>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="auto"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <Ellipse Grid.Column="0" Grid.Row="0" Height="32" Width="32" 
                             VerticalAlignment="Top" HorizontalAlignment="Left">
                        <Ellipse.Fill>
                            <ImageBrush ImageSource="{Binding IconPathName}" />
                        </Ellipse.Fill>
                    </Ellipse>
                    <TextBlock Grid.Column="1" Grid.Row="0" TextWrapping="WrapWithOverflow"
                               HorizontalAlignment="Left" VerticalAlignment="Top"
                               Text="{Binding Name}" />
    
                </Grid> 
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
    

    Remarks
    If the visual tree itself will not change for the first row, consider to add a second attached property to the ListBox class (e.g., IsFirstRowItem) which you would set on the ListBoxItems. You can then use a DataTrigger to modify the control properties to change the appearance. This will very likely increase the performance too.