当添加新项时,如何使ListBox自动滚动?

63

我有一个WPF ListBox,设置为横向滚动。ItemsSource绑定到我的ViewModel类中的ObservableCollection。每次添加新项时,我希望ListBox向右滚动,以便可以查看新项。

在DataTemplate中定义了ListBox,因此无法在代码后台文件中按名称访问ListBox。

如何使ListBox始终滚动以显示最新添加的项?

我想知道何时将新项添加到ListBox中, 但我没有找到相应的事件。

12个回答

72

您可以使用附加属性来扩展 ListBox 的行为。在您的情况下,我会定义一个名为ScrollOnNewItem的附加属性,当设置为true时,它会钩入 ListBox 项源的INotifyCollectionChanged事件,并在检测到新项时将其滚动到 ListBox 中。

示例:

class ListBoxBehavior
{
    static readonly Dictionary<ListBox, Capture> Associations =
           new Dictionary<ListBox, Capture>();

    public static bool GetScrollOnNewItem(DependencyObject obj)
    {
        return (bool)obj.GetValue(ScrollOnNewItemProperty);
    }

    public static void SetScrollOnNewItem(DependencyObject obj, bool value)
    {
        obj.SetValue(ScrollOnNewItemProperty, value);
    }

    public static readonly DependencyProperty ScrollOnNewItemProperty =
        DependencyProperty.RegisterAttached(
            "ScrollOnNewItem",
            typeof(bool),
            typeof(ListBoxBehavior),
            new UIPropertyMetadata(false, OnScrollOnNewItemChanged));

    public static void OnScrollOnNewItemChanged(
        DependencyObject d,
        DependencyPropertyChangedEventArgs e)
    {
        var listBox = d as ListBox;
        if (listBox == null) return;
        bool oldValue = (bool)e.OldValue, newValue = (bool)e.NewValue;
        if (newValue == oldValue) return;
        if (newValue)
        {
            listBox.Loaded += ListBox_Loaded;
            listBox.Unloaded += ListBox_Unloaded;
            var itemsSourcePropertyDescriptor = TypeDescriptor.GetProperties(listBox)["ItemsSource"];
            itemsSourcePropertyDescriptor.AddValueChanged(listBox, ListBox_ItemsSourceChanged);
        }
        else
        {
            listBox.Loaded -= ListBox_Loaded;
            listBox.Unloaded -= ListBox_Unloaded;
            if (Associations.ContainsKey(listBox))
                Associations[listBox].Dispose();
            var itemsSourcePropertyDescriptor = TypeDescriptor.GetProperties(listBox)["ItemsSource"];
            itemsSourcePropertyDescriptor.RemoveValueChanged(listBox, ListBox_ItemsSourceChanged);
        }
    }

    private static void ListBox_ItemsSourceChanged(object sender, EventArgs e)
    {
        var listBox = (ListBox)sender;
        if (Associations.ContainsKey(listBox))
            Associations[listBox].Dispose();
        Associations[listBox] = new Capture(listBox);
    }

    static void ListBox_Unloaded(object sender, RoutedEventArgs e)
    {
        var listBox = (ListBox)sender;
        if (Associations.ContainsKey(listBox))
            Associations[listBox].Dispose();
        listBox.Unloaded -= ListBox_Unloaded;
    }

    static void ListBox_Loaded(object sender, RoutedEventArgs e)
    {
        var listBox = (ListBox)sender;
        var incc = listBox.Items as INotifyCollectionChanged;
        if (incc == null) return;
        listBox.Loaded -= ListBox_Loaded;
        Associations[listBox] = new Capture(listBox);
    }

    class Capture : IDisposable
    {
        private readonly ListBox listBox;
        private readonly INotifyCollectionChanged incc;

        public Capture(ListBox listBox)
        {
            this.listBox = listBox;
            incc = listBox.ItemsSource as INotifyCollectionChanged;
            if (incc != null)
            {
                incc.CollectionChanged += incc_CollectionChanged;
            }
        }

        void incc_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            if (e.Action == NotifyCollectionChangedAction.Add)
            {
                listBox.ScrollIntoView(e.NewItems[0]);
                listBox.SelectedItem = e.NewItems[0];
            }
        }

        public void Dispose()
        {
            if (incc != null)
                incc.CollectionChanged -= incc_CollectionChanged;
        }
    }
}

