Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Restore ListView state MVVM

When using MVVM we are disposing view (while viewmodel persists).

My question is how to restore ListView state when creating new view as close as possible to one when view was disposed?

ScrollIntoView works only partially. I can only scroll to a single item and it can be on top or bottom, there is no control of where item will appears in the view.

I have multi-selection (and horizontal scroll-bar, but this is rather unimportant) and someone may select several items and perhaps scroll further (without changing selection).

Ideally binding ScrollViewer of ListView properties to viewmodel would do, but I am afraid to fall under XY problem asking for that directly (not sure if this is even applicable). Moreover this seems to me to be a very common thing for wpf, but perhaps I fail to formulate google query properly as I can't find related ListView+ScrollViewer+MVVM combo.

Is this possible?


I have problems with ScrollIntoView and data-templates (MVVM) with rather ugly workarounds. Restoring ListView state with ScrollIntoView sounds wrong. There should be another way. Today google leads me to my own unanswered question.


I am looking for a solution to restore ListView state. Consider following as 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>

This is a window with ContentControl which content is bound to DataContext (toggled by button to be either null or ViewModel instance).

I've added IsSelected support (try to select some items, hiding/showing ListView will restore that).

The aim is: show ListView, scroll (it's 100x100 size, so that content is bigger) vertically and/or horizontally, click button to hide, click button to show and at this time ListView should restore its state (namely position of ScrollViewer).

like image 454
Sinatr Avatar asked Feb 01 '16 11:02

Sinatr


1 Answers

I don't think you can get around having to manually scroll the scrollviewer to the previous position - with or without MVVM. As such you need to store the offsets of the scrollviewer, one way or another, and restore it when the view is loaded.

You could take the pragmatic MVVM approach and store it on the viewmodel as illustrated here: WPF & MVVM: Save ScrollViewer Postion And Set When Reloading. It could probably be decorated with an attached property/behavior for reusability if needed.

Alternatively you could completely ignore MVVM and keep it entirely on the view side:

EDIT: Updated the sample based on your code:

The view:

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

The code-behind has two buttons to illustrate the MVVM and View-only approach respectively

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

The view class to hold the values in the View-only approach

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

For the MVVM based approach the VM has a Horizontal/VerticalOffset property

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

So the difficult thing is actually getting access to the offset properties of the ScrollViewer, which required introducing an extension method which walks the visual tree. I didn't realize this when writing the original answer.

like image 190
sondergard Avatar answered Nov 08 '22 02:11

sondergard