None of the answers here did what I needed. So I wrote my own behavior that auto scrolls an items control, and pauses autoscrolling when the user scrolls up, and resumes auto scrolling when the user scrolls down to the bottom.
/// <summary>
/// This will auto scroll a list view to the bottom as items are added.
/// Automatically suspends if the user scrolls up, and recommences when
/// the user scrolls to the end.
/// </summary>
/// <example>
/// <ListView sf:AutoScrollToBottomBehavior="{Binding viewModelAutoScrollFlag}" />
/// </example>
public class AutoScrollToBottomBehavior
{
/// <summary>
/// Enumerated type to keep track of the current auto scroll status
/// </summary>
public enum StatusType
{
NotAutoScrollingToBottom,
AutoScrollingToBottom,
AutoScrollingToBottomButSuppressed
}
public static StatusType GetAutoScrollToBottomStatus(DependencyObject obj)
{
return (StatusType)obj.GetValue(AutoScrollToBottomStatusProperty);
}
public static void SetAutoScrollToBottomStatus(DependencyObject obj, StatusType value)
{
obj.SetValue(AutoScrollToBottomStatusProperty, value);
}
// Using a DependencyProperty as the backing store for AutoScrollToBottomStatus. This enables animation, styling, binding, etc...
public static readonly DependencyProperty AutoScrollToBottomStatusProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToBottomStatus",
typeof(StatusType),
typeof(AutoScrollToBottomBehavior),
new PropertyMetadata(StatusType.NotAutoScrollingToBottom, (s, e) =>
{
if (s is DependencyObject viewer && e.NewValue is StatusType autoScrollToBottomStatus)
{
// Set the AutoScrollToBottom property to mirror this one
bool? autoScrollToBottom = autoScrollToBottomStatus switch
{
StatusType.AutoScrollingToBottom => true,
StatusType.NotAutoScrollingToBottom => false,
StatusType.AutoScrollingToBottomButSuppressed => false,
_ => null
};
if (autoScrollToBottom.HasValue)
{
SetAutoScrollToBottom(viewer, autoScrollToBottom.Value);
}
// Only hook/unhook for cases below, not when suspended
switch(autoScrollToBottomStatus)
{
case StatusType.AutoScrollingToBottom:
HookViewer(viewer);
break;
case StatusType.NotAutoScrollingToBottom:
UnhookViewer(viewer);
break;
}
}
}));
public static bool GetAutoScrollToBottom(DependencyObject obj)
{
return (bool)obj.GetValue(AutoScrollToBottomProperty);
}
public static void SetAutoScrollToBottom(DependencyObject obj, bool value)
{
obj.SetValue(AutoScrollToBottomProperty, value);
}
// Using a DependencyProperty as the backing store for AutoScrollToBottom. This enables animation, styling, binding, etc...
public static readonly DependencyProperty AutoScrollToBottomProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToBottom",
typeof(bool),
typeof(AutoScrollToBottomBehavior),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (s, e) =>
{
if (s is DependencyObject viewer && e.NewValue is bool autoScrollToBottom)
{
// Set the AutoScrollToBottomStatus property to mirror this one
if (autoScrollToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottom);
}
else if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.NotAutoScrollingToBottom);
}
// No change if autoScrollToBottom = false && viewer.AutoScrollToBottomStatus = AutoScrollToBottomStatusType.AutoScrollingToBottomButSuppressed;
}
}));
private static Action GetUnhookAction(DependencyObject obj)
{
return (Action)obj.GetValue(UnhookActionProperty);
}
private static void SetUnhookAction(DependencyObject obj, Action value)
{
obj.SetValue(UnhookActionProperty, value);
}
// Using a DependencyProperty as the backing store for MyProperty. This enables animation, styling, binding, etc...
private static readonly DependencyProperty UnhookActionProperty =
DependencyProperty.RegisterAttached("UnhookAction", typeof(Action), typeof(AutoScrollToBottomBehavior), new PropertyMetadata(null));
private static void ItemsControl_Loaded(object sender, RoutedEventArgs e)
{
if (sender is ItemsControl itemsControl)
{
itemsControl.Loaded -= ItemsControl_Loaded;
HookViewer(itemsControl);
}
}
private static void HookViewer(DependencyObject viewer)
{
if (viewer is ItemsControl itemsControl)
{
// If this is triggered the xaml setup then the control won't be loaded yet,
// and so won't have a visual tree which we need to get the scrollviewer,
// so defer this hooking until the items control is loaded.
if (!itemsControl.IsLoaded)
{
itemsControl.Loaded += ItemsControl_Loaded;
return;
}
if (FindScrollViewer(viewer) is ScrollViewer scrollViewer)
{
scrollViewer.ScrollToBottom();
// Scroll to bottom when the item count changes
NotifyCollectionChangedEventHandler itemsCollectionChangedHandler = (s, e) =>
{
if (GetAutoScrollToBottom(viewer))
{
scrollViewer.ScrollToBottom();
}
};
((INotifyCollectionChanged)itemsControl.Items).CollectionChanged += itemsCollectionChangedHandler;
ScrollChangedEventHandler scrollChangedEventHandler = (s, e) =>
{
bool userScrolledToBottom = (e.VerticalOffset + e.ViewportHeight) > (e.ExtentHeight - 1.0);
bool userScrolledUp = e.VerticalChange < 0;
// Check if auto scrolling should be suppressed
if (userScrolledUp && !userScrolledToBottom)
{
if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottomButSuppressed);
}
}
// Check if auto scrolling should be unsuppressed
if (userScrolledToBottom)
{
if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottomButSuppressed)
{
SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottom);
}
}
};
scrollViewer.ScrollChanged += scrollChangedEventHandler;
Action unhookAction = () =>
{
((INotifyCollectionChanged)itemsControl.Items).CollectionChanged -= itemsCollectionChangedHandler;
scrollViewer.ScrollChanged -= scrollChangedEventHandler;
};
SetUnhookAction(viewer, unhookAction);
}
}
}
/// <summary>
/// Unsubscribes the event listeners on the ItemsControl and ScrollViewer
/// </summary>
/// <param name="viewer"></param>
private static void UnhookViewer(DependencyObject viewer)
{
var unhookAction = GetUnhookAction(viewer);
SetUnhookAction(viewer, null);
unhookAction?.Invoke();
}
/// <summary>
/// A recursive function that drills down a visual tree until a ScrollViewer is found.
/// </summary>
/// <param name="viewer"></param>
/// <returns></returns>
private static ScrollViewer FindScrollViewer(DependencyObject viewer)
{
if (viewer is ScrollViewer scrollViewer)
return scrollViewer;
return Enumerable.Range(0, VisualTreeHelper.GetChildrenCount(viewer))
.Select(i => FindScrollViewer(VisualTreeHelper.GetChild(viewer, i)))
.Where(child => child != null)
.FirstOrDefault();
}
}