在MVVM应用程序中,在视图之间导航时如何保留视图的完整状态?

17

我有一个MVVM应用程序,需要在屏幕之间进行基本的后退/前进导航。目前,我使用一个 WorkspaceHostViewModel 实现了这一点,该视图模型跟踪当前的工作区,并按如下方式公开必要的导航命令。

public class WorkspaceHostViewModel : ViewModelBase
{
    private WorkspaceViewModel _currentWorkspace;
    public WorkspaceViewModel CurrentWorkspace
    {
        get { return this._currentWorkspace; }
        set
        {
            if (this._currentWorkspace == null
                || !this._currentWorkspace.Equals(value))
            {
                this._currentWorkspace = value;
                this.OnPropertyChanged(() => this.CurrentWorkspace);
            }
        }
    }

    private LinkedList<WorkspaceViewModel> _navigationHistory;

    public ICommand NavigateBackwardCommand { get; set; }
    public ICommand NavigateForwardCommand { get; set; }
}

我还有一个WorkspaceHostView,它绑定到WorkspaceHostViewModel如下。

<Window x:Class="MyNavigator.WorkspaceHostViewModel"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

  <Window.Resources>
    <ResourceDictionary Source="../../Resources/WorkspaceHostResources.xaml" />
  </Window.Resources>

  <Grid>
    <!-- Current Workspace -->
    <ContentControl Content="{Binding Path=CurrentWorkspace}"/>
  </Grid>

</Window>
在WorkspaceHostResources.xaml文件中,我使用DataTemplates来关联WPF应该使用的视图来渲染每个WorkspaceViewModel。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:MyNavigator">

  <DataTemplate DataType="{x:Type local:WorkspaceViewModel1}">
    <local:WorkspaceView1/>
  </DataTemplate>

  <DataTemplate DataType="{x:Type local:WorkspaceViewModel2}">
    <local:WorkspaceView2/>
  </DataTemplate>

</ResourceDictionary>
这个方案运作得相当好,但是存在一个缺点,由于 DataTemplates 的机制,在每次导航之间 Views 都会被重新创建。如果视图包含复杂的控件,比如 DataGrids 或 TreeViews,它们的内部状态就会丢失。例如,如果我有一个带有可展开和可排序行的 DataGrid,则当用户导航到下一个屏幕,然后返回到 DataGrid 屏幕时,展开/折叠状态和排序顺序会丢失。在大多数情况下,可以跟踪需要在导航之间保留的每个状态信息,但这似乎是一种非常不优雅的方法。

是否有更好的方法来保留视图的整个状态,以便在更改整个屏幕的导航事件之间进行保留?
5个回答

10
我遇到了同样的问题,最终我使用了在网上找到的一些代码来扩展 TabControl,以防止它在切换选项卡时销毁其子元素。通常我会重写 TabControl 模板以隐藏选项卡,并且我将使用 SelectedItem 来定义当前可见的“工作区”。
其背后的思想是,每个 TabItemContentPresenter 在切换到新项目时会被缓存,然后当您切换回来时,它会重新加载缓存的项目,而不是重新创建它。
<local:TabControlEx ItemsSource="{Binding AvailableWorkspaces}"
                    SelectedItem="{Binding CurrentWorkspace}"
                    Template="{StaticResource BlankTabControlTemplate}" />

这段代码所在的网站似乎已经被关闭,但是我正在使用的代码在这里。它已经从原始代码进行了一些修改。

// Extended TabControl which saves the displayed item so you don't get the performance hit of 
// unloading and reloading the VisualTree when switching tabs

// Obtained from http://www.pluralsight-training.net/community/blogs/eburke/archive/2009/04/30/keeping-the-wpf-tab-control-from-destroying-its-children.aspx
// and made a some modifications so it reuses a TabItem's ContentPresenter when doing drag/drop operations

[TemplatePart(Name = "PART_ItemsHolder", Type = typeof(Panel))]
public class TabControlEx : System.Windows.Controls.TabControl
{
    // Holds all items, but only marks the current tab's item as visible
    private Panel _itemsHolder = null;

