恢复 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
(通过按钮切换为 null
或 ViewModel
实例)。
我添加了 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);
}
}
当使用 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
(通过按钮切换为 null
或 ViewModel
实例)。
我添加了 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);
}
}