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

后端 未结 12 2227
走了就别回头了
走了就别回头了 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:46

    I found an really slick way to do this, simply update the listbox scrollViewer and set position to the bottom. Call this function in one of the ListBox Events like SelectionChanged for example.

     private void UpdateScrollBar(ListBox listBox)
        {
            if (listBox != null)
            {
                var border = (Border)VisualTreeHelper.GetChild(listBox, 0);
                var scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
                scrollViewer.ScrollToBottom();
            }
    
        }
    
    0 讨论(0)
  • 2020-11-28 20:49

    solution for Datagrid (the same for ListBox, only substitute DataGrid with ListBox class)

        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);
                    }));
                }
    
            }
        }
    
    0 讨论(0)
  • 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.

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

    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<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();
            }
        }
    }
    

    Bridge from events to Reactive Extensions

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

    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);
        }
    }
    

    Add Reactive Extensions

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

    0 讨论(0)
  • 2020-11-28 20:52
    <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();
            }
        }
    
    0 讨论(0)
  • 2020-11-28 20:53

    I was not happy with proposed solutions.

    • I didn't want to use "leaky" property descriptors.
    • I didn't want to add Rx dependency and 8-line query for seemingly trivial task. Neither did I want a constantly running timer.
    • I did like shawnpfiore's idea though, so I've built an attached behavior on top of it, which so far works well in my case.

    Here is what I ended up with. Maybe it will save somebody some time.

    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
    }
    
    0 讨论(0)
  • 2020-11-28 20:55

    The most straight-forward way i've found to do this, especially for listbox (or listview) that is bound to a data source is to hook it up with the collection change event. You can do this very easily at DataContextChanged event of the listbox:

        //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); };
        }
    

    This is actually just a combination of all the other answers i've found. I feel that this is such a trivial feature that we should not need to spend so much time (and lines of code) doing.

    If only there was an Autoscroll = true property. Sigh.

    0 讨论(0)
提交回复
热议问题