    // Temporaily holds deleted item in case this was a drag/drop operation
    private object _deletedObject = null;

    public TabControlEx()
        : base()
    {
        // this is necessary so that we get the initial databound selected item
        this.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;
    }

    /// <summary>
    /// if containers are done, generate the selected item
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
    {
        if (this.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated)
        {
            this.ItemContainerGenerator.StatusChanged -= ItemContainerGenerator_StatusChanged;
            UpdateSelectedItem();
        }
    }

    /// <summary>
    /// get the ItemsHolder and generate any children
    /// </summary>
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        _itemsHolder = GetTemplateChild("PART_ItemsHolder") as Panel;
        UpdateSelectedItem();
    }

    /// <summary>
    /// when the items change we remove any generated panel children and add any new ones as necessary
    /// </summary>
    /// <param name="e"></param>
    protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnItemsChanged(e);

        if (_itemsHolder == null)
        {
            return;
        }

        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Reset:
                _itemsHolder.Children.Clear();

                if (base.Items.Count > 0)
                {
                    base.SelectedItem = base.Items[0];
                    UpdateSelectedItem();
                }

                break;

            case NotifyCollectionChangedAction.Add:
            case NotifyCollectionChangedAction.Remove:

                // Search for recently deleted items caused by a Drag/Drop operation
                if (e.NewItems != null && _deletedObject != null)
                {
                    foreach (var item in e.NewItems)
                    {
                        if (_deletedObject == item)
                        {
                            // If the new item is the same as the recently deleted one (i.e. a drag/drop event)
                            // then cancel the deletion and reuse the ContentPresenter so it doesn't have to be 
                            // redrawn. We do need to link the presenter to the new item though (using the Tag)
                            ContentPresenter cp = FindChildContentPresenter(_deletedObject);
                            if (cp != null)
                            {
                                int index = _itemsHolder.Children.IndexOf(cp);

                                (_itemsHolder.Children[index] as ContentPresenter).Tag =
                                    (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
                            }
                            _deletedObject = null;
                        }
                    }
                }

                if (e.OldItems != null)
                {
                    foreach (var item in e.OldItems)
                    {

                        _deletedObject = item;

                        // We want to run this at a slightly later priority in case this
                        // is a drag/drop operation so that we can reuse the template
                        this.Dispatcher.BeginInvoke(DispatcherPriority.DataBind,
                            new Action(delegate()
                        {
                            if (_deletedObject != null)
                            {
                                ContentPresenter cp = FindChildContentPresenter(_deletedObject);
                                if (cp != null)
                                {
                                    this._itemsHolder.Children.Remove(cp);
                                }
                            }
                        }
                        ));
                    }
                }

                UpdateSelectedItem();
                break;

            case NotifyCollectionChangedAction.Replace:
                throw new NotImplementedException("Replace not implemented yet");
        }
    }

    /// <summary>
    /// update the visible child in the ItemsHolder
    /// </summary>
    /// <param name="e"></param>
    protected override void OnSelectionChanged(SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);
        UpdateSelectedItem();
    }

    /// <summary>
    /// generate a ContentPresenter for the selected item
    /// </summary>
    void UpdateSelectedItem()
    {
        if (_itemsHolder == null)
        {
            return;
        }

        // generate a ContentPresenter if necessary
        TabItem item = GetSelectedTabItem();
        if (item != null)
        {
            CreateChildContentPresenter(item);
        }

        // show the right child
        foreach (ContentPresenter child in _itemsHolder.Children)
        {
            child.Visibility = ((child.Tag as TabItem).IsSelected) ? Visibility.Visible : Visibility.Collapsed;
        }
    }

    /// <summary>
    /// create the child ContentPresenter for the given item (could be data or a TabItem)
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    ContentPresenter CreateChildContentPresenter(object item)
    {
        if (item == null)
        {
            return null;
        }

        ContentPresenter cp = FindChildContentPresenter(item);

        if (cp != null)
        {
            return cp;
        }

        // the actual child to be added.  cp.Tag is a reference to the TabItem
        cp = new ContentPresenter();
        cp.Content = (item is TabItem) ? (item as TabItem).Content : item;
        cp.ContentTemplate = this.SelectedContentTemplate;
        cp.ContentTemplateSelector = this.SelectedContentTemplateSelector;
        cp.ContentStringFormat = this.SelectedContentStringFormat;
        cp.Visibility = Visibility.Collapsed;
        cp.Tag = (item is TabItem) ? item : (this.ItemContainerGenerator.ContainerFromItem(item));
        _itemsHolder.Children.Add(cp);
        return cp;
    }

    /// <summary>
    /// Find the CP for the given object.  data could be a TabItem or a piece of data
    /// </summary>
    /// <param name="data"></param>
    /// <returns></returns>
    ContentPresenter FindChildContentPresenter(object data)
    {
        if (data is TabItem)
        {
            data = (data as TabItem).Content;
        }

        if (data == null)
        {
            return null;
        }

        if (_itemsHolder == null)
        {
            return null;
        }

        foreach (ContentPresenter cp in _itemsHolder.Children)
        {
            if (cp.Content == data)
            {
                return cp;
            }
        }

        return null;
    }

    /// <summary>
    /// copied from TabControl; wish it were protected in that class instead of private
    /// </summary>
    /// <returns></returns>
    protected TabItem GetSelectedTabItem()
    {
        object selectedItem = base.SelectedItem;
        if (selectedItem == null)
        {
            return null;
        }

        if (_deletedObject == selectedItem)
        { 

        }

        TabItem item = selectedItem as TabItem;
        if (item == null)
        {
            item = base.ItemContainerGenerator.ContainerFromIndex(base.SelectedIndex) as TabItem;
        }
        return item;
    }
}

