WPF ListBox自动滚动到末尾

59

在我的应用程序中,我有一个带有项目的 ListBox。这个应用程序是使用 WPF 编写的。

我该如何自动滚动到最后添加的项目?当新项目被添加时,我想让 ScrollViewer 移动到列表的末尾。

是否有像 ItemsChanged 这样的事件?(我不想使用 SelectionChanged 事件)

15个回答

56

最简单的方法是:

if (VisualTreeHelper.GetChildrenCount(listView) > 0)
{
    Border border = (Border)VisualTreeHelper.GetChild(listView, 0);
    ScrollViewer scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
    scrollViewer.ScrollToBottom();
}

这段代码始终适用于 ListView 和 ListBox 控件。将此代码附加到 listView.Items.SourceCollection.CollectionChanged 事件上,您将拥有完全自动的自动滚动行为。


1
其他解决方案对我根本没有用。代码已执行(在调试中证明),但它对控件的状态没有任何影响。这个方法第一次就完美地解决了问题。 - Mark W
2
如果你使用自定义模板来进行ListBox的操作,这种方法可能无法正常工作,所以请谨慎。 - Drew Noakes
6
如果有人想知道如何将CollectionChanged事件附加到ListBox上:在InitializeComponent();之后,您需要添加((INotifyCollectionChanged).Items).CollectionChanged += YourListboxCollectionChanged;。请注意,您需要保持原文意思不变,并使翻译更加通俗易懂。 - MarkusEgle
1
第一个子元素对我来说是 ListBoxChrome。将类型转换从 Border 改为 FrameworkElement,现在它完美地工作了,谢谢! - Alfie
我确认@Alfie上面写的内容。所以,Border border = (Border)...必须更改为FrameworkElement border = (FrameworkElement)... - Mike Nakis
1
这很棒,但是你可以在找到 ScrollViewer 后保存它的句柄,以节省一些工作。 - Chuck Savage

55

试试这个:

lstBox.SelectedIndex = lstBox.Items.Count -1;
lstBox.ScrollIntoView(lstBox.SelectedItem) ;

在您的 MainWindow 中,这将选择并聚焦于列表中的最后一项!


这只是一个有效的选项,如果最后添加的项目是列表中的最后一个。但是,最后添加的项目可能会被添加到位置0。 - 0xBADF00D
5
这个答案应该被接受!如果是这样的话,你只需要执行lstBox.SelectedIndex = 0 ;) - DerpyNerd
1
不适用于原始值、struct 或实现比较器以比较值而非引用的 record。此外,问题只回答了一半:你会在哪个事件中执行它? - Emy Blacksmith

35

请注意,listBox.ScrollIntoView(listBox.Items[listBox.Items.Count - 1]); 只在没有重复项时起作用。如果有内容相同的项目,则向下滚动到第一个查找结果。

我找到的解决方案如下:

ListBoxAutomationPeer svAutomation = 
    ListBoxAutomationPeer)ScrollViewerAutomationPeer.
        CreatePeerForElement(myListBox);

IScrollProvider scrollInterface =
    (IScrollProvider)svAutomation.GetPattern(PatternInterface.Scroll);

System.Windows.Automation.ScrollAmount scrollVertical = 
    System.Windows.Automation.ScrollAmount.LargeIncrement;

System.Windows.Automation.ScrollAmount scrollHorizontal = 
    System.Windows.Automation.ScrollAmount.NoAmount;

// If the vertical scroller is not available, 
// the operation cannot be performed, which will raise an exception. 
if (scrollInterface.VerticallyScrollable)
    scrollInterface.Scroll(scrollHorizontal, scrollVertical);

谢谢。对我来说无瑕疵地工作。我认为你应该将chatMessages更改为类似于myListBox的内容。 - Syaiful Nizam Yahya
4
好的,谢谢。供其他人参考:必须将这些引用添加到您的项目中:UIAutomationProvider和UIAutomationTypes。 - BCA

31

最佳解决方案是使用 ListBox 控件内部的 ItemCollection 对象,该集合专门为内容查看器而设计。它具有预定义的方法来选择最后一个项目并保持光标位置引用...

myListBox.Items.MoveCurrentToLast();
myListBox.ScrollIntoView(myListBox.Items.CurrentItem);

是的,同意@Givanio的观点,在设置了SelectedItem之后,我的鼠标光标在listview中将不再起作用。谢谢! - yancyn

16

一个略微不同的方法,与迄今为止提出的方法有所不同。

您可以使用 ScrollViewerScrollChanged 事件,并观察 ScrollViewer 内容变得更大。

