Development Tip

새 항목이 추가 될 때 ListBox 자동 스크롤을 어떻게 할 수 있습니까?

yourdevel 2021. 1. 6. 20:28
반응형

새 항목이 추가 될 때 ListBox 자동 스크롤을 어떻게 할 수 있습니까?


가로로 스크롤하도록 설정된 WPF ListBox가 있습니다. ItemsSource는 내 ViewModel 클래스의 ObservableCollection에 바인딩됩니다. 새 항목이 추가 될 때마다 ListBox를 오른쪽으로 스크롤하여 새 항목을 볼 수 있도록합니다.

ListBox는 DataTemplate에 정의되어 있으므로 파일 뒤에있는 코드에서 이름으로 ListBox에 액세스 할 수 없습니다.

ListBox를 항상 스크롤하여 최근에 추가 된 항목을 표시하려면 어떻게해야합니까?

ListBox에 새 항목이 추가되었는지 알 수있는 방법이 필요하지만이 작업을 수행하는 이벤트가 표시되지 않습니다.


연결된 속성을 사용하여 ListBox의 동작을 확장 할 수 있습니다. 귀하의 경우 에는 목록 상자 항목 소스 이벤트에 연결 ScrollOnNewItem하도록 설정 하고 새 항목을 감지하면 목록 상자를 스크롤 하는 연결된 속성을 정의 합니다.trueINotifyCollectionChanged

예:

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"/>

업데이트 아래 주석에서 Andrej의 제안에 따라 .NET Framework의 변경 사항을 감지하는 후크를 추가 ItemsSource했습니다 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();
        }
    }

이 작업을 수행하는 정말 매끄러운 방법을 찾았습니다. 목록 상자 scrollViewer를 업데이트하고 위치를 맨 아래로 설정하기 만하면됩니다. 예를 들어 SelectionChanged와 같은 ListBox 이벤트 중 하나에서이 함수를 호출하십시오.

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

    }

이 솔루션을 사용합니다 : http://michlg.wordpress.com/2010/01/16/listbox-automatically-scroll-currentitem-into-view/ .

목록 상자의 ItemsSource를 UI가 아닌 스레드에서 조작되는 ObservableCollection에 바인딩하는 경우에도 작동합니다.


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

        }
    }

MVVM 스타일 연결 동작

이 연결된 동작은 새 항목이 추가 될 때 목록 상자를 자동으로 아래쪽으로 스크롤합니다.

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

당신의에서 ViewModel, 당신은 논리 값으로 결합 할 수있는 IfFollowTail { get; set; }자동 스크롤이 활성인지 여부를 제어 할 수 있습니다.

Behavior는 모든 올바른 작업을 수행합니다.

  • IfFollowTail=falseViewModel에가 설정되어 있으면 ListBox가 더 이상 새 항목의 맨 아래로 스크롤되지 않습니다.
  • IfFollowTail=trueViewModel에가 설정 되 자마자 ListBox는 즉시 맨 아래로 스크롤되고 계속됩니다.
  • 빠르다. 수백 밀리 초 동안 활동이 없을 때만 스크롤됩니다. 순진한 구현은 추가 된 모든 새 항목을 스크롤하므로 매우 느립니다.
  • 중복 된 ListBox 항목에서 작동합니다 (많은 다른 구현은 중복 항목에서 작동하지 않습니다. 첫 번째 항목으로 스크롤 한 다음 중지합니다).
  • 지속적으로 들어오는 항목을 처리하는 로깅 콘솔에 이상적입니다.

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

이벤트에서 Reactive Extensions로 연결

마지막으로 모든 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.


특히 데이터 소스에 바인딩 된 목록 상자 (또는 목록보기)의 경우이 작업을 수행하는 가장 간단한 방법은 컬렉션 변경 이벤트와 연결하는 것입니다. 목록 상자의 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 속성 만있는 경우. 한숨.


유사한 문제를 해결하는 데 도움이되는 훨씬 간단한 방법을 찾았습니다. 코드 몇 줄만 있으면 사용자 지정 동작을 만들 필요가 없습니다. 이 질문에 대한 내 대답을 확인하고 내부 링크를 따르십시오.

wpf (C #) DataGrid ScrollIntoView-표시되지 않은 첫 번째 행으로 스크롤하는 방법은 무엇입니까?

ListBox, ListView 및 DataGrid에서 작동합니다.


제안 된 솔루션이 마음에 들지 않았습니다.

  • "누수"속성 설명자를 사용하고 싶지 않았습니다.
  • 겉보기에 사소한 작업에 대해 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
}

그래서 제가이 topcs에서 읽은 것은 간단한 행동으로 약간 복잡합니다.

그래서 scrollchanged 이벤트를 구독하고 다음 코드를 사용했습니다.

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

    }

보너스:

After it I made a checkbox where I could set when I want use the autoscroll function and I relaized I forgot some times uncheck the listbox if I saw some interesting information for me. So I decided I would like to create a intelligent autoscrolled listbox what react to my mouse action.

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

When I scolled to botton the checkbox checked true and stay my view on bottom if I scroulled up with mouse wheel the checkox will be unchecked and you can explorer you listbox.


This is the solution I use that works, might help someone else;

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

ReferenceURL : https://stackoverflow.com/questions/2006729/how-can-i-have-a-listbox-auto-scroll-when-a-new-item-is-added

반응형