wpf listbox scroll

wpf - ¿Cómo puedo tener un desplazamiento automático de ListBox cuando se agrega un nuevo elemento?



scroll (9)

Comportamiento adjunto estilo MVVM

Este comportamiento adjunto desplaza automáticamente el cuadro de lista a la parte inferior cuando se agrega un nuevo elemento.

<ListBox ItemsSource="{Binding LoggingStream}"> <i:Interaction.Behaviors> <behaviors:ScrollOnNewItemBehavior IsActiveScrollOnNewItem="{Binding IfFollowTail, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/> </i:Interaction.Behaviors> </ListBox>

En su ViewModel , puede enlazar a boolean IfFollowTail { get; set; } IfFollowTail { get; set; } IfFollowTail { get; set; } para controlar si el desplazamiento automático está activo o no.

El Comportamiento hace todas las cosas correctas:

  • Si IfFollowTail=false se establece en el ViewModel, el ListBox ya no se desplaza hacia la parte inferior de un nuevo elemento.
  • Tan pronto como IfFollowTail=true se establece en ViewModel, ListBox se desplaza instantáneamente hacia abajo y continúa haciéndolo.
  • Es rápido. Solo se desplaza después de un par de cientos de milisegundos de inactividad. Una implementación ingenua sería extremadamente lenta, ya que se desplazaría en cada nuevo elemento agregado.
  • Funciona con elementos duplicados de ListBox (muchas otras implementaciones no funcionan con duplicados: se desplazan al primer elemento y luego se detienen).
  • Es ideal para una consola de registro que se ocupa de elementos entrantes continuos.

Comportamiento Código C #

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(); } } }

Puente de eventos a Extensiones reactivas

Finalmente, agregue este método de extensión para que podamos usar todo el valor de 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); } }

Añadir extensiones reactivas

Deberá agregar Reactive Extensions a su proyecto. Recomiendo NuGet .

Tengo un ListBox de WPF que está configurado para desplazarse horizontalmente. ItemsSource está vinculado a una ObservableCollection en mi clase ViewModel. Cada vez que se agrega un nuevo elemento, quiero que el ListBox se desplace hacia la derecha para que el nuevo elemento se pueda ver.

ListBox se define en una DataTemplate, por lo que no puedo acceder al ListBox por nombre en mi código detrás del archivo.

¿Cómo puedo hacer que un ListBox siempre se desplace para mostrar un último elemento agregado?

Me gustaría una forma de saber cuándo el ListBox tiene un nuevo elemento agregado, pero no veo un evento que haga esto.



Encontré una manera muy hábil de hacerlo, simplemente actualice el listbox scrollViewer y establezca la posición en la parte inferior. Llame a esta función en uno de los eventos ListBox como SelectionChanged por ejemplo.

private void UpdateScrollBar(ListBox listBox) { if (listBox != null) { var border = (Border)VisualTreeHelper.GetChild(listBox, 0); var scrollViewer = (ScrollViewer)VisualTreeHelper.GetChild(border, 0); scrollViewer.ScrollToBottom(); } }


La forma más directa que he encontrado para hacer esto, especialmente para listbox (o listview) que está vinculado a una fuente de datos, es conectarlo con el evento de cambio de colección. Puedes hacerlo fácilmente en el evento DataContextChanged del listbox:

//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); }; }

Esto es solo una combinación de todas las otras respuestas que he encontrado. Siento que esta es una característica tan trivial que no deberíamos necesitar pasar tanto tiempo (y líneas de código).

Si solo hubiera un Autoscroll = propiedad verdadera. Suspiro.


No estaba contento con las soluciones propuestas.

  • No quería usar descriptores de propiedad "con fugas".
  • No quería agregar la dependencia de Rx y la consulta de 8 líneas para una tarea aparentemente trivial. Tampoco quería un temporizador constantemente en funcionamiento.
  • Sin embargo, me gustó la idea de shawnpfiore, así que construí un comportamiento adjunto sobre él, que hasta ahora funciona bien en mi caso.

Aquí es a lo que terminé. Tal vez le ahorrará a alguien algún tiempo.

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 }



solución para Datagrid (lo mismo para ListBox, solo sustituye DataGrid con clase 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); })); } } }


Puede extender el comportamiento de ListBox utilizando las propiedades adjuntas. En su caso definiría una propiedad adjunta llamada ScrollOnNewItem que cuando se establece en true engancha en los eventos INotifyCollectionChanged de la fuente de elementos del cuadro de lista y al detectar un nuevo elemento, lo desplaza por el cuadro de lista.

Ejemplo:

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; } } }

Uso:

<ListBox ItemsSource="{Binding SourceCollection}" lb:ListBoxBehavior.ScrollOnNewItem="true"/>

ACTUALIZACIÓN Según la sugerencia de Andrej en los comentarios a continuación, agregué ganchos para detectar un cambio en el ItemsSource de ListBox .


<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(); } }