我已经实现了你的MDITabControl解决方案,它很好。所以谢谢你!但是我注意到当一个视图被关闭(使用选项卡上的“x”按钮)时,该视图没有触发任何“Unloaded”事件。Loaded确实会触发。这让我担心它可能会泄漏内存(更不用说那个事件会很方便)。你有注意到吗?有什么想法为什么会这样? - DonBoitnott
从代码后台更新放置在选项卡项内的数据网格的滚动条是否可行?例如 dataGrid.ScrollIntoView(log)?目前布局已缓存,但UI更改的代码后处理程序无法工作。 - Ankush Madankar

4

我最终在WorkspaceHostViewModel中添加了一个ActiveWorkspaces ObservableCollection属性,并将ItemsControl绑定到它上面,如下所示。

<!-- Workspace -->
<ItemsControl ItemsSource="{Binding Path=ActiveWorkspaces}">
    <ItemsControl.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
    </ItemsControl.Resources>
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>            
    <ItemsControl.ItemContainerStyle>
        <Style TargetType="{x:Type ContentPresenter}">
            <Setter Property="Visibility" Value="{Binding Visible, Converter={StaticResource BooleanToVisibilityConverter}}"/>
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

ActiveWorkspaces属性包含导航历史中的所有工作区。它们在UI上重叠渲染,但通过绑定各自ContentPresenter的可见性,我能够一次只显示一个。
操作Visible属性的逻辑(这是Workspace本身的新属性)存在于向前/向后导航命令中。
这种方法与Rachel提出的解决方案非常相似,并且部分基于她网站上找到的ItemsControl教程;然而,我选择自己编写show/hide逻辑,而不是依赖于子类化TabControl来执行它。我仍然觉得有可能改进show/hide逻辑。具体来说,我想消除Workspace类中的Visible属性,但现在这足够好用了。
更新:

在成功使用上述解决方案数月后,我选择使用Prism提供的view-based navigation功能来替换它。尽管这种方法需要更多的开销,但其优势大大超过了所需的努力。一般的想法是,在您的视图中定义Region,然后通过在ViewModel中调用regionManager.RequestNavigate("RegionName", "navigationUri")来进行导航。Prism处理实例化、初始化和在指定区域显示您的视图的工作。此外,您可以控制视图的生命周期,是否应在后续导航请求中重新使用视图,导航到和从事件上执行什么逻辑,以及是否应中止导航(由于当前视图中存在未保存的更改等)。请注意,Prism基于视图的导航需要依赖注入容器(例如Unity或MEF),因此您可能需要将其纳入您的应用程序架构中,但即使没有Prism导航,采用DI容器也非常值得投资。


