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

Posted

技术标签:

【中文标题】在 MVVM 应用程序中的视图之间导航时如何保留视图的完整状态?【英文标题】:How to preserve the full state of the View when navigating between Views in an MVVM application? 【发布时间】:2012-02-07 03:33:15 【问题描述】:

我有一个 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 文件中,我关联了 WPF 应该使用 DataTemplates 呈现每个 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 的机制,视图会在每次导航之间重新创建。如果视图包含复杂的控件,例如 DataGrids 或 TreeViews,它们的内部状态就会丢失。例如,如果我有一个带有可展开和可排序行的 DataGrid,当用户导航到下一个屏幕然后返回 DataGrid 屏幕时,展开/折叠状态和排序顺序就会丢失。在大多数情况下,可以跟踪需要在导航之间保留的每条状态信息,但这似乎是一种非常不雅的方法。

有没有更好的方法在更改整个屏幕的导航事件之间保留视图的整个状态?

【问题讨论】:

【参考方案1】:

我遇到了同样的问题,最后我使用了一些我在网上找到的扩展 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 着火了。这让我担心它可能会泄漏内存(更不用说那个事件会很方便)。你注意到了吗?知道为什么吗? 是否可以从后面的代码更新放置在选项卡项内的数据网格的滚动?喜欢dataGrid.ScrollIntoView( log )?现在布局已缓存,但 UI 更改处理程序后面的代码不起作用..【参考方案2】:

我最终向 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 属性(这是工作区本身的新属性)的逻辑存在于向前/向后导航命令中。

这与 Rachel 提出的解决方案非常相似,部分基于她网站上的 ItemsControl 教程;但是,我选择自己编写显示/隐藏逻辑,而不是依赖子类 TabControl 为我编写。我仍然觉得可以改进显示/隐藏逻辑。具体来说,我想从 Workspace 类中删除 Visible 属性,但现在这已经足够好了。

更新:

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

【讨论】:

好的,线程已经很老了,但我没能解决完全相同的问题。如果我像在这个答案中那样写下我的代码,则样式设置器中的“可见绑定”指向外部视图模型(在示例中为 WorkspaceHostViewModel),而不是集合内的工作区。如果我尝试将 DataTrigger 从标志 Visible(值 true/false)直接应用到 Style 属性 Visibility(值 Visible/Collapsed),我会产生相同的效果。如果有人知道我做错了什么......【参考方案3】:

要使 TabControlEx 工作,您还必须应用控件模板,此处未在答案中提供。 你可以找到它@Stop TabControl from recreating its children

【讨论】:

【参考方案4】:

我已经设法在不使用 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>

DataTemplateSelector:

 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; 
    

【讨论】:

【参考方案5】:

我可能没有抓住重点,但任何重要的视图状态都可以(或者甚至应该)存储在 ViewModel 中。这在某种程度上取决于有多少,以及你愿意得到多脏。

如果这不合适(从纯粹主义者的角度来看,它可能与您正在做的事情不相符),您可以将视图中那些不太支持 VM 的部分绑定到包含状态的单独类(称它们为也许是 ViewState 类?)。

如果它们确实是仅供查看的属性,并且您不想采用其中任何一条路线,那么它们就是它们所属的位置,在视图中。相反,您应该找出一种不每次都重新创建视图的方法:例如,使用工厂而不是内置数据模板。如果你去DataTemplateSelector 你会返回一个我相信的模板,也许有一种方法可以重用那里的视图实例? (我必须检查..)

【讨论】:

我想尽可能限制在 ViewModel 中存储 View 状态。就我而言,我只讨论查看特定信息,例如 DataGrid 中的当前行、文本框中当前突出显示的文本或滚动条的位置。 ViewModel 中的任何东西都不是严格要求的东西,但在导航离开然后返回屏幕时应该保留这些东西。这听起来像是一种重用以前创建的视图而不是重新创建新视图的技术,这正是我所需要的,但我还没有找到任何技术来实现这一点。

以上是关于在 MVVM 应用程序中的视图之间导航时如何保留视图的完整状态?的主要内容,如果未能解决你的问题,请参考以下文章

在 WPF MVVM 中的视图之间导航

使用 mvvm 在 xamarin 表单中的视图之间传递数据

使用mvvm在xamarin表单中的视图之间传递数据

在 SwiftUI MVVM 中的子视图和父视图之间共享值

导航返回时导航控制器和视图未更新

MVVM的本质:视图逻辑处理视图管理视数中间层