How can I have a ListBox auto-scroll when a new item is added?

后端 未结 12 2360
走了就别回头了
走了就别回头了 2020-11-28 20:34

I have a WPF ListBox that is set to scroll horizontally. The ItemsSource is bound to an ObservableCollection in my ViewModel class. Every time a new item is added, I want th

12条回答
  •  醉梦人生
    2020-11-28 20:51

    MVVM-style Attached Behavior

    This Attached Behavior automatically scrolls the listbox to the bottom when a new item is added.

    
        
            
        
    
    

    In your ViewModel, you can bind to boolean IfFollowTail { get; set; } to control whether auto scrolling is active or not.

    The Behavior does all the right things:

    • If IfFollowTail=false is set in the ViewModel, the ListBox no longer scrolls to the bottom on a new item.
    • As soon as IfFollowTail=true is set in the ViewModel, the ListBox instantly scrolls to the bottom, and continues to do so.
    • It's fast. It only scrolls after a couple of hundred milliseconds of inactivity. A naive implementation would be extremely slow, as it would scroll on every new item added.
    • It works with duplicate ListBox items (a lot of other implementations do not work with duplicates - they scroll to the first item, then stop).
    • It's ideal for a logging console that deals with continuous incoming items.

    Behavior C# Code

    public class ScrollOnNewItemBehavior : Behavior
    {
        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();
        }
    
        /// 
        /// Scrolls to the bottom. Unlike other methods, this works even if there are duplicate items in the listbox.
        /// 
        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();
            }
        }
    }
    

    Bridge from events to Reactive Extensions

    Finally, add this extension method so we can use all of the RX goodness:

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

    Add Reactive Extensions

    You will need to add Reactive Extensions to your project. I recommend NuGet.

提交回复
热议问题