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

12

64

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 the ListBox to scroll to the right so that the new item is viewable.

The ListBox is defined in a DataTemplate, so I am unable to access the ListBox by name in my code behind file.

How can I get a ListBox to always scroll to show a latest added item?

I would like a way to know when the ListBox has a new item added to it, but I do not see an event that does this.

Montmartre answered 5/1, 2010 at 14:49 Comment(0)
B
73

You can extend the behavior of the ListBox by using attached properties. In your case I would define an attached property called ScrollOnNewItem that when set to true hooks into the INotifyCollectionChanged events of the list box items source and upon detecting a new item, scrolls the list box to it.

Example:

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

Usage:

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

UPDATE As per Andrej's suggestion in the comments below, I added hooks to detect a change in the ItemsSource of the ListBox.

Biogenesis answered 5/1, 2010 at 15:39 Comment(11)
thanks, I added this code to my project and it worked as is! I really appreciate the quick and accurate reply. I don't quite understand what is happening on the line: var incc = listBox.Items as INotifyCollectionChanged; How can listBox Items be cast to INotifyCollectionChanged? Where can I learn more about creating attached properties in general?Montmartre
update: the code above works most of the time for me - at times listbox items are added and the listbox does not scroll.Montmartre
I think I meant to write listBox.ItemsSource... I'll try that. Btw it works for me every time, perhaps it's a problem with focus. Does the selection change work always?Biogenesis
+1 great post, I added what I did below, using your notations, just as a different option/packaging..Rollway
Thanks for the post @AviadP., works flawlessly. Only thing I added into _Loaded was a hook on ItemsSource changed, without it, after changing the collection to a different one, the scroll is not triggered, since the Collection_Changed belongs to the first collection: TypeDescriptor.GetProperties(listBox)["ItemsSource"].AddValueChanged(listBox, new EventHandler((a, b) => { ListBoxBehavior.Associations[listBox].Dispose(); ListBoxBehavior.Associations[listBox] = new ListBoxBehavior.Capture(listBox); }));Footway
@AndrejKikelj I updated the answer to include your suggestion, thanks!Biogenesis
This page is the top DuckDuckGo result for a n00b question like "WPF scroll listbox to last line". I just wanted to point out the single line that actually does the scrolling is this: listBox.ScrollIntoView(e.NewItems[0]); If you have a method that adds an element to the listbox then you have a reference to the list item and can just add this to scroll to the last item.Knee
Most WPF apps are MVVM, so usually you don't have a method which directly manipulates the view, and therefore you don't have a reference to the list box itself.Biogenesis
for me it does not work... InteliSense is recognizing the attached property and I bound an ObservableCollection<string> to the ItemSource of the ListBox but it doesn't scroll automatically to the newest entry. Do I have to do something else?Fitment
It's working now! the problem was that I added the same string several times for testing, but theScrollIntoView method and the SelectedItem property just get the FIRST object, so it was always on top, just when I add a different string it scrolls down. I prevented this behaviour by adding a timestamp incl. milliseconds to the string:)Fitment
When will newValue == oldValue?Oleaceous
R
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();
        }
    }
Rollway answered 17/7, 2012 at 20:34 Comment(5)
Very nice, I wasn't aware of the Behavior(Of T) class at all! Looks more brief and readable.Biogenesis
BringIntoView() doesn't seem to work. In debug, I can see the code executing, but the ListBox doesn't scroll. I saw someone else having a similar issue: #12431423Benenson
Also, there is this evolution, #12255555 that is supposed to stop the scrolling if the user has scrolled up. I am still having trouble getting the behavior to work. In both cases, the item container doesn't seem to exist.Benenson
since you are using a listbox, you should probably use listBox.ScrollIntoView(). I am pretty sure that should work.Rollway
I'm having issues with UI thread. I have a background task updating the collection on which this listbox is bound. This behavior throws the InvalidOperation excption at int count = AssociatedObject... Using Dispatch.Invoke gets around the exception, but the listbox does not scrollAntimony
I
23

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

    }
Indiscrete answered 31/5, 2014 at 4:39 Comment(3)
greate answer! I tried your answer on the forth attemp, and this is the only the worked!Adhamh
Only this solution worked for me.. ListBox.ScrollIntoView doesn't workCapitation
This works the best on Windows 10. Unfortunately it makes my app crash on Windows 7, which I need to support.Huff
Y
10

I use this solution: http://michlg.wordpress.com/2010/01/16/listbox-automatically-scroll-currentitem-into-view/.

It works even if you bind listbox's ItemsSource to an ObservableCollection that is manipulated in a non-UI thread.

Yelp answered 11/12, 2010 at 21:3 Comment(0)
A
6

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.

Adjure answered 14/3, 2017 at 17:45 Comment(0)
M
2

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

        }
    }
Medication answered 12/9, 2013 at 7:15 Comment(1)
ListBox does not have an "OnCollectionChanged" event.Negotiate
R
2

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
}
Riviera answered 31/12, 2017 at 14:26 Comment(1)
Your dissatisfaction just saved my day. Really clever solution.Satori
Z
1

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.

Zymosis answered 14/8, 2017 at 8:1 Comment(0)
C
0

I found a much simpler way which helped me with a similar problem, just a couple of lines of code behind, no need to create custom Behaviors. Check my answer to this question (and follow the link within):

wpf(C#) DataGrid ScrollIntoView - how to scroll to the first row that is not shown?

It works for ListBox, ListView and DataGrid.

Compensatory answered 18/12, 2012 at 13:31 Comment(0)
W
0

So what i read in this topcs is a little bit complex for a simple action.

So I subscribed to scrollchanged event and then I used this code:

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

    }

Bonus:

After it I made a checkbox where I could set when I want use the autoscroll function and I relaized I forgot some times uncheck the listbox if I saw some interesting information for me. So I decided I would like to create a intelligent autoscrolled listbox what react to my mouse action.

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

When I scolled to botton the checkbox checked true and stay my view on bottom if I scroulled up with mouse wheel the checkox will be unchecked and you can explorer you listbox.

Worthwhile answered 4/10, 2018 at 13:36 Comment(0)
I
0

This is the solution I use that works, might help someone else;

 statusWindow.SelectedIndex = statusWindow.Items.Count - 1;
 statusWindow.UpdateLayout();
 statusWindow.ScrollIntoView(statusWindow.SelectedItem);
 statusWindow.UpdateLayout();
Isidora answered 9/4, 2019 at 0:19 Comment(0)
S
0

This works for me:

DirectoryInfo di = new DirectoryInfo(folderBrowserDialog1.SelectedPath);
foreach (var fi in di.GetFiles("*", SearchOption.AllDirectories))
{
    int count = Convert.ToInt32(listBox1.Items.Count); // counts every listbox entry
    listBox1.Items.Add(count + " - " + fi.Name); // display entrys
    listBox1.TopIndex = count; // scroll to the last entry
}
Sawyer answered 25/8, 2020 at 14:17 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.