WPF ListView 데이터 추가시 이전 위치로 이동 관련 질문드립니다.

안녕하세요.
예전 데이터는 리스트 상단에, 최신 데이터는 하단에 추가하는 기능을 구현중입니다.

MVVM 패턴을 적용했고
ItemSource로는 ObservableCollection<>을 사용중인데 데이터를 추가하는데
문제점이 있어 질문드립니다.

리스트의 최상단으로 스크롤 해서 이전 데이터를 로딩할때는 Insert(0,data)를,
최신 데이터의 경우는 Add()를 사용해서 추가하고 있는데

이전 데이터를 로드하고나서 이전에 보이던 화면(아이템)이 나타나도록
스크롤을 이동하려니 적절한 아이디어가 떠오르지 않습니다.

Android에서는 전체 컨텐츠의 높이 변화량만큼 스크롤하여 해결했던 경험이 있어
동일하게 처리하려고 ScrollViewer의 ExtentHeight를 사용해봤습니다.
그런데 이전 값과의 차이만큼 ScrollToVerticalOffset()로 이동시켜보니
많이 동떨어지는 위치로 가더군요.

제가 만들려는 기능은 자주 사용하는 기능인것 같은데 보통 어떻게 구현하시나요?

1 Like

원하시는 구현일지 모르겠지만,

bandicam2024-05-2314-02-56-708-ezgif.com-video-to-webp-converter

private void Handler_ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.NewItems.Count > 0 && _scroll.VerticalOffset >= e.NewStartingIndex)
    {
        _scroll.ScrollToVerticalOffset(_scroll.VerticalOffset + e.NewItems.Count);
        Dispatcher.Invoke(() => { }, DispatcherPriority.Render);
    }
}

ObservableCollection<T>.CollectionChanged 이벤트 핸들러 내에서 ScrollToVerticalOffset()를 호출한 뒤

Dispatcher.Invoke(() => { }, DispatcherPriority.Render);

문을 실행해 보세요.
ScrollToVerticalOffset()만 호출 할 경우 Offset 값이 즉시 반영되지 않고 Dispatcher 우선 순위에 따라 지연 적용되는데 해당 구분은 WinForms의 Application.DoEvents()와 같은 역할을 해줘서 Offset 값을 즉시 반영해 줍니다.

XAML

<Window Name="_root" x:Class="WpfApp2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Width="300" Height="250">
    <DockPanel>
        <UniformGrid Columns="2"  DockPanel.Dock="Top">
            <Button Content="Prepand" Click="Handler_PrepandClick" />
            <Button Content="Append" Click="Handler_AppendClick" />
        </UniformGrid>
        <ListView Name="_listView" ItemsSource="{Binding Items, ElementName=_root}" />
    </DockPanel>
</Window>

코드 비하인드

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;

namespace WpfApp2
{
    public partial class MainWindow : Window
    {
        private ScrollViewer _scroll;

        public MainWindow()
        {
            InitializeComponent();

            Loaded += Handler_Loaded;
            Items.CollectionChanged += Handle_ItemsCollectionChanged;
        }

        public ObservableCollection<int> Items { get; }
            = new ObservableCollection<int>();

        private void Handler_Loaded(object sender, RoutedEventArgs e) 
            => _scroll = _listView.FindChildOfType<ScrollViewer>();

        private void Handler_ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            if (e.NewItems.Count > 0 && _scroll.VerticalOffset >= e.NewStartingIndex)
            {
                _scroll.ScrollToVerticalOffset(_scroll.VerticalOffset + e.NewItems.Count);
                Dispatcher.Invoke(() => { }, DispatcherPriority.Render);
            }
        }

        private void Handler_PrepandClick(object sender, RoutedEventArgs e)
        {
            for (int i = 0; i < 5; i++)
            {
                Items.Insert(0, Items.FirstOrDefault() - 1);
            }
        }

        private void Handler_AppendClick(object sender, RoutedEventArgs e)
        {
            for (int i = 0; i < 5; i++)
            {
                Items.Add(Items.LastOrDefault() + 1);
            }
        }

    }

    public static class VisualTreeUtility
    {
        public static T FindChildOfType<T>(this DependencyObject depObj) where T : DependencyObject
        {
            if (depObj == null) return null;

            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++)
            {
                var child = VisualTreeHelper.GetChild(depObj, i);

                var result = (child as T) ?? FindChildOfType<T>(child);
                if (result != null) return result;
            }
            return null;
        }
    }
}
3 Likes

답변 감사합니다.

올려주신 내용은 제가 구현하려고 했던 기능이 맞습니다.
‘if (e.NewItems.Count > 0 && _scroll.VerticalOffset >= e.NewStartingIndex) {…}’
부분은 생각하지 못했던 방식이에요.

그리고
‘_scroll.ScrollToVerticalOffset(_scroll.VerticalOffset + e.NewItems.Count);’
해당 코드에서
VerticalOffset는 세로 '길이’이고 Count는 '개수’인데 동작이 정상적으로 이루어지는
이유를 알 수 있을까요?

저의 경우는 아래처럼 작성하여 기능을 완성했습니다.

private void ScrollViewer_ScrollChanged()
{
    if (_scrollViewer.VerticalOffset == 0)
    {
        var changedScrollableHeight = _scrollViewer.ScrollableHeight;
        var diffScrollableHeight = changedScrollableHeight - _scrollableHeight;
        _scrollViewer.ScrollToVerticalOffset(diffScrollableHeight);

        _scrollableHeight = changedScrollableHeight;
    }
    else
    {
        _scrollableHeight = _scrollViewer.ScrollableHeight;
    }
}

_scrollViewer.ScrollChanged += (sender, e) => ScrollViewer_ScrollChanged();

어떤 항목의 개수를 이용할 생각은 못하고
레이아웃 길이의 변화량 관점에서만 접근했었는데
al6uiz 님 접근 방식이 좀더 깔끔해보이네요…

2 Likes

컨트롤에 따라 다르겠지만 ListView의 Visual Tree를 살펴보면 VirtualizingStackPanel을 사용하여 구현되어 있는 것을 알 수 있습니다.

image

일반적인 ScrollViewer는 생각하시는 대로 픽셀 값을 단위 크기로 사용하고 있는데요, VirtualizingStackPanel을 내부적으로 사용하는 컨트롤들은 세로 Scroll 값으로 Item 개수를 단위로 사용하도록 구현되어 있습니다.

이러한 방식으로 구현된 컨트롤들은 세로 방항으로 스크롤할 때 유심히 살펴보시면 픽셀 단위가 아니라 Item 단위로 이동하는 것을 확인하실 수 있을 겁니다.

4 Likes

저렇게 구현에 차이가 있었군요.

덕분에 몰랐던 개념 많이 알게되었습니다.
답변 정말 감사합니다 ㅎㅎ

3 Likes