使用方法:

<ListBox ItemsSource="{Binding SourceCollection}" 
         lb:ListBoxBehavior.ScrollOnNewItem="true"/>

更新:根据Andrje在下方评论中的建议,我添加了钩子以检测ListBoxItemsSource更改。


3
上面的代码在大多数情况下都有效,但有时会添加列表框项而列表框不会滚动。 - Rob Buhler
感谢@AviadP.的帖子,它完美无瑕地使用。唯一我在_Loaded中添加的是一个钩子,在ItemsSource更改时触发,如果没有它,在将集合更改为不同的集合后,滚动不会触发,因为Collection_Changed属于第一个集合:TypeDescriptor.GetProperties(listBox)["ItemsSource"].AddValueChanged(listBox, new EventHandler((a, b) => { ListBoxBehavior.Associations[listBox].Dispose(); ListBoxBehavior.Associations[listBox] = new ListBoxBehavior.Capture(listBox); })); - Andrej Kikelj
@AndrejKikelj,我更新了答案并包含了您的建议,谢谢! - Aviad P.
对我来说它不起作用... InteliSense识别了附加属性,并将ObservableCollection<string>绑定到ListBox的ItemSource,但它不会自动滚动到最新的条目。我还需要做些什么吗? - Berger
2
现在它可以工作了!问题是我为了测试添加了多次相同的字符串,但是ScrollIntoView方法和SelectedItem属性只获取第一个对象,所以它总是在顶部,只有当我添加不同的字符串时它才会滚动到底部。我通过在字符串中添加时间戳(包括毫秒)来防止这种行为:) - Berger
显示剩余6条评论

24
<ItemsControl ItemsSource="{Binding SourceCollection}">
    <i:Interaction.Behaviors>
        <Behaviors:ScrollOnNewItem/>
    </i:Interaction.Behaviors>              
</ItemsControl>

public class ScrollOnNewItem : Behavior<ItemsControl>
{
    protected override void OnAttached()
    {
        AssociatedObject.Loaded += OnLoaded;
        AssociatedObject.Unloaded += OnUnLoaded;
    }

    protected override void OnDetaching()
    {
        AssociatedObject.Loaded -= OnLoaded;
        AssociatedObject.Unloaded -= OnUnLoaded;
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var incc = AssociatedObject.ItemsSource as INotifyCollectionChanged;
        if (incc == null) return;

        incc.CollectionChanged += OnCollectionChanged;
    }

    private void OnUnLoaded(object sender, RoutedEventArgs e)
    {
        var incc = AssociatedObject.ItemsSource as INotifyCollectionChanged;
        if (incc == null) return;

        incc.CollectionChanged -= OnCollectionChanged;
    }

    private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if(e.Action == NotifyCollectionChangedAction.Add)
        {
            int count = AssociatedObject.Items.Count;
            if (count == 0) 
                return; 

            var item = AssociatedObject.Items[count - 1];

            var frameworkElement = AssociatedObject.ItemContainerGenerator.ContainerFromItem(item) as FrameworkElement;
            if (frameworkElement == null) return;

            frameworkElement.BringIntoView();
        }
    }

4
非常好,我之前完全不知道 Behavior(Of T) 类!看起来更加简洁易读。 - Aviad P.
BringIntoView() 似乎无法正常工作。在调试中,我可以看到代码正在执行,但 ListBox 没有滚动。我看到其他人也遇到了类似的问题:http://stackoverflow.com/questions/12430923/bringintoview-on-a-listboxitem-with-an-attached-property - Nathan
2
还有一个进化版本,http://stackoverflow.com/questions/12255055/how-to-get-itemscontrol-scrollbar-position-programmatically,它应该在用户向上滚动时停止滚动。我仍然无法让这种行为起作用。在这两种情况下,项目容器似乎不存在。 - Nathan
1
由于您正在使用ListBox,因此您应该使用listBox.ScrollIntoView()。我非常确定这应该可以工作。 - denis morozov
我遇到了UI线程的问题。我有一个后台任务正在更新此列表框绑定的集合。这种行为会在int count = AssociatedObject...处抛出InvalidOperation异常。使用Dispatch.Invoke可以解决异常,但是列表框无法滚动。 - Sinaesthetic

