恢复 ListView 状态 MVVM

Restore ListView state MVVM

当使用 MVVM 时,我们正在处理视图(而视图模型仍然存在)。

我的问题是如何在创建新视图时恢复 ListView 状态,尽可能接近处理视图时的状态?

ScrollIntoView 部分 有效。我只能滚动到单个项目,它可以在顶部或底部,无法控制项目在视图中的显示位置。

我有 multi-selection(和水平滚动条,但这并不重要),有人可能 select 几个项目并可能进一步滚动(不改变 selection)。

理想情况下,将 ListView 属性中的 ScrollViewer 绑定到 viewmodel 就可以了,但我恐怕会陷入直接要求的 XY 问题(不确定 this 是否适用) .此外,在我看来,这对于 wpf 来说是一件很常见的事情,但也许我没有正确地制定 google 查询,因为我找不到相关的 ListView+ScrollViewer+MVVM连击

这可能吗?


我在使用 ScrollIntoView 和数据模板 (MVVM) 时遇到问题,解决方法相当丑陋。用 ScrollIntoView 恢复 ListView 状态听起来是错误的。应该有另一种方法。今天 google 将我引向了我自己未回答的问题。


我正在寻找恢复 ListView 状态的解决方案。考虑如下 mcve:

public class ViewModel
{
    public class Item
    {
        public string Text { get; set; }
        public bool IsSelected { get; set; }

        public static implicit operator Item(string text) => new Item() { Text = text };
    }

    public ObservableCollection<Item> Items { get; } = new ObservableCollection<Item>
    {
        "Item 1",
        "Item 2",
        "Item 3 long enough to use horizontal scroll",
        "Item 4",
        "Item 5",
        new Item {Text = "Item 6", IsSelected = true }, // select something
        "Item 7",
        "Item 8",
        "Item 9",
    };
}

public partial class MainWindow : Window
{
    ViewModel _vm = new ViewModel();

    public MainWindow()
    {
        InitializeComponent();
    }

    void Button_Click(object sender, RoutedEventArgs e) => DataContext = DataContext == null ? _vm : null;
}

xaml:

<StackPanel>
    <ContentControl Content="{Binding}">
        <ContentControl.Resources>
            <DataTemplate DataType="{x:Type local:ViewModel}">
                <ListView Width="100" Height="100" ItemsSource="{Binding Items}">
                    <ListView.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Text}" />
                        </DataTemplate>
                    </ListView.ItemTemplate>
                    <ListView.ItemContainerStyle>
                        <Style TargetType="ListViewItem">
                            <Setter Property="IsSelected" Value="{Binding IsSelected}" />
                        </Style>
                    </ListView.ItemContainerStyle>
                </ListView>
            </DataTemplate>
        </ContentControl.Resources>
    </ContentControl>
    <Button Content="Click"
            Click="Button_Click" />
</StackPanel>

这是一个带有 ContentControl 的 window,内容绑定到 DataContext(通过按钮切换为 nullViewModel 实例)。

我添加了 IsSelected 支持(尝试 select 一些项目,hiding/showing ListView 将恢复它)。

目的是:显示 ListView,滚动(100x100 大小,以便内容更大)垂直 and/or 水平,单击按钮隐藏,单击按钮显示和此时ListView应该恢复它的状态(即ScrollViewer的位置)。

我认为您无法绕过必须手动将滚动查看器滚动到之前的位置 - 使用或不使用 MVVM。 因此,您需要以一种或另一种方式存储滚动查看器的偏移量,并在加载视图时恢复它。

您可以采用实用的 MVVM 方法并将其存储在视图模型中,如下所示:WPF & MVVM: Save ScrollViewer Postion And Set When Reloading。 如果需要,它可能可以用附加的 property/behavior 进行装饰以实现可重用性。

或者您可以完全忽略 MVVM 并将其完全保留在视图端:

编辑:根据您的代码更新了示例:

观点:

