Search code examples
c#wpfoxyplot

When a tab is revisited a new set of views are generated leading to a potential stack overflow


I have an application based on Josh Smith's MVVM Demo, with the business logic replaced. The user clicks links that dynamically generate tabs. Each tab has a view and viewmodel. The tab views contain child views one of which contains multiple OxyPlot PlotViews. The user is presented with multiple plots, and zooming on one plot will cause all the plots to zoom accordingly. This has worked well on my applications with hard-coded tabs.

The problem occurs when the user goes back to a previously selected tab. All the views in the hierarchy are recreated, but the old views are not deleted.

I have added static counters to my views, so I can keep track of what is happening with otherwise identical views. If the user zooms a plot on a revisited tab, I can see all the views and the copies getting zoomed. The more times a user switches tabs, the more copies that are created.

I actually had a stack overflow because the Oxyplot zoom was causing recursion and an infinite loop. I replaced the zoom with individual min/max settings, which solved that problem, but I still need to get rid of the extra view copies.

Here is the view that contains the PlotView:

<UserControl x:Class="DataPlot.Views.GenericTrackView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:DataPlot.Views"
             xmlns:oxy="http://oxyplot.org/wpf"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800" Loaded="GenericTrackView_OnLoaded">
    <Grid>
        <Grid x:Name="OuterGrid" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="200"/>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid Grid.Column="0">
                <Grid.RowDefinitions>
                    <RowDefinition Height="40"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="460*"/>
                </Grid.RowDefinitions>

                <StackPanel Grid.Row="0" Orientation="Horizontal">
                    <Button Height="30" Width="30" HorizontalAlignment="Left" Margin="2" Click="Button_Click">
                        <TextBlock Text="-" Margin="0,-8,0,0" FontSize="28"/>
                    </Button>
                    <Menu Height="30">
                        <MenuItem Header="{Binding TrackName}">
                            <MenuItem Header="IsLogarithmic" IsCheckable="True" IsChecked="{Binding IsLogarithmic}"/>
                        </MenuItem>
                    </Menu>

                </StackPanel>

                <GroupBox Grid.Row="1" x:Name="Filters" Header="Filters" Visibility="{Binding FiltersVisible}">
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="Auto"/>
                        </Grid.RowDefinitions>

                        <ItemsControl Grid.Row="0" x:Name="FrequencyItems" ItemsSource="{Binding Frequencies}"
                                      IsEnabled="{Binding FrequenciesEnabled}"
                                      Visibility="{Binding FrequenciesVisible}">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate>
                                    <WrapPanel IsItemsHost="True" />
                                </ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <CheckBox Content="{Binding DisplayName }"  IsChecked="{Binding IsChecked, Mode=TwoWay}"
                                      Margin="0,0,5,0">
                                        <CheckBox.InputBindings>
                                            <MouseBinding Gesture="MiddleClick"
                                                  Command="{Binding ElementName=FrequencyItems, Path=DataContext.SelectFrequencyCommand}"
                                                  CommandParameter="{Binding }" />
                                            <MouseBinding Gesture="LeftClick"
                                                  Command="{Binding ElementName=FrequencyItems, Path=DataContext.ToggleFrequencyCommand}"
                                                  CommandParameter="{Binding }" />
                                        </CheckBox.InputBindings>
                                    </CheckBox>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>

                        <ItemsControl Grid.Row="1" x:Name="ComponentItems" ItemsSource="{Binding Components}"
                              IsEnabled="{Binding ComponentsEnabled}"
                              Visibility="{Binding ComponentsVisible}">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate>
                                    <WrapPanel IsItemsHost="True" />
                                </ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <CheckBox Content="{Binding ComponentName }"  IsChecked="{Binding IsChecked, Mode=TwoWay}"
                                      Margin="0,0,5,0">
                                        <CheckBox.InputBindings>
                                            <MouseBinding Gesture="MiddleClick"
                                                  Command="{Binding ElementName=ComponentItems, Path=DataContext.SelectComponentCommand}"
                                                  CommandParameter="{Binding }" />
                                            <MouseBinding Gesture="LeftClick"
                                                  Command="{Binding ElementName=ComponentItems, Path=DataContext.ToggleComponentCommand}"
                                                  CommandParameter="{Binding }" />
                                        </CheckBox.InputBindings>
                                    </CheckBox>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>

                        <ItemsControl Grid.Row="2" x:Name="SpacingsItems" ItemsSource="{Binding Spacings}"
                              IsEnabled="{Binding SpacingsEnabled}"
                              Visibility="{Binding SpacingsVisible}">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate>
                                    <WrapPanel IsItemsHost="True" />
                                </ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <CheckBox Content="{Binding SpacingName }"  IsChecked="{Binding IsChecked, Mode=TwoWay}"
                                      Margin="0,0,5,0">
                                        <CheckBox.InputBindings>
                                            <MouseBinding Gesture="MiddleClick"
                                                  Command="{Binding ElementName=SpacingsItems, Path=DataContext.SelectSpacingCommand}"
                                                  CommandParameter="{Binding }" />
                                            <MouseBinding Gesture="LeftClick"
                                                  Command="{Binding ElementName=SpacingsItems, Path=DataContext.ToggleSpacingCommand}"
                                                  CommandParameter="{Binding }" />
                                        </CheckBox.InputBindings>
                                    </CheckBox>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>

                    </Grid>
                </GroupBox>

                <ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
                    <ItemsControl x:Name="CheckBoxItems" ItemsSource="{Binding Curves}">
                        <ItemsControl.ItemsPanel>
                            <ItemsPanelTemplate>
                                <WrapPanel IsItemsHost="True" />
                            </ItemsPanelTemplate>
                        </ItemsControl.ItemsPanel>
                        <ItemsControl.ItemTemplate>
                            <DataTemplate>
                                <CheckBox Content="{Binding Name}" IsChecked="{Binding IsChecked, Mode=TwoWay}"
                                          Margin="0,0,5,0">
                                    <CheckBox.InputBindings>
                                        <MouseBinding Gesture="MiddleClick"
                                                      Command="{Binding ElementName=CheckBoxItems, Path=DataContext.SelectOnlyCommand}"
                                                      CommandParameter="{Binding }" />
                                    </CheckBox.InputBindings>
                                </CheckBox>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </ScrollViewer>

            </Grid>

            <GridSplitter x:Name="GridSplitter" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Stretch"
                          Background="Gray" ShowsPreview="True" Width="3" DragCompleted="GridSplitter_DragCompleted"/>

            <oxy:PlotView x:Name="Plot" Model="{Binding PlotModel}" Grid.Column="2" Loaded="Plot_OnLoaded" MinHeight="{Binding MinimumPlotHeight}">
                <oxy:PlotView.DefaultTrackerTemplate>
                    <ControlTemplate>
                        <oxy:TrackerControl Position="{Binding Position}" LineExtents="{Binding PlotModel.PlotArea}">
                            <oxy:TrackerControl.Background>
                                <LinearGradientBrush EndPoint="0,1">
                                    <GradientStop Color="#f0e0e0ff" />
                                    <GradientStop Offset="1" Color="#f0ffffff" />
                                </LinearGradientBrush>
                            </oxy:TrackerControl.Background>
                            <oxy:TrackerControl.Content>
                                <TextBlock Text="{Binding}" Margin="7" />
                            </oxy:TrackerControl.Content>
                        </oxy:TrackerControl>
                    </ControlTemplate>
                </oxy:PlotView.DefaultTrackerTemplate>
            </oxy:PlotView>

        </Grid>
    </Grid>