private void ListBox_OnLoaded(object sender, RoutedEventArgs e)
{
    var listBox = (ListBox) sender;

    var scrollViewer = FindScrollViewer(listBox);

    if (scrollViewer != null)
    {
        scrollViewer.ScrollChanged += (o, args) =>
        {
            if (args.ExtentHeightChange > 0)
                scrollViewer.ScrollToBottom();
        };
    }
}

这样可以避免与 ListBoxItemsSource 绑定更改相关的问题。

ScrollViewer 也可以在不假设 ListBox 使用默认控件模板的情况下找到。

// Search for ScrollViewer, breadth-first
private static ScrollViewer FindScrollViewer(DependencyObject root)
{
    var queue = new Queue<DependencyObject>(new[] {root});

    do
    {
        var item = queue.Dequeue();

        if (item is ScrollViewer)
            return (ScrollViewer) item;

        for (var i = 0; i < VisualTreeHelper.GetChildrenCount(item); i++)
            queue.Enqueue(VisualTreeHelper.GetChild(item, i));
    } while (queue.Count > 0);

    return null;
}

然后将此附加到 ListBox Loaded 事件:

<ListBox Loaded="ListBox_OnLoaded" />

这可以很容易地修改为附加属性,使其更具通用性。


或者yarik的建议:

<ListBox ScrollViewer.ScrollChanged="ScrollViewer_OnScrollChanged" />

并且在代码后台:

private void ScrollViewer_OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
    if (e.OriginalSource is ScrollViewer scrollViewer &&
        Math.Abs(e.ExtentHeightChange) > 0.0)
    {
        scrollViewer.ScrollToBottom();
    }
}

这是一个不错的工作解决方案,但由于 WPF 路由事件会沿着元素树向上冒泡,因此大多数代码都是不必要的:<ListBox ScrollViewer.ScrollChanged="..." /> - Yarik
你必须小心处理这个问题,因为如果 ListBox 有自定义模板,它可能没有 ScrollViewer - Scroog1
如果没有ScrollViewer,那么就没有可滚动的内容,此事件也不会被触发。 - Yarik
我的错。我假设如果更改了模板,ScrollViewer属性将不可用。但是,使用这种方法仍然存在一个缺点,即必须为每个ListBox实现单独的事件处理程序(或者至少为包含列表框的每个控件实现一个处理程序)。而附加属性只需要一个实现。很遗憾您无法调用静态方法事件处理程序。 - Scroog1

6

listBox.ScrollIntoView(listBox.Items[listBox.Items.Count - 1]);

的意思是让listBox自动滚动到最后一个选项。

6

这里的所有答案都不能满足我的需求。因此,我编写了自己的行为来自动滚动项目控件,并在用户向上滚动时暂停自动滚动,并在用户向底部滚动时恢复自动滚动。

/// <summary>
/// This will auto scroll a list view to the bottom as items are added.
/// Automatically suspends if the user scrolls up, and recommences when
/// the user scrolls to the end.
/// </summary>
/// <example>
///     <ListView sf:AutoScrollToBottomBehavior="{Binding viewModelAutoScrollFlag}" />
/// </example>
public class AutoScrollToBottomBehavior
{
  /// <summary>
  /// Enumerated type to keep track of the current auto scroll status
  /// </summary>
  public enum StatusType
  {
    NotAutoScrollingToBottom,
    AutoScrollingToBottom,
    AutoScrollingToBottomButSuppressed
  }

  public static StatusType GetAutoScrollToBottomStatus(DependencyObject obj)
  {
    return (StatusType)obj.GetValue(AutoScrollToBottomStatusProperty);
  }

  public static void SetAutoScrollToBottomStatus(DependencyObject obj, StatusType value)
  {
    obj.SetValue(AutoScrollToBottomStatusProperty, value);
  }

  // Using a DependencyProperty as the backing store for AutoScrollToBottomStatus.  This enables animation, styling, binding, etc...
  public static readonly DependencyProperty AutoScrollToBottomStatusProperty =
      DependencyProperty.RegisterAttached(
        "AutoScrollToBottomStatus",
        typeof(StatusType),
        typeof(AutoScrollToBottomBehavior),
        new PropertyMetadata(StatusType.NotAutoScrollingToBottom, (s, e) =>
        {
          if (s is DependencyObject viewer && e.NewValue is StatusType autoScrollToBottomStatus)
          {
            // Set the AutoScrollToBottom property to mirror this one

            bool? autoScrollToBottom = autoScrollToBottomStatus switch
            {
              StatusType.AutoScrollingToBottom => true,
              StatusType.NotAutoScrollingToBottom => false,
              StatusType.AutoScrollingToBottomButSuppressed => false,
              _ => null
            };

            if (autoScrollToBottom.HasValue)
            {
              SetAutoScrollToBottom(viewer, autoScrollToBottom.Value);
            }

            // Only hook/unhook for cases below, not when suspended
            switch(autoScrollToBottomStatus)
            {
              case StatusType.AutoScrollingToBottom:
                HookViewer(viewer);
                break;
              case StatusType.NotAutoScrollingToBottom:
                UnhookViewer(viewer);
                break;
            }
          }
        }));