22

我发现了一种非常巧妙的方法,只需更新列表框滚动查看器并将其位置设置为底部。例如,在ListBox事件之一(如SelectionChanged)中调用此函数。

 private void UpdateScrollBar(ListBox listBox)
    {
        if (listBox != null)
        {
            var border = (Border)VisualTreeHelper.GetChild(listBox, 0);
            var scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
            scrollViewer.ScrollToBottom();
        }

    }

很棒的答案!我在第四次尝试时尝试了您的答案,这是唯一有效的! - Guy Segal
只有这个解决方案对我有效。ListBox.ScrollIntoView 不起作用。 - JobaDiniz
这在Windows 10上效果最佳。不幸的是,它会导致我的应用程序在我需要支持的Windows 7上崩溃。 - alehro

10

6

MVVM风格的附加行为

这个附加行为在新项添加到列表框时自动将其滚动到底部。

<ListBox ItemsSource="{Binding LoggingStream}">
    <i:Interaction.Behaviors>
        <behaviors:ScrollOnNewItemBehavior 
           IsActiveScrollOnNewItem="{Binding IfFollowTail, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
    </i:Interaction.Behaviors>
</ListBox>

在你的ViewModel中,你可以绑定到布尔型IfFollowTail { get; set; },以控制自动滚动是否有效。
这个行为做了所有正确的事情:
  • 如果在ViewModel中设置了IfFollowTail=false,ListBox不会再在新项目上滚动到底部。
  • 一旦在ViewModel中设置了IfFollowTail=true,ListBox立即滚动到底部,并持续滚动。
  • 它非常快。它只在数百毫秒的不活动时间后滚动。一个简单的实现将非常缓慢,因为它将在每个新项目添加时滚动。
  • 它适用于拥有重复ListBox项目的情况(许多其他实现不能处理重复项——它们只滚动到第一项,然后停止)。
  • 它非常适合处理连续传入项目的日志记录控制台。

C#代码

public class ScrollOnNewItemBehavior : Behavior<ListBox>
{
    public static readonly DependencyProperty IsActiveScrollOnNewItemProperty = DependencyProperty.Register(
        name: "IsActiveScrollOnNewItem", 
        propertyType: typeof(bool), 
        ownerType: typeof(ScrollOnNewItemBehavior),
        typeMetadata: new PropertyMetadata(defaultValue: true, propertyChangedCallback:PropertyChangedCallback));

    private static void PropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs)
    {
        // Intent: immediately scroll to the bottom if our dependency property changes.
        ScrollOnNewItemBehavior behavior = dependencyObject as ScrollOnNewItemBehavior;
        if (behavior == null)
        {
            return;
        }
        
        behavior.IsActiveScrollOnNewItemMirror = (bool)dependencyPropertyChangedEventArgs.NewValue;

        if (behavior.IsActiveScrollOnNewItemMirror == false)
        {
            return;
        }
        
        ListboxScrollToBottom(behavior.ListBox);
    }

    public bool IsActiveScrollOnNewItem
    {
        get { return (bool)this.GetValue(IsActiveScrollOnNewItemProperty); }
        set { this.SetValue(IsActiveScrollOnNewItemProperty, value); }
    } 

    public bool IsActiveScrollOnNewItemMirror { get; set; } = true;

    protected override void OnAttached()
    {
        this.AssociatedObject.Loaded += this.OnLoaded;
        this.AssociatedObject.Unloaded += this.OnUnLoaded;
    }

    protected override void OnDetaching()
    {
        this.AssociatedObject.Loaded -= this.OnLoaded;
        this.AssociatedObject.Unloaded -= this.OnUnLoaded;
    }

    private IDisposable rxScrollIntoView;

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var changed = this.AssociatedObject.ItemsSource as INotifyCollectionChanged;
        if (changed == null)
        {
            return;   
        }

        // Intent: If we scroll into view on every single item added, it slows down to a crawl.
        this.rxScrollIntoView = changed
            .ToObservable()
            .ObserveOn(new EventLoopScheduler(ts => new Thread(ts) { IsBackground = true}))
            .Where(o => this.IsActiveScrollOnNewItemMirror == true)
            .Where(o => o.NewItems?.Count > 0)
            .Sample(TimeSpan.FromMilliseconds(180))
            .Subscribe(o =>
            {       
                this.Dispatcher.BeginInvoke((Action)(() => 
                {
                    ListboxScrollToBottom(this.ListBox);
                }));
            });           
    }

    ListBox ListBox => this.AssociatedObject;

    private void OnUnLoaded(object sender, RoutedEventArgs e)
    {
        this.rxScrollIntoView?.Dispose();
    }

    /// <summary>
    /// Scrolls to the bottom. Unlike other methods, this works even if there are duplicate items in the listbox.
    /// </summary>
    private static void ListboxScrollToBottom(ListBox listBox)
    {
        if (VisualTreeHelper.GetChildrenCount(listBox) > 0)
        {
            Border border = (Border)VisualTreeHelper.GetChild(listBox, 0);
            ScrollViewer scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
            scrollViewer.ScrollToBottom();
        }
    }
}

