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:
- 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, 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>