2

I've been trying to improve the behavior of the WPF ListBox control in the following way: The ListBox below automatically scrolls to the bottom as new items are added. It does this using the ScrollToBottom function shown. Using the preview events shown, if the user clicks an item, it stops scrolling, even if more items are added. (It would be obnoxious to let it keep scrolling!) If the user manually scrolls with the mouse or wheel, then it stops scrolling in the same way.

Right now I have a button in the code below that starts automatic scrolling again.

My question is this: How can I start off automatic scrolling if the user either scrolls the listbox all the way down to the bottom, or does the equivalent with the mouse wheel or keyboard. This is how my old Borland listboxes used to work out of the box.

using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;

// Note requires .NET framework 4.5
namespace MMP
{
  public partial class MainWindow : Window
  {
    public ObservableCollection<String> data { get; set; }

    public MainWindow()
    {
      InitializeComponent();
      data = new ObservableCollection<String>();
      DataContext = this;
      BeginAddingItems();
    }

    private async void BeginAddingItems()
    {
      await Task.Factory.StartNew(() =>
      {
        for (int i = 0; i < Int32.MaxValue; ++i)
        {
          if (i > 20) 
            Thread.Sleep(1000);
          AddToList("Added " + i.ToString());
        }
      });
    }

    void AddToList(String item)
    {
        Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal,
            new Action(() => { data.Add(item); ScrollToBottom(); }));
    }

    bool autoScroll = true;
    public void ScrollToBottom()
    {
      if (!autoScroll)
        return;
      if (listbox.Items.Count > 0)
        listbox.ScrollIntoView(listbox.Items[listbox.Items.Count - 1]);
    }

    private void listbox_PreviewMouseDown(object sender, MouseButtonEventArgs e)
    {
      autoScroll = false;
      Console.WriteLine("PreviewMouseDown: setting autoScroll to false");
    }

    private void listbox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
      Console.WriteLine("PreviewMouseWheel: setting autoScroll to false");
      autoScroll = false;
    }

    private void startButton_Click(object sender, RoutedEventArgs e)
    {
      ScrollToBottom(); // Catch up with the current last item.
      Console.WriteLine("startButton_Click: setting autoScroll to true");
      autoScroll = true;
    }

    private void listbox_ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
      // Can this be useful?
    }
  }
}



<Window x:Class="MMP.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Test Scrolling"
        FontFamily="Verdana"
        Width="400" Height="250"
        WindowStartupLocation="CenterScreen">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="*" />
      <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>
    <ListBox x:Name="listbox" Grid.Row="0" 
             PreviewMouseWheel="listbox_PreviewMouseWheel" 
             PreviewMouseDown="listbox_PreviewMouseDown" 
             ItemsSource="{Binding data}" ScrollViewer.ScrollChanged="listbox_ScrollChanged" 
             >
    </ListBox>
    <StackPanel Orientation="Horizontal" Grid.Row="1" HorizontalAlignment="Right">
      <Button x:Name="startButton" Click="startButton_Click" MinWidth="80" >Auto Scroll</Button>
    </StackPanel>
  </Grid>
</Window>

2 Answers 2

1

The desired listbox behavior was achieved using the following code, with kind thanks to Roel for providing the initial Behavior<> framework above. This is a sample project that contains the behavior code, along with a minimal WPF window that can be used to test the interactivity.

The test window contains a ListBox, to which items are added asynchronously via a background task. The important points of the behavior are as follows:

  1. List box automatically scrolls to show new items as they are added asynchronously.
  2. A user interaction with the listbox stops automatic scrolling - AKA obnoxious behavior.
  3. Once finished interacting, to continue automatic scrolling, user drags the scroll bar to the bottom and lets go, or uses the mouse wheel or keyboard to do the same. This indicates that the user wants automatic scrolling to resume.

AutoScrolBehavior.cs:

using System;
using System.Collections.Specialized;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;
using System.Windows.Media;

namespace BehaviorTest.Code
{
  // List box automatically scrolls to show new items as they are added asynchronously.
  // A user interaction with the listbox stops automatic scrolling - AKA obnoxious behavior.
  // Once finished interacting, to continue automatic scrolling, drag the scroll bar to 
  // the bottom and let go, or use the mouse wheel or keyboard to do the same. 
  // This indicates that the user wants automatic scrolling to resume.

  public class AutoScrollBehavior : Behavior<ListBox>
  {
    private ScrollViewer scrollViewer;
    private bool autoScroll = true;
    private bool justWheeled = false;
    private bool userInteracting = false;
    protected override void OnAttached()
    {
      AssociatedObject.Loaded += AssociatedObjectOnLoaded;
      AssociatedObject.Unloaded += AssociatedObjectOnUnloaded;
    }