  public static bool GetAutoScrollToBottom(DependencyObject obj)
  {
    return (bool)obj.GetValue(AutoScrollToBottomProperty);
  }

  public static void SetAutoScrollToBottom(DependencyObject obj, bool value)
  {
    obj.SetValue(AutoScrollToBottomProperty, value);
  }

  // Using a DependencyProperty as the backing store for AutoScrollToBottom.  This enables animation, styling, binding, etc...
  public static readonly DependencyProperty AutoScrollToBottomProperty =
      DependencyProperty.RegisterAttached(
        "AutoScrollToBottom",
        typeof(bool),
        typeof(AutoScrollToBottomBehavior),
        new FrameworkPropertyMetadata(false,  FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (s, e) =>
        {
          if (s is DependencyObject viewer && e.NewValue is bool autoScrollToBottom)
          {
            // Set the AutoScrollToBottomStatus property to mirror this one
            if (autoScrollToBottom)
            {
              SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottom);
            }
            else if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
            {
              SetAutoScrollToBottomStatus(viewer, StatusType.NotAutoScrollingToBottom);
            }

            // No change if autoScrollToBottom = false && viewer.AutoScrollToBottomStatus = AutoScrollToBottomStatusType.AutoScrollingToBottomButSuppressed;
          }
        }));


  private static Action GetUnhookAction(DependencyObject obj)
  {
    return (Action)obj.GetValue(UnhookActionProperty);
  }

  private static void SetUnhookAction(DependencyObject obj, Action value)
  {
    obj.SetValue(UnhookActionProperty, value);
  }

  // Using a DependencyProperty as the backing store for MyProperty.  This enables animation, styling, binding, etc...
  private static readonly DependencyProperty UnhookActionProperty =
      DependencyProperty.RegisterAttached("UnhookAction", typeof(Action), typeof(AutoScrollToBottomBehavior), new PropertyMetadata(null));

  private static void ItemsControl_Loaded(object sender, RoutedEventArgs e)
  {
    if (sender is ItemsControl itemsControl)
    {
      itemsControl.Loaded -= ItemsControl_Loaded;
      HookViewer(itemsControl);
    }
  }

  private static void HookViewer(DependencyObject viewer)
  {
    if (viewer is ItemsControl itemsControl)
    {
      // If this is triggered the xaml setup then the control won't be loaded yet,
      // and so won't have a visual tree which we need to get the scrollviewer,
      // so defer this hooking until the items control is loaded.
      if (!itemsControl.IsLoaded)
      {
        itemsControl.Loaded += ItemsControl_Loaded;
        return;
      }

      if (FindScrollViewer(viewer) is ScrollViewer scrollViewer)
      {
        scrollViewer.ScrollToBottom();

        // Scroll to bottom when the item count changes
        NotifyCollectionChangedEventHandler itemsCollectionChangedHandler = (s, e) =>
        {
          if (GetAutoScrollToBottom(viewer))
          {
            scrollViewer.ScrollToBottom();
          }
        };
        ((INotifyCollectionChanged)itemsControl.Items).CollectionChanged += itemsCollectionChangedHandler;


        ScrollChangedEventHandler scrollChangedEventHandler = (s, e) =>
        {
          bool userScrolledToBottom = (e.VerticalOffset + e.ViewportHeight) > (e.ExtentHeight - 1.0);
          bool userScrolledUp = e.VerticalChange < 0;

          // Check if auto scrolling should be suppressed
          if (userScrolledUp && !userScrolledToBottom)
          {
            if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
            {
              SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottomButSuppressed);
            }
          }

          // Check if auto scrolling should be unsuppressed
          if (userScrolledToBottom)
          {
            if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottomButSuppressed)
            {
              SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottom);
            }
          }
        };

        scrollViewer.ScrollChanged += scrollChangedEventHandler;

        Action unhookAction = () =>
        {
          ((INotifyCollectionChanged)itemsControl.Items).CollectionChanged -= itemsCollectionChangedHandler;
          scrollViewer.ScrollChanged -= scrollChangedEventHandler;
        };

        SetUnhookAction(viewer, unhookAction);
      }
    }
  }

  /// <summary>
  /// Unsubscribes the event listeners on the ItemsControl and ScrollViewer
  /// </summary>
  /// <param name="viewer"></param>
  private static void UnhookViewer(DependencyObject viewer)
  {
    var unhookAction = GetUnhookAction(viewer);
    SetUnhookAction(viewer, null);
    unhookAction?.Invoke();
  }

  /// <summary>
  /// A recursive function that drills down a visual tree until a ScrollViewer is found.
  /// </summary>
  /// <param name="viewer"></param>
  /// <returns></returns>
  private static ScrollViewer FindScrollViewer(DependencyObject viewer)
  {
    if (viewer is ScrollViewer scrollViewer)
      return scrollViewer;

    return Enumerable.Range(0, VisualTreeHelper.GetChildrenCount(viewer))
      .Select(i => FindScrollViewer(VisualTreeHelper.GetChild(viewer, i)))
      .Where(child => child != null)
      .FirstOrDefault();
  }
}