</UserControl>

Code behind

public partial class GenericTrackView : UserControl, ITrackView
{
    #region Fields

    private TrackContainerView _parentView;
    private static int Count;

    #endregion

    #region Constructor

    public GenericTrackView()
    {
        InitializeComponent();
        Count++;

        // for debugging, keep track of redundant views
        ID = Count;
    }

    #endregion

    #region Dependency Properties

    public static readonly DependencyProperty TrackProperty =
        DependencyProperty.Register("Track", typeof(Track), typeof(GenericTrackView), new FrameworkPropertyMetadata(null, OnTrackChanged));

    public Track Track
    {
        get => (Track)GetValue(TrackProperty);
        set => SetValue(TrackProperty, value);
    }

    public static readonly DependencyProperty IsActiveProperty =
        DependencyProperty.Register("IsActive", typeof(bool), typeof(GenericTrackView),
            new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public bool IsActive
    {
        get => (bool)GetValue(IsActiveProperty);
        set => SetValue(IsActiveProperty, value);
    }

    #endregion

    #region Properties
    public int ID { get; private set; }
    public Axis XAxis { get; private set; }
    public bool IsInternalChange { get; private set; }

    #endregion

    #region Private Methods

    private static void OnTrackChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var trackPanel = sender as GenericTrackView;
        if (trackPanel == null)
            return;

        var track = (Track)e.NewValue;
    }