    private void AssociatedObjectOnUnloaded(object sender, RoutedEventArgs routedEventArgs)
    {
      if (scrollViewer != null)
      {
        scrollViewer.ScrollChanged -= ScrollViewerOnScrollChanged;
      }
      AssociatedObject.SelectionChanged -= AssociatedObjectOnSelectionChanged;
      AssociatedObject.ItemContainerGenerator.ItemsChanged -= ItemContainerGeneratorItemsChanged;
      AssociatedObject.GotMouseCapture -= AssociatedObject_GotMouseCapture;
      AssociatedObject.LostMouseCapture -= AssociatedObject_LostMouseCapture;
      AssociatedObject.PreviewMouseWheel -= AssociatedObject_PreviewMouseWheel;

      scrollViewer = null;
    }

    private void AssociatedObjectOnLoaded(object sender, RoutedEventArgs routedEventArgs)
    {
      scrollViewer = GetScrollViewer(AssociatedObject);
      if (scrollViewer != null)
      {
        scrollViewer.ScrollChanged += ScrollViewerOnScrollChanged;

        AssociatedObject.SelectionChanged += AssociatedObjectOnSelectionChanged;
        AssociatedObject.ItemContainerGenerator.ItemsChanged += ItemContainerGeneratorItemsChanged;
        AssociatedObject.GotMouseCapture += AssociatedObject_GotMouseCapture;
        AssociatedObject.LostMouseCapture += AssociatedObject_LostMouseCapture;
        AssociatedObject.PreviewMouseWheel += AssociatedObject_PreviewMouseWheel;
      }
    }

    private static ScrollViewer GetScrollViewer(DependencyObject root)
    {
      int childCount = VisualTreeHelper.GetChildrenCount(root);
      for (int i = 0; i < childCount; ++i)
      {
        DependencyObject child = VisualTreeHelper.GetChild(root, i);
        ScrollViewer sv = child as ScrollViewer;
        if (sv != null)
          return sv;

        return GetScrollViewer(child);
      }
      return null;
    }

    void AssociatedObject_GotMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
    {
      // User is actively interacting with listbox. Do not allow automatic scrolling to interfere with user experience.
      userInteracting = true;
      autoScroll = false;
    }

    void AssociatedObject_LostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
    {
      // User is done interacting with control.
      userInteracting = false;
    }

    private void ScrollViewerOnScrollChanged(object sender, ScrollChangedEventArgs e)
    {
      // diff is exactly zero if the last item in the list is visible. This can occur because of scroll-bar drag, mouse-wheel, or keyboard event.
      double diff = (scrollViewer.VerticalOffset - (scrollViewer.ExtentHeight - scrollViewer.ViewportHeight));

      // User just wheeled; this event is called immediately afterwards.
      if (justWheeled && diff != 0.0)
      {
        justWheeled = false;
        autoScroll = false;
        return;
      }

      if (diff == 0.0)
      {
        // then assume user has finished with interaction and has indicated through this action that scrolling should continue automatically.
        autoScroll = true;
      }
    }

    private void ItemContainerGeneratorItemsChanged(object sender, System.Windows.Controls.Primitives.ItemsChangedEventArgs e)
    {
      if (e.Action == NotifyCollectionChangedAction.Add || e.Action == NotifyCollectionChangedAction.Reset)
      { 
        // An item was added to the listbox, or listbox was cleared.
        if (autoScroll && !userInteracting)
        {
          // If automatic scrolling is turned on, scroll to the bottom to bring new item into view.
          // Do not do this if the user is actively interacting with the listbox.
          scrollViewer.ScrollToBottom();
        }
      }
    }

    private void AssociatedObjectOnSelectionChanged(object sender, SelectionChangedEventArgs selectionChangedEventArgs)
    {
      // User selected (clicked) an item, or used the keyboard to select a different item. 
      // Turn off automatic scrolling.
      autoScroll = false;
    }

    void AssociatedObject_PreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
    {
      // User wheeled the mouse. 
      // Cannot detect whether scroll viewer right at the bottom, because the scroll event has not occurred at this point.
      // Same for bubbling event.
      // Just indicated that the user mouse-wheeled, and that the scroll viewer should decide whether or not to stop autoscrolling.
      justWheeled = true;
    }
  }
}

MainWindow.xaml.cs:

using BehaviorTest.Code;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Interactivity;
using System.Windows.Threading;

namespace BehaviorTest
{
  public partial class MainWindow : Window
  {
    public ObservableCollection<String> data { get; set; }
    public MainWindow()
    {
      InitializeComponent();
      data = new ObservableCollection<String>();
      DataContext = this;
      Interaction.GetBehaviors(listbox).Add(new AutoScrollBehavior());
      BeginAddingItems();
    }
    private async void BeginAddingItems()
    {
      List<Task> tasks = new List<Task>();

      await Task.Factory.StartNew(() =>
      {
        for (int i = 0; i < Int32.MaxValue; ++i)
        {
          AddToList("Added Slowly: " + i.ToString());
          Thread.Sleep(2000);
          if (i % 3 == 0)
          {
            for (int j = 0; j < 5; ++j)
            {
              AddToList("Added Quickly: " + j.ToString());
              Thread.Sleep(200);
            }
          }
        }
      });
    }