很好,正是我需要的。不得不进行一些调整:FindScrollViewer现在也会向上搜索树(我的ItemsControl被包装在ScrollViewer中);将switch-assignment改为switch-case(仍然在.net 4.6上);以及使用AutoScrollToBottomBehavior.AutoScrollToBottomStatus="AutoScrollingToBottom" - MHolzmayr
@JohnStewien 这个方法很好用,只要屏幕上只有一个具有这个属性的控件。如果有两个或更多的控件,只有其中一个会与ItemsControl_Loaded属性关联。你有什么想法可以修改代码,让多个控件都能使用这个功能吗? - Alexa Kirk

2
最简单的实现自动滚动的方法是钩取 CollectionChanged 事件。只需将该功能添加到从 ListBox 控件派生的自定义类中即可:
using System.Collections.Specialized;
using System.Windows.Controls;
using System.Windows.Media;

namespace YourProgram.CustomControls
{
  public class AutoScrollListBox : ListBox
  {
      public AutoScrollListBox()
      {
          if (Items != null)
          {
              // Hook to the CollectionChanged event of your ObservableCollection
              ((INotifyCollectionChanged)Items).CollectionChanged += CollectionChange;
          }
      }

      // Is called whenever the item collection changes
      private void CollectionChange(object sender, NotifyCollectionChangedEventArgs e)
      {
          if (Items.Count > 0)
          {
              // Get the ScrollViewer object from the ListBox control
              Border border = (Border)VisualTreeHelper.GetChild(this, 0);
              ScrollViewer SV = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);

              // Scroll to bottom
              SV.ScrollToBottom();
          }
      }
  }
}

将自定义控件的命名空间添加到您的 WPF 窗口中,并使用自定义 ListBox 控件:
<Window x:Class="MainWindow"
         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:YourProgram"
         xmlns:cc="clr-namespace:YourProgram.CustomControls"
         mc:Ignorable="d" 
         d:DesignHeight="450" d:DesignWidth="800">
         
    <cc:AutoScrollListBox ItemsSource="{Binding YourObservableCollection}"/>
        
</Window>

1
你还应该针对VisualChildrenCount进行测试。如果列表处于Collapsed状态,它将会导致程序崩溃。 - Paul Jon
我在集合的CollectionChanged事件中添加了委托,使用以下代码:gridView.ScrollIntoView(gridView.Items.Count > 0 ? gridView.Items [gridView.Items.Count - 1] : null); 当列表更新时,有时会在屏幕上绘制两次表格的一行(重复行),如果我增加列表和其项的更新频率,就会出现异常,所以要小心! - Aminos

1
对我来说,最简单的工作方式是这样的:(不需要绑定)
 private void WriteMessage(string message, Brush color, ListView lv)
        {

            Dispatcher.BeginInvoke(new Action(delegate
            {
                ListViewItem ls = new ListViewItem
                {
                    Foreground = color,
                    Content = message
                };
                lv.Items.Add(ls);
                lv.ScrollIntoView(lv.Items[lv.Items.Count - 1]);
            }));
        }

不需要创建类或更改XAML,只需使用此方法编写消息即可自动滚动。仅调用:
myLv.Items.Add(ls);
myLv.ScrollIntoView(lv.Items[lv.Items.Count - 1]);

例如,对我来说不起作用。

0

只有在最后一个添加的项目是列表中的最后一个时,才滚动到底部。

if (lstBox.SelectedIndex == lstBox.Items.Count - 1)
{
    // Scroll to bottom
    lstBox.ScrollIntoView(lstBox.SelectedItem);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接