    private void OnAxisChanged(object sender, AxisChangedEventArgs e)
    {
        // if true then stop any recursion
        if (IsInternalChange)
            return;

        var xMin = XAxis.ActualMinimum;
        var xMax = XAxis.ActualMaximum;

        foreach (var track in _parentView.TrackList)
        {
            if (track == this)
                continue;

            var genericTrack = track as GenericTrackView;

            genericTrack.IsInternalChange = true;

            // do not use zoom to set axis, it can lead to recursion
            genericTrack.XAxis.AbsoluteMinimum = xMin;
            genericTrack.XAxis.AbsoluteMaximum = xMax;
            genericTrack.Plot.InvalidatePlot(false);
            genericTrack.IsInternalChange = false;
        }
    }

    public override string ToString()
    {
        return $"{Name}: {ID}";
    }

    #endregion

    #region Event Handlers

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        var button = sender as Button;
        if (button == null)
            return;

        IsActive = !IsActive;
        Plot.Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
        CheckBoxItems.Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
        GridSplitter.Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
        Filters.Visibility = IsActive ? ((GenericTrackViewModel)DataContext).FiltersVisible : Visibility.Collapsed;
        if (button.Content is TextBlock textBlock)
        {
            textBlock.Text = IsActive ? "-" : "+";
        }
        else
        {
            button.Content = IsActive ? "-" : "+";
        }

        // We need to toggle the Grid.RowDefinition.Height from "*" to "Auto" in order
        // for the row to collapse.
        // We know this control is in a ContentPresenter, which is a child of the Grid.
        // The Grid is the ItemsPanel of an ItemsControl. So first we find the Grid,
        // then we find and set the row that this TrackPanelView is in.
        var grid = WpfHelpers.FindParentControl<Grid>(this);
        if (grid == null)
            return;

        var index = 0;
        foreach (var contentPresenter in grid.Children)
        {
            var dependencyObject = contentPresenter as DependencyObject;
            if (dependencyObject == null)
                continue;

            var panel = WpfHelpers.FindFirstVisualChild<GenericTrackView>(dependencyObject);
            if (Equals(panel, this))
            {
                grid.RowDefinitions[index].Height = IsActive ? new GridLength(1, GridUnitType.Star) : new GridLength(1, GridUnitType.Auto);
                break;
            }
            index++;
        }
    }

    private void GridSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        var gridSplitter = sender as GridSplitter;
        if (gridSplitter == null)
            return;

        if (!(Math.Abs(e.HorizontalChange) > 0.0))
            return;

        foreach (var trackView in _parentView.TrackList)
        {
            if (!Equals(trackView, this))
            {
                ((GenericTrackView)trackView).OuterGrid.ColumnDefinitions[0].Width = OuterGrid.ColumnDefinitions[0].Width;
            }
        }
    }

    /// <summary>
    /// When the plot is loaded, find the x axis and link its AxisChanged event to the
    /// OnAxisChanged method.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Plot_OnLoaded(object sender, RoutedEventArgs e)
    {
        foreach (var axis in Plot.ActualModel.Axes)
        {
            if (axis.Position != AxisPosition.Bottom)
                continue;

            XAxis = axis;
            break;
        }

        if (XAxis != null)
            XAxis.AxisChanged += OnAxisChanged;
    }

    private void GenericTrackView_OnLoaded(object sender, RoutedEventArgs e)
    {
        Name = ((GenericTrackViewModel)DataContext).TrackName;

        // find parent and add this track to its list
        _parentView = WpfHelpers.FindParentControl<TrackContainerView>(this);
        _parentView.TrackList.Add(this);
    }

    #endregion

}