    void AddToList(String item)
    {
      if (Application.Current == null)
        return; // Application is shutting down.
      Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal,
          new Action(() => { data.Add(item); }));
    }

    private void clearButton_Click(object sender, RoutedEventArgs e)
    {
      data.Clear();
    }

    private void listbox_MouseDoubleClick(object sender, MouseButtonEventArgs e)
    {
      MessageBox.Show("Launch a modal dialog. Items are still added to the list in the background.");
    }
  }
}

MainWindow.xaml.cs:

<Window x:Class="BehaviorTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Test Scrolling"
        FontFamily="Verdana"
        Width="400" Height="250"
        WindowStartupLocation="CenterScreen">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="*" />
      <RowDefinition Height="Auto" />
    </Grid.RowDefinitions>
    <ListBox x:Name="listbox" Grid.Row="0" 
             ItemsSource="{Binding data}"
             MouseDoubleClick="listbox_MouseDoubleClick" >
    </ListBox>
    <StackPanel Orientation="Horizontal" Grid.Row="1" HorizontalAlignment="Right">
      <Button x:Name="startButton" Click="clearButton_Click" MinWidth="80" >Clear</Button>
    </StackPanel>
  </Grid>
</Window>
Sign up to request clarification or add additional context in comments.

2 Comments

Edited AutoScrolBehavior.cs to allow for single mouse wheel event to stop scrolling.
I've tried to many different implementations of this behavior, and this is the only one that actually works the way I want after slight adaptations to make it work for ItemsControl.
0

You could try creating a Blend Behavior that does this for you. This is a small start:

public class AutoScrollBehavior:Behavior<ListBox> 
{
    private ScrollViewer scrollViewer;
    private bool autoScroll = true;
    protected override void OnAttached() 
    {
        AssociatedObject.Loaded += AssociatedObjectOnLoaded;
        AssociatedObject.Unloaded += AssociatedObjectOnUnloaded;      
    }

    private void AssociatedObjectOnUnloaded(object sender, RoutedEventArgs routedEventArgs) 
    {
        AssociatedObject.SelectionChanged -= AssociatedObjectOnSelectionChanged;
        AssociatedObject.ItemContainerGenerator.ItemsChanged -= ItemContainerGeneratorItemsChanged;

        scrollViewer = null;
    }

    private void AssociatedObjectOnLoaded(object sender, RoutedEventArgs routedEventArgs) 
    {
        scrollViewer = GetScrollViewer(AssociatedObject);
        if(scrollViewer != null) 
        {
            scrollViewer.ScrollChanged += ScrollViewerOnScrollChanged;

            AssociatedObject.SelectionChanged += AssociatedObjectOnSelectionChanged;
            AssociatedObject.ItemContainerGenerator.ItemsChanged += ItemContainerGeneratorItemsChanged;
        }
    }

    private void ScrollViewerOnScrollChanged(object sender, ScrollChangedEventArgs e) {
        if (e.VerticalOffset == e.ExtentHeight-e.ViewportHeight) {
            autoScroll = true;
        }
    }

    private static ScrollViewer GetScrollViewer(DependencyObject root) 
    {
        int childCount = VisualTreeHelper.GetChildrenCount(root);
        for (int i = 0; i < childCount; i++) 
        {
            DependencyObject child = VisualTreeHelper.GetChild(root, i);
            ScrollViewer sv = child as ScrollViewer;
            if (sv != null)
                return sv;

            return GetScrollViewer(child);
        }

        return null;
    }

    private void ItemContainerGeneratorItemsChanged(object sender, System.Windows.Controls.Primitives.ItemsChangedEventArgs e) 
    {
        if (e.Action == NotifyCollectionChangedAction.Add || e.Action == NotifyCollectionChangedAction.Reset) {
            if (autoScroll) {
                scrollViewer.ScrollToBottom();

            }
        }
    }

    private void AssociatedObjectOnSelectionChanged(object sender, SelectionChangedEventArgs selectionChangedEventArgs) 
    {
        autoScroll = false;
    }
}

4 Comments

Thanks for the detailed reply - I will look at this over the weekend. We still need to handle some cases - e.g. if you are holding down the scroll bar, scrolling (that is, you have captured the mouse), and simultaneously another item is added to the list. I need to come up with a list of similar possible actions.
I expanded on your solution above, and posted the resulting code below, along with a small test project. I would value your opinion on usability - is the behavior intuitive and clear? Thanks!
@Dean The only nuisance I could find was needing to scroll with the mouse wheel more than once before it stops the auto scroll, but apart from that, it works pretty good. You might need to remove the ScrollViewerOnScrollChanged event too, by the way.
I fixed that issue with the single mousewheel event. The code in AutoScrollBehavior.cs is updated. The wheel event is fired before the scrollviewer changes, so it took two wheel events before it would work. Thanks for helping.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.