<Window x:Class="RestorableView.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RestorableView"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <ListView x:Name="list" ItemsSource="{Binding Items}" ScrollViewer.HorizontalScrollBarVisibility="Auto">
                <ListView.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding Text}" />
                    </DataTemplate>
                </ListView.ItemTemplate>
                <ListView.ItemContainerStyle>
                    <Style TargetType="ListViewItem">
                        <Setter Property="IsSelected" Value="{Binding IsSelected}" />
                    </Style>
                </ListView.ItemContainerStyle>
            </ListView>
            <StackPanel Orientation="Horizontal" Grid.Row="1">
                <Button Content="MVVM Based" x:Name="MvvmBased" Click="MvvmBased_OnClick"/>
                <Button Content="View Based" x:Name="ViewBased" Click="ViewBased_OnClick" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>

代码隐藏有两个按钮分别说明 MVVM 和仅查看方法

public partial class MainWindow : Window
{
    ViewModel _vm = new ViewModel();

    public MainWindow()
    {
        InitializeComponent();
    }

    private void MvvmBased_OnClick(object sender, RoutedEventArgs e)
    {
        var scrollViewer = list.GetChildOfType<ScrollViewer>();
        if (DataContext != null)
        {
            _vm.VerticalOffset = scrollViewer.VerticalOffset;
            _vm.HorizontalOffset = scrollViewer.HorizontalOffset;
            DataContext = null;
        }
        else
        {
            scrollViewer.ScrollToVerticalOffset(_vm.VerticalOffset);
            scrollViewer.ScrollToHorizontalOffset(_vm.HorizontalOffset);
            DataContext = _vm;
        }
    }

    private void ViewBased_OnClick(object sender, RoutedEventArgs e)
    {
        var scrollViewer = list.GetChildOfType<ScrollViewer>();
        if (DataContext != null)
        {
            View.State[typeof(MainWindow)] = new Dictionary<string, object>()
            {
                { "ScrollViewer_VerticalOffset", scrollViewer.VerticalOffset },
                { "ScrollViewer_HorizontalOffset", scrollViewer.HorizontalOffset },
                // Additional fields here
            };
            DataContext = null;
        }
        else
        {
            var persisted = View.State[typeof(MainWindow)];
            if (persisted != null)
            {
                scrollViewer.ScrollToVerticalOffset((double)persisted["ScrollViewer_VerticalOffset"]);
                scrollViewer.ScrollToHorizontalOffset((double)persisted["ScrollViewer_HorizontalOffset"]);
                // Additional fields here
            }
            DataContext = _vm;
        }
    }
}

视图 class 在仅视图方法中保存值

public class View
{
    private readonly Dictionary<string, Dictionary<string, object>> _views = new Dictionary<string, Dictionary<string, object>>();

    private static readonly View _instance = new View();
    public static View State => _instance;

    public Dictionary<string, object> this[string viewKey]
    {
        get
        {
            if (_views.ContainsKey(viewKey))
            {
                return _views[viewKey];
            }
            return null;
        }
        set
        {
            _views[viewKey] = value;
        }
    }

    public Dictionary<string, object> this[Type viewType]
    {
        get
        {
            return this[viewType.FullName];
        }
        set
        {
            this[viewType.FullName] = value;
        }
    }
}

public static class Extensions
{
    public static T GetChildOfType<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) ?? GetChildOfType<T>(child);
            if (result != null) return result;
        }
        return null;
    }
}

对于基于 MVVM 的方法,VM 具有 Horizontal/VerticalOffset 属性

 public class ViewModel
{
    public class Item
    {
        public string Text { get; set; }
        public bool IsSelected { get; set; }

        public static implicit operator Item(string text) => new Item() { Text = text };
    }

    public ViewModel()
    {
        for (int i = 0; i < 50; i++)
        {
            var text = "";
            for (int j = 0; j < i; j++)
            {
                text += "Item " + i;
            }
            Items.Add(new Item() { Text = text });
        }
    }

    public double HorizontalOffset { get; set; }

