Как я могу использовать автоматическую прокрутку ListBox при добавлении нового элемента?
У меня есть ListBox WPF, который настроен на прокрутку по горизонтали. Элемент ItemsSource привязан к ObservableCollection в моем классе ViewModel. Каждый раз, когда добавляется новый элемент, я хочу, чтобы ListBox прокручивался вправо, чтобы новый элемент был доступен для просмотра.
ListBox определен в DataTemplate, поэтому я не могу получить доступ к ListBox по имени в моем коде за файлом.
Как я могу заставить ListBox всегда прокручивать, чтобы показать последний добавленный элемент?
Мне бы хотелось узнать, когда в ListBox добавлен новый элемент, но я не вижу события, которое это делает.
Ответы
Ответ 1
Вы можете расширить поведение ListBox, используя прикрепленные свойства. В вашем случае я бы определил прикрепленное свойство ScrollOnNewItem
, которое при установке true
перехватывает события INotifyCollectionChanged
источника элементов списка и при обнаружении нового элемента прокручивает к нему список.
Пример:
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"/>
ОБНОВЛЕНИЕ. В соответствии с предложением Андрея в комментариях ниже я добавил крючки, чтобы обнаружить изменение в ItemsSource
ListBox
.
Ответ 2
<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();
}
}
Ответ 3
Я нашел действительно гладкий способ сделать это, просто обновил listroll scrollViewer и установил позицию снизу. Вызовите эту функцию в одном из таких событий 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();
}
}
Ответ 4
Я использую это решение: http://michlg.wordpress.com/2010/01/16/listbox-automatically-scroll-currentitem-into-view/.
Он работает, даже если вы привязываете Listbox ItemsSource к ObservableCollection, который обрабатывается в потоке, отличном от UI.
Ответ 5
для 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);
}));
}
}
}
Ответ 6
Приложенное поведение в стиле MVVM
Это прикрепленное поведение автоматически прокручивает список вниз, когда добавляется новый элемент.
<ListBox ItemsSource="{Binding LoggingStream}">
<i:Interaction.Behaviors>
<behaviors:ScrollOnNewItemBehavior
IsActiveScrollOnNewItem="{Binding IfFollowTail, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</i:Interaction.Behaviors>
</ListBox>
В вашем ViewModel
вы можете привязываться к boolean IfFollowTail { get; set; }
, чтобы управлять активностью автоматической прокрутки.
Поведение делает все правильно:
- Если в ViewModel установлен
IfFollowTail=false
, ListBox больше не прокручивается в нижнюю часть нового элемента.
- Как только
IfFollowTail=true
установлен в ViewModel, ListBox мгновенно прокручивается вниз и продолжает это делать.
- Это быстро. Он прокручивается только через пару сотен миллисекунд бездействия. Наивная реализация будет очень медленной, поскольку она будет прокручиваться при каждом добавленном добавлении.
- Он работает с дублирующимися элементами 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);
}
}
Добавить реактивные расширения
Вам нужно добавить Reactive Extensions
в свой проект. Я рекомендую NuGet
.
Ответ 7
Самый прямой способ, который я нашел для этого, особенно для listbox (или listview), привязанного к источнику данных, - это связать его с событием изменения коллекции.
Вы можете сделать это очень легко в событии DataContextChanged из списка:
//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); };
}
На самом деле это всего лишь комбинация всех других ответов, которые я нашел.
Я чувствую, что это такая тривиальная функция, что нам не нужно тратить так много времени (и строк кода).
Если только существовало свойство Autoscroll = true. Вздох.
Ответ 8
Я нашел гораздо более простой способ, который помог мне с аналогичной проблемой, всего лишь несколько строк кода, не нужно создавать пользовательские Behaviors. Проверьте мой ответ на этот вопрос (и перейдите по ссылке внутри):
wpf (С#) DataGrid ScrollIntoView - как прокручивать первую строку, которая не отображается?
Он работает для ListBox, ListView и DataGrid.
Ответ 9
Я не был доволен предложенными решениями.
- Я не хотел использовать "дырявые" дескрипторы свойств.
- Я не хотел добавлять зависимость Rx и 8-строчный запрос для, казалось бы, тривиальной задачи. Также я не хотел постоянно работающий таймер.
- Мне действительно понравилась идея shawnpfiore, поэтому я выстроил на ней привязанное поведение, которое до сих пор хорошо работает в моем случае.
Вот чем я закончил. Может быть, это сэкономит кому-то время.
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
}
Ответ 10
Так что то, что я прочитал в этом topcs, немного сложнее для простого действия.
Поэтому я подписался на событие прокрутки и затем использовал этот код:
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;
}
}
Когда я прокрутил до нижнего предела, флажок отмечен как true и останется моим взглядом снизу, если я прокрутил колесом мыши, флажок будет снят, и вы сможете просматривать список.
Ответ 11
Это решение, которое я использую, работает, может помочь кому-то еще;
statusWindow.SelectedIndex = statusWindow.Items.Count - 1;
statusWindow.UpdateLayout();
statusWindow.ScrollIntoView(statusWindow.SelectedItem);
statusWindow.UpdateLayout();