我有一个WPF ListBox,设置为横向滚动。ItemsSource绑定到我的ViewModel类中的ObservableCollection。每次添加新项时,我希望ListBox向右滚动,以便可以查看新项。
在DataTemplate中定义了ListBox,因此无法在代码后台文件中按名称访问ListBox。
如何使ListBox始终滚动以显示最新添加的项?
我想知道何时将新项添加到ListBox中, 但我没有找到相应的事件。
您可以使用附加属性来扩展 ListBox 的行为。在您的情况下,我会定义一个名为ScrollOnNewItem
的附加属性,当设置为true
时,它会钩入 ListBox 项源的INotifyCollectionChanged
事件,并在检测到新项时将其滚动到 ListBox 中。
示例:
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;
}
}
}
使用方法:
<ListBox ItemsSource="{Binding SourceCollection}"
lb:ListBoxBehavior.ScrollOnNewItem="true"/>
更新:根据Andrje在下方评论中的建议,我添加了钩子以检测ListBox
的ItemsSource
更改。
<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();
}
}
Behavior(Of T)
类!看起来更加简洁易读。 - Aviad P.BringIntoView()
似乎无法正常工作。在调试中,我可以看到代码正在执行,但 ListBox
没有滚动。我看到其他人也遇到了类似的问题:http://stackoverflow.com/questions/12430923/bringintoview-on-a-listboxitem-with-an-attached-property - Nathan我发现了一种非常巧妙的方法,只需更新列表框滚动查看器并将其位置设置为底部。例如,在ListBox事件之一(如SelectionChanged)中调用此函数。
private void UpdateScrollBar(ListBox listBox)
{
if (listBox != null)
{
var border = (Border)VisualTreeHelper.GetChild(listBox, 0);
var scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0);
scrollViewer.ScrollToBottom();
}
}
ListBox.ScrollIntoView
不起作用。 - JobaDiniz这个附加行为在新项添加到列表框时自动将其滚动到底部。
<ListBox ItemsSource="{Binding LoggingStream}">
<i:Interaction.Behaviors>
<behaviors:ScrollOnNewItemBehavior
IsActiveScrollOnNewItem="{Binding IfFollowTail, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</i:Interaction.Behaviors>
</ListBox>
ViewModel
中,你可以绑定到布尔型IfFollowTail { get; set; }
,以控制自动滚动是否有效。ViewModel
中设置了IfFollowTail=false
,ListBox不会再在新项目上滚动到底部。ViewModel
中设置了IfFollowTail=true
,ListBox立即滚动到底部,并持续滚动。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();
}
}
}
最后,添加此扩展方法,以便我们可以使用RX的所有优点:
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);
}
}
您需要将响应式扩展
添加到您的项目中。我建议使用NuGet
来完成。
Datagrid的解决方案(ListBox也适用,只需将DataGrid替换为ListBox类)
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);
}));
}
}
}
我不满意提出的解决方案。
这就是我最终得出的结果。也许它能节省某个人的时间。
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
}
//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); };
}
这个话题中的内容对于一个简单的操作来说有点复杂。
所以我订阅了滚动事件(scrollchanged event),然后使用了以下代码:
private void TelnetListBox_OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
var scrollViewer = ((ScrollViewer)e.OriginalSource);
scrollViewer.ScrollToEnd();
}
奖励:
在此之后,我创建了一个复选框,可以设置何时使用自动滚动功能,但我发现有时如果我看到一些对我有用的信息,我会忘记取消勾选列表框。因此,我决定创建一个智能自动滚动列表框,可以根据我的鼠标动作做出反应。
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;
}
}
这是我使用的解决方案,它有效,可能会帮助其他人;
statusWindow.SelectedIndex = statusWindow.Items.Count - 1;
statusWindow.UpdateLayout();
statusWindow.ScrollIntoView(statusWindow.SelectedItem);
statusWindow.UpdateLayout();
ObservableCollection<string>
绑定到ListBox的ItemSource
,但它不会自动滚动到最新的条目。我还需要做些什么吗? - BergerScrollIntoView
方法和SelectedItem
属性只获取第一个对象,所以它总是在顶部,只有当我添加不同的字符串时它才会滚动到底部。我通过在字符串中添加时间戳(包括毫秒)来防止这种行为:) - Berger