好的,这个线程很旧了,但我仍然无法解决完全相同的问题。如果我像这个答案中那样编写我的代码,那么样式设置器中的“Binding Visible”将指向外部视图模型(在示例中将是WorkspaceHostViewModel),而不是集合内部的工作区。如果我尝试直接将DataTrigger从标志Visible(值为true/false)应用到Style属性Visibility(值为Visible/Collapsed),我会得到相同的效果。如果有人知道我做错了什么,请告诉我... - Hans Huckebein

3

0
我已经成功修复了它,而不使用TabControlEx(因为它对我也没有用)。 我使用了Datatemplates和templateselector来在选项卡之间切换。
Xaml:
 <Window.Resources>
    <local:MainTabViewDataTemplateSelector x:Key="myMainContentTemplateSelector" />
    <DataTemplate x:Key="Dashboard">
        <views:DashboardView />
    </DataTemplate>
    <DataTemplate x:Key="SystemHealth">
        <views:SystemHealthView />
    </DataTemplate>
</Window.Resources>
        <TabControl ItemsSource="{Binding MainTabs}"
                Margin="0,33,0,0"
                Grid.RowSpan="2"
                SelectedIndex="0"
                 Width="auto" 
                Style="{DynamicResource TabControlStyleMain}"
                ContentTemplateSelector="{StaticResource myMainContentTemplateSelector}"
                Padding="20" Grid.ColumnSpan="2"
                VerticalAlignment="Stretch">
        <TabControl.Background>

            <ImageBrush ImageSource="/SystemHealthAndDashboard;component/Images/innerBackground.png"/>

            </TabControl.Background>
        <TabControl.ItemTemplate>
            <DataTemplate >
                    <TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" HorizontalAlignment="Left"/>
            </DataTemplate>
        </TabControl.ItemTemplate>
    </TabControl>

数据模板选择器:

 public class MainTabViewDataTemplateSelector : DataTemplateSelector
{
    public override DataTemplate SelectTemplate(object item, DependencyObject container)
    {
        FrameworkElement element = container as FrameworkElement;
        switch ((item as TabInfoEntity).TabIndex)
        {
            case 1:
                {
                    return element.FindResource("Dashboard") as DataTemplate;
                }
            case 2:
                {
                    return element.FindResource("SystemHealth") as DataTemplate;
                }

        }
        return null;
    }
}

TabInfoEntity类(此类型对象的列表是TabControl的ItemSource):

public class TabInfoEntity
{
    public TabInfoEntity()
    {
            
    }
    private string name;

    public string Name
    {
        get { return name; }
        set { name = value; }
    }

    private int tabindex;

    public int TabIndex
    {
        get { return tabindex; }
        set { tabindex = value; }
    }
}

-2

我可能没有理解到重点,但任何重要的视图状态都可以(甚至应该)存储在ViewModel中。这有点取决于有多少内容以及你愿意变得多么“肮脏”。

如果从纯粹主义的角度来看,这种方法可能不符合你的要求,那么你可以将那些无法完全放入ViewModel中的部分绑定到一个包含状态的单独类中(也许称之为ViewState类?)。

如果它们确实是仅限于视图的属性,并且您不想采用上述任何一种方法,那么它们就属于视图。您应该找出一种不需要每次重新创建视图的方法:例如使用工厂而不是内置数据模板。如果您使用DataTemplateSelector,则可以返回一个模板,也许有一种方法可以在那里重复使用视图实例?(我需要检查一下...)


4
我希望在ViewModel中尽可能减少存储视图状态。在我的情况下,我只讨论与视图相关的信息,例如DataGrid中的当前行、文本框中当前高亮的文本或滚动条的位置等。这些信息在ViewModel中并非必需,但在离开和返回屏幕时应该被保留。似乎重用先前创建的视图而不是创建新视图的技术正好是我所需的,但我找不到任何实现此目标的技术。 - Timothy Schoonover

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接