In my viewmodels, I am not using PlotModel, I am using ViewResolvingPlotModel as described in a previous post:

OxyPlot - This PlotModel is already in use by some other PlotView control

I don't know if the code I have shown is helpful, as I don't understand what the root cause of the problem is. All the connections between views and viewmodels are done through DataTemplates. I see no code in my MainWindowViewModel that is called when a user switches tabs.

The viewmodel is created as follows.

private void ShowPipelineTest()
{
    var workspace = new TestPipelineViewModel();
    Workspaces.Add(workspace);
    SetActiveWorkspace(workspace);
}

void SetActiveWorkspace(WorkspaceViewModel workspace)
{
    Debug.Assert(Workspaces.Contains(workspace));

    var collectionView = CollectionViewSource.GetDefaultView(Workspaces);
    collectionView?.MoveCurrentTo(workspace);
}

But this code is not called when the tab is revisited.

Edit: I found some code for testing. The original MVVM Demo can be found here:

https://github.com/djangojazz/JoshSmith_MVVMDemo

I had problems with the solution, but the project opened and worked. First open an All Customers tab, then a New Customer tab. When you click back on the first tab you will see the view get instantiated again. That is the problem. My application uses the same MainWindowView and the same MainWondowViewModel with my tab viewmodels replacing those in the demo.


Solution

  • I had some private discussion with BionicCode, in order to make it more claer what I was trying to do with my code. He quickly found the problem was my handlers were keeping the garbage collector from cleaning up the extra objects. I had to add some Unloaded handlers. Here is the latest code, which has been moved to a base class, so it is a little different.

        /// <summary>
        /// When the plot is loaded, find the x axis and link its AxisChanged event to the
        /// OnAxisChanged method.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        protected void Plot_OnLoaded(object sender, RoutedEventArgs e)
        {
            Plot = sender as PlotView;
            foreach (var axis in Plot.ActualModel.Axes)
            {
                if (axis.Position != AxisPosition.Bottom)
                    continue;
            
                XAxis = axis;
                break;
            }
            
            if (XAxis != null)
                XAxis.AxisChanged += OnAxisChanged;
        }
    
        /// <summary>
        /// When the plot is unloaded, unlink its AxisChanged event.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        protected void Plot_UnLoaded(object sender, RoutedEventArgs e)
        {
            if (XAxis != null)
                XAxis.AxisChanged -= OnAxisChanged;
        }
    
        protected void TrackView_OnLoaded(object sender, RoutedEventArgs e)
        {
            Name = ((ITrackViewModel)DataContext).TrackName;
    
            // find parent and add this track to its list
            _parentView = WpfHelpers.FindParentControl<TrackContainerView>(this);
            _parentView.TrackList.Add(this);
        }
    
        protected void TrackView_UnLoaded(object sender, RoutedEventArgs e)
        {
            // remove this from parent.
            if (_parentView != null && _parentView.TrackList.Contains(this))
                _parentView.TrackList.Remove(this);
        }
    

    Thanks for the help.