6

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

Community
  • 1
  • 1
Sinatr
  • 20,892
  • 15
  • 90
  • 319
  • You can do with `System.Windows.Interactivity`. Check **[this](https://stackoverflow.com/a/8830961/6940144)** way. – EgoistDeveloper Jun 23 '20 at 19:37
  • @EgoistDeveloper, active scrolling to selected item produced some side effects and is not as reliable compared to restoring child `ScrollViewer` offsets in accepted answer. – Sinatr Jun 24 '20 at 07:22

2 Answers2

3

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.

Community
  • 1
  • 1
sondergard
  • 3,184
  • 1
  • 16
  • 25
  • I don't see how to use anything from this answer. See edit, I've added MCVE, could you make it working (restoring state) ? – Sinatr Aug 09 '16 at 12:24
  • I update the answer according to your sample. I also realised there were a bunch of compile errors in my original answer - I apologise for that. It was written in notepad, as I didn't have access to Visual Studio. This one is written and tested in VS though :) – sondergard Aug 09 '16 at 13:15
  • It's in the class Extensions, which got bundled into the View class in the sample. – sondergard Aug 09 '16 at 13:40
  • Didn't noticed it. I am only interested in MVVM approach. Your code seems to work. Let me try it with real project. – Sinatr Aug 09 '16 at 13:44
0

You can try to add SelectedValue in ListView and use the Behavior to Autoscroll. Here is code:

For ViewModel:

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

For XAML:

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

For Behavior:

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);
    }
}
zzczzc004
  • 131
  • 5
  • I didn't tested it (sorry), but I had similar idea in past. And the problem was with following: select something, then scroll (up or down) and select more items. As soon as you select first item outside you will have your scroll logic triggered and depending on strategy (do you scroll to first or last item?) something will happens. This is very annoying to the user. Another thing here you are using dictionary, why? Simply subscribe to `SelectedChanged` similarly to how you do it with `Loaded`. And local variable `datagrid` tells about from where this was ripped off. – Sinatr Aug 11 '16 at 07:39
  • I actually didn't consider select more items. I test it again and It will scroll to the **first item**. The dictionary is use to unsubscribe the `SelectionChangedEventHandler` because the lambda function[link](http://stackoverflow.com/questions/183367/unsubscribe-anonymous-method-in-c-sharp). `datagrid` is a mistake because I initial use this Behavior in the Datagrid and I changed it to Listbox. – zzczzc004 Aug 11 '16 at 08:01