从事件到响应式扩展的桥梁

最后,添加此扩展方法,以便我们可以使用RX的所有优点:

public static class ListBoxEventToObservableExtensions
{
    /// <summary>Converts CollectionChanged to an observable sequence.</summary>
    public static IObservable<NotifyCollectionChangedEventArgs> ToObservable<T>(this T source)
        where T : INotifyCollectionChanged
    {
        return Observable.FromEvent<NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>(
            h => (sender, e) => h(e),
            h => source.CollectionChanged += h,
            h => source.CollectionChanged -= h);
    }
}

添加响应式扩展

您需要将响应式扩展添加到您的项目中。我建议使用NuGet来完成。


2

Datagrid的解决方案(ListBox也适用,只需将DataGrid替换为ListBox类)

    private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            int count = AssociatedObject.Items.Count;
            if (count == 0)
                return;

            var item = AssociatedObject.Items[count - 1];

            if (AssociatedObject is DataGrid)
            {
                DataGrid grid = (AssociatedObject as DataGrid);
                grid.Dispatcher.BeginInvoke((Action)(() =>
                {
                    grid.UpdateLayout();
                    grid.ScrollIntoView(item, null);
                }));
            }

        }
    }

ListBox没有"OnCollectionChanged"事件。 - Matt

2

我不满意提出的解决方案。

  • 我不想使用“泄漏”的属性描述符。
  • 我也不想添加Rx依赖和8行查询,用于看似琐碎的任务。我也不想要一个不断运行的计时器。
  • 然而,我确实喜欢shawnpfiore的想法,所以我在此基础上构建了一个附加的行为,在我的情况下目前表现良好。

这就是我最终得出的结果。也许它能节省某个人的时间。

public class AutoScroll : Behavior<ItemsControl>
{
    public static readonly DependencyProperty ModeProperty = DependencyProperty.Register(
        "Mode", typeof(AutoScrollMode), typeof(AutoScroll), new PropertyMetadata(AutoScrollMode.VerticalWhenInactive));
    public AutoScrollMode Mode
    {
        get => (AutoScrollMode) GetValue(ModeProperty);
        set => SetValue(ModeProperty, value);
    }

    protected override void OnAttached()
    {
        base.OnAttached();
        AssociatedObject.Loaded += OnLoaded;
        AssociatedObject.Unloaded += OnUnloaded;
    }

    protected override void OnDetaching()
    {
        Clear();
        AssociatedObject.Loaded -= OnLoaded;
        AssociatedObject.Unloaded -= OnUnloaded;
        base.OnDetaching();
    }

    private static readonly DependencyProperty ItemsCountProperty = DependencyProperty.Register(
        "ItemsCount", typeof(int), typeof(AutoScroll), new PropertyMetadata(0, (s, e) => ((AutoScroll)s).OnCountChanged()));
    private ScrollViewer _scroll;

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var binding = new Binding("ItemsSource.Count")
        {
            Source = AssociatedObject,
            Mode = BindingMode.OneWay
        };
        BindingOperations.SetBinding(this, ItemsCountProperty, binding);
        _scroll = AssociatedObject.FindVisualChild<ScrollViewer>() ?? throw new NotSupportedException("ScrollViewer was not found!");
    }

    private void OnUnloaded(object sender, RoutedEventArgs e)
    {
        Clear();
    }

    private void Clear()
    {
        BindingOperations.ClearBinding(this, ItemsCountProperty);
    }

    private void OnCountChanged()
    {
        var mode = Mode;
        if (mode == AutoScrollMode.Vertical)
        {
            _scroll.ScrollToBottom();
        }
        else if (mode == AutoScrollMode.Horizontal)
        {
            _scroll.ScrollToRightEnd();
        }
        else if (mode == AutoScrollMode.VerticalWhenInactive)
        {
            if (_scroll.IsKeyboardFocusWithin) return;
            _scroll.ScrollToBottom();
        }
        else if (mode == AutoScrollMode.HorizontalWhenInactive)
        {
            if (_scroll.IsKeyboardFocusWithin) return;
            _scroll.ScrollToRightEnd();
        }
    }
}

