web-dev-qa-db-fra.com

Comment puis-je avoir un défilement automatique ListBox lorsqu'un nouvel élément est ajouté?

J'ai un ListBox WPF qui est configuré pour faire défiler horizontalement. ItemsSource est lié à une ObservableCollection dans ma classe ViewModel. Chaque fois qu'un nouvel élément est ajouté, je souhaite que le contrôle ListBox défile vers la droite afin que le nouvel élément soit visible.

Le ListBox étant défini dans un DataTemplate, je ne peux pas accéder au ListBox par son nom dans mon code derrière le fichier.

Comment faire en sorte qu'un ListBox défile toujours pour afficher le dernier élément ajouté?

Je voudrais un moyen de savoir quand le ListBox a un nouvel élément ajouté, mais je ne vois pas un événement qui fait cela.

54
Rob Buhler

Vous pouvez étendre le comportement du ListBox en utilisant des propriétés attachées. Dans votre cas, je définirais une propriété attachée appelée ScrollOnNewItem qui, lorsqu'elle est définie sur true, s'accroche aux événements INotifyCollectionChanged des éléments de la zone de liste source et lors de la détection d'un nouvel élément, la fait défiler.

Exemple:

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

Usage:

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

UPDATEComme suggéré par Andrej dans les commentaires ci-dessous, j'ai ajouté des crochets pour détecter un changement dans la ItemsSource de la ListBox.

63
Aviad P.
<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();
        }
    }
20
denis morozov

J'ai trouvé un moyen très astucieux de faire cela, il suffit de mettre à jour le listView scrollViewer et de régler la position au bas. Appelez cette fonction dans l'un des événements ListBox, comme SelectionChanged par exemple. 

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

    }
19
shawnpfiore

J'utilise cette solution: http://michlg.wordpress.com/2010/01/16/listbox-automatically-scroll-currentitem-into-view/ .

Cela fonctionne même si vous liez ItemsSource de listbox à un ObservableCollection manipulé dans un thread non-UI.

9
Shuo

solution pour Datagrid (identique pour ListBox, remplacez seulement DataGrid par la classe 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);
                }));
            }

        }
    }
2
Jozef Kemenik

Le moyen le plus simple que j'ai trouvé pour le faire, en particulier pour listbox (ou listview) lié à une source de données est de la relier à l'événement de modification de collection. Vous pouvez le faire très facilement à l’événement DataContextChanged de la 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); };
    }

Ceci est en fait juste une combinaison de toutes les autres réponses que j'ai trouvées. J’estime que c’est une fonctionnalité tellement triviale que nous ne devrions pas perdre autant de temps (et de lignes de code) à le faire. 

Si seulement il y avait une propriété Autoscroll = true. Soupir. 

1
mickeymicks

Comportement attaché de style MVVM

Ce comportement associé fait automatiquement défiler la liste vers le bas lorsqu'un nouvel élément est ajouté.

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

Dans votre ViewModel, vous pouvez vous connecter à un booléen IfFollowTail { get; set; } pour contrôler si le défilement automatique est activé ou non.

Le comportement fait toutes les bonnes choses:

  • Si IfFollowTail=false est défini dans le ViewModel, le contrôle ListBox ne défile plus vers le bas pour un nouvel élément.
  • Dès que IfFollowTail=true est défini dans le ViewModel, le contrôle ListBox défile instantanément vers le bas et continue de le faire.
  • C'est rapide. Il ne défile qu'après quelques centaines de millisecondes d'inactivité. Une mise en œuvre naïve serait extrêmement lente, car elle défilerait pour chaque nouvel élément ajouté.
  • Cela fonctionne avec les éléments de ListBox en double (beaucoup d'autres implémentations ne fonctionnent pas avec les doublons - ils défilent jusqu'au premier élément, puis s'arrêtent).
  • C'est idéal pour une console de journalisation qui traite les éléments entrants en continu.

Comportement C # Code

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

Passerelle des événements aux extensions réactives

Enfin, ajoutez cette méthode d’extension pour que nous puissions utiliser tous les avantages 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);
    }
}

Ajouter des extensions réactives

Vous devrez ajouter Reactive Extensions à votre projet. Je recommande NuGet.

1
Contango

J'ai trouvé un moyen beaucoup plus simple qui m'a aidé avec un problème similaire, juste quelques lignes de code derrière, pas besoin de créer des comportements personnalisés. Vérifiez ma réponse à cette question (et suivez le lien à l'intérieur):

wpf (C #) DataGrid ScrollIntoView - Comment faire défiler jusqu'à la première ligne qui n'est pas affichée?

Cela fonctionne pour ListBox, ListView et DataGrid.

0
Hannish

Donc, ce que je lis dans ce topcs est un peu complexe pour une action simple.

Je me suis donc abonné à l'événement scrollchanged puis j'ai utilisé ce code:

private void TelnetListBox_OnScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        var scrollViewer = ((ScrollViewer)e.OriginalSource);
        scrollViewer.ScrollToEnd();

    }

Prime:

Après cela, j'ai fait une case à cocher que je pouvais définir quand je voulais utiliser la fonction de défilement automatique et je me suis souvenu que j'avais oublié de désélectionner la liste si je voyais des informations intéressantes pour moi. J'ai donc décidé de créer une liste de sélection intelligente qui réagit à l'action de la souris.

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

Lorsque je me suis promené pour cocher la case, la case est cochée et que je reste en bas de l'écran si la molette de la souris est cochée, la case à cocher ne sera pas cochée et vous pourrez explorer votre liste.

0
Birek

Je n'étais pas content des solutions proposées.

  • Je ne voulais pas utiliser les descripteurs de propriété "qui fuient".
  • Je ne voulais pas ajouter une dépendance Rx et une requête de 8 lignes pour une tâche apparemment triviale. Je ne voulais pas non plus que la minuterie tourne constamment.
  • Cependant, j’ai aimé l’idée de shawnpfiore, j’y ai donc construit un comportement attaché, qui jusqu’à présent fonctionne bien dans mon cas.

Voici ce que j'ai fini avec. Cela fera peut-être gagner du temps à quelqu'un.

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
}
0
Nikita B

C’est la solution que j’utilise qui fonctionne et qui pourrait aider quelqu'un d’autre;

 statusWindow.SelectedIndex = statusWindow.Items.Count - 1;
 statusWindow.UpdateLayout();
 statusWindow.ScrollIntoView(statusWindow.SelectedItem);
 statusWindow.UpdateLayout();
0
Madison Courto