    public double VerticalOffset { get; set; }

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

所以困难的事情实际上是访问 ScrollViewer 的偏移属性,这需要引入遍历可视化树的扩展方法。我在写原答案的时候没有意识到这一点。

您可以尝试在ListView 中添加SelectedValue 并使用Behavior 来自动滚动。 这是代码:

对于视图模型:

public class ViewModel
{
    public ViewModel()
    {
        // select something
        SelectedValue = Items[5];
    }

    public ObservableCollection<Item> Items { get; } = new ObservableCollection<Item>
    {
        "Item 1",
        "Item 2",
        "Item 3 long enough to use horizontal scroll",
        "Item 4",
        "Item 5",
        "Item 6", 
        "Item 7",
        "Item 8",
        "Item 9"
    };

    // To save which item is selected
    public Item SelectedValue { get; set; }

    public class Item
    {
        public string Text { get; set; }
        public bool IsSelected { get; set; }

        public static implicit operator Item(string text) => new Item {Text = text};
    }
}

对于XAML:

<ListView Width="100" Height="100" ItemsSource="{Binding Items}" SelectedValue="{Binding SelectedValue}" local:ListBoxAutoscrollBehavior.Autoscroll="True">

对于行为:

public static class ListBoxAutoscrollBehavior
{
    public static readonly DependencyProperty AutoscrollProperty = DependencyProperty.RegisterAttached(
        "Autoscroll", typeof (bool), typeof (ListBoxAutoscrollBehavior),
        new PropertyMetadata(default(bool), AutoscrollChangedCallback));

    private static readonly Dictionary<ListBox, SelectionChangedEventHandler> handlersDict =
        new Dictionary<ListBox, SelectionChangedEventHandler>();

    private static void AutoscrollChangedCallback(DependencyObject dependencyObject,
        DependencyPropertyChangedEventArgs args)
    {
        var listBox = dependencyObject as ListBox;
        if (listBox == null)
        {
            throw new InvalidOperationException("Dependency object is not ListBox.");
        }

        if ((bool) args.NewValue)
        {
            Subscribe(listBox);
            listBox.Unloaded += ListBoxOnUnloaded;
            listBox.Loaded += ListBoxOnLoaded;
        }
        else
        {
            Unsubscribe(listBox);
            listBox.Unloaded -= ListBoxOnUnloaded;
            listBox.Loaded -= ListBoxOnLoaded;
        }
    }

    private static void Subscribe(ListBox listBox)
    {
        if (handlersDict.ContainsKey(listBox))
        {
            return;
        }

        var handler = new SelectionChangedEventHandler((sender, eventArgs) => ScrollToSelect(listBox));
        handlersDict.Add(listBox, handler);
        listBox.SelectionChanged += handler;
        ScrollToSelect(listBox);
    }

    private static void Unsubscribe(ListBox listBox)
    {
        SelectionChangedEventHandler handler;
        handlersDict.TryGetValue(listBox, out handler);
        if (handler == null)
        {
            return;
        }
        listBox.SelectionChanged -= handler;
        handlersDict.Remove(listBox);
    }

    private static void ListBoxOnLoaded(object sender, RoutedEventArgs routedEventArgs)
    {
        var listBox = (ListBox) sender;
        if (GetAutoscroll(listBox))
        {
            Subscribe(listBox);
        }
    }

    private static void ListBoxOnUnloaded(object sender, RoutedEventArgs routedEventArgs)
    {
        var listBox = (ListBox) sender;
        if (GetAutoscroll(listBox))
        {
            Unsubscribe(listBox);
        }
    }

    private static void ScrollToSelect(ListBox datagrid)
    {
        if (datagrid.Items.Count == 0)
        {
            return;
        }

        if (datagrid.SelectedItem == null)
        {
            return;
        }

        datagrid.ScrollIntoView(datagrid.SelectedItem);
    }

    public static void SetAutoscroll(DependencyObject element, bool value)
    {
        element.SetValue(AutoscrollProperty, value);
    }

    public static bool GetAutoscroll(DependencyObject element)
    {
        return (bool) element.GetValue(AutoscrollProperty);
    }
}