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.
public class AutoScrollToBottomBehavior
{
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);
}
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)
{
bool? autoScrollToBottom = autoScrollToBottomStatus switch
{
StatusType.AutoScrollingToBottom => true,
StatusType.NotAutoScrollingToBottom => false,
StatusType.AutoScrollingToBottomButSuppressed => false,
_ => null
};
if (autoScrollToBottom.HasValue)
{
SetAutoScrollToBottom(viewer, autoScrollToBottom.Value);
}
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);
}
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)
{
if (autoScrollToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottom);
}
else if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.NotAutoScrollingToBottom);
}
}
}));
private static Action GetUnhookAction(DependencyObject obj)
{
return (Action)obj.GetValue(UnhookActionProperty);
}
private static void SetUnhookAction(DependencyObject obj, Action value)
{
obj.SetValue(UnhookActionProperty, value);
}
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 (!itemsControl.IsLoaded)
{
itemsControl.Loaded += ItemsControl_Loaded;
return;
}
if (FindScrollViewer(viewer) is ScrollViewer scrollViewer)
{
scrollViewer.ScrollToBottom();
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;
if (userScrolledUp && !userScrolledToBottom)
{
if (GetAutoScrollToBottomStatus(viewer) == StatusType.AutoScrollingToBottom)
{
SetAutoScrollToBottomStatus(viewer, StatusType.AutoScrollingToBottomButSuppressed);
}
}
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);
}
}
}
private static void UnhookViewer(DependencyObject viewer)
{
var unhookAction = GetUnhookAction(viewer);
SetUnhookAction(viewer, null);
unhookAction?.Invoke();
}
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();
}
}