public enum AutoScrollMode
{
    /// <summary>
    /// No auto scroll
    /// </summary>
    Disabled,
    /// <summary>
    /// Automatically scrolls horizontally, but only if items control has no keyboard focus
    /// </summary>
    HorizontalWhenInactive,
    /// <summary>
    /// Automatically scrolls vertically, but only if itmes control has no keyboard focus
    /// </summary>
    VerticalWhenInactive,
    /// <summary>
    /// Automatically scrolls horizontally regardless of where the focus is
    /// </summary>
    Horizontal,
    /// <summary>
    /// Automatically scrolls vertically regardless of where the focus is
    /// </summary>
    Vertical
}

你的不满意刚好让我受益匪浅。真是个聪明的解决方案。 - Prince Owen

1
我发现最直接的方法是使用集合更改事件,特别是对于绑定到数据源的列表框(或列表视图)。您可以在列表框的DataContextChanged事件中很容易地完成此操作。
    //in xaml <ListView x:Name="LogView" DataContextChanged="LogView_DataContextChanged">
    private void LogView_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
      var src = LogView.Items.SourceCollection as INotifyCollectionChanged;
      src.CollectionChanged += (obj, args) => { LogView.Items.MoveCurrentToLast(); LogView.ScrollIntoView(LogView.Items.CurrentItem); };
    }

这实际上只是我发现的其他答案的结合。 我觉得这是一个如此琐碎的功能,我们不应该花费太多时间(和代码行数)去完成它。
如果只有存在一个 Autoscroll = true 属性就好了。叹气。

0

这个话题中的内容对于一个简单的操作来说有点复杂。

所以我订阅了滚动事件(scrollchanged event),然后使用了以下代码:

private void TelnetListBox_OnScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        var scrollViewer = ((ScrollViewer)e.OriginalSource);
        scrollViewer.ScrollToEnd();

    }

奖励:

在此之后,我创建了一个复选框,可以设置何时使用自动滚动功能,但我发现有时如果我看到一些对我有用的信息,我会忘记取消勾选列表框。因此,我决定创建一个智能自动滚动列表框,可以根据我的鼠标动作做出反应。

private void TelnetListBox_OnScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        var scrollViewer = ((ScrollViewer)e.OriginalSource);
        scrollViewer.ScrollToEnd();
        if (AutoScrollCheckBox.IsChecked != null && (bool)AutoScrollCheckBox.IsChecked)
            scrollViewer.ScrollToEnd();

        if (_isDownMouseMovement)
        {
            var verticalOffsetValue = scrollViewer.VerticalOffset;
            var maxVerticalOffsetValue = scrollViewer.ExtentHeight - scrollViewer.ViewportHeight;

            if (maxVerticalOffsetValue < 0 || verticalOffsetValue == maxVerticalOffsetValue)
            {
                // Scrolled to bottom

                AutoScrollCheckBox.IsChecked = true;
                _isDownMouseMovement = false;

            }
            else if (verticalOffsetValue == 0)
            {


            }

        }
    }



    private bool _isDownMouseMovement = false;

    private void TelnetListBox_OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {

        if (e.Delta > 0)
        {
            _isDownMouseMovement = false;
            AutoScrollCheckBox.IsChecked = false;
        }
        if (e.Delta < 0)
        {
            _isDownMouseMovement = true;
        } 
    }

当我向下滚动时,复选框被选中并停留在底部。如果我使用鼠标滚轮向上滚动,复选框将取消选中,你可以继续浏览你的列表框。

0

这是我使用的解决方案,它有效,可能会帮助其他人;

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

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