AvaloniaUI / Avalonia

Develop Desktop, Embedded, Mobile and WebAssembly apps with C# and XAML. The most popular .NET UI client technology
https://avaloniaui.net
MIT License
26.09k stars 2.26k forks source link

TabControl only instantiates its ContentTemplate once #14529

Open AtomCrafty opened 9 months ago

AtomCrafty commented 9 months ago

Describe the bug

A tab control with multiple tabs will only instantiate its content template a single time and re-use the same controls for all tabs. Only the DataContext is changed when switching between tabs. This means that control state like the position of a scroll viewer carries over from one tab to another, even though the scroll viewers in different tabs should be entirely independent.

Imagine switching between documents in your IDE of choice and the scroll position is shared among all open files. I feel like this behavior is very unintuitive and I don't see a straightforward way to work around it without writing a completely custom tab control.

I haven't looked to deeply into this, but I assume this is what's happening: Contrary to most other ItemsControls, the TabControl only has a single ContentPresenter, since only one item can be shown at a time. This however also means that when the DataContext (a.k.a. the tab control's SelectedContent) changes to another object of the same type, said content presenter has no reason to create a second instance of the template.

Expected behavior

Since the TabControl is an ItemsControl, I would expect each item to get its own instance of the appropriate data template. The obvious approach would be to force a new template instantiation whenever a new tab is selected, but I don't think that's a good solution either. Ideally, the control state within each tab should be independent, but persistent, so switching to another tab and back to the first should preserve the position of any scroll viewers on the first tab. To return to the IDE example: Imagine switching to another document and the scroll position is reset to the top every time.

There might be a discussion about virtualization to be had here, but I'd posit that tab controls will - in most cases - contain a fairly small amount of tabs, so it should be possible to keep all of the template instances materialized at all times.

Screenshots

TabControl on the right, ListBox for comparison on the left. Three instances of the template are created in total. TabControl

To Reproduce

Minimal example:

<UserControl xmlns="https://github.com/avaloniaui"
             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:vm="clr-namespace:TabControlTest.ViewModels"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="TabControlTest.Views.MainView"
             x:DataType="vm:MainViewModel">
  <UserControl.DataContext>
    <vm:MainViewModel/>
  </UserControl.DataContext>

  <UserControl.Resources>
    <DataTemplate DataType="vm:TabViewModel" x:Key="Template">
      <ScrollViewer Width="500" Height="300" HorizontalScrollBarVisibility="Visible" AllowAutoHide="False" Initialized="OnTemplateInstantiated">
        <Grid Width="1000" Height="300" ColumnDefinitions="* *">
          <Border Grid.Column="0" Background="Aquamarine">
            <TextBlock FontSize="50" Text="{Binding Name}"
                       HorizontalAlignment="Center" VerticalAlignment="Center"/>
          </Border>
          <Border Grid.Column="1" Background="Coral">
            <TextBlock FontSize="50" Text="{Binding Name}"
                       HorizontalAlignment="Center" VerticalAlignment="Center"/>
          </Border>
        </Grid>
      </ScrollViewer>
    </DataTemplate>
  </UserControl.Resources>

  <Grid RowDefinitions="* Auto Auto" ColumnDefinitions="* *">
    <ListBox Grid.Row="0" Grid.Column="0" ItemsSource="{Binding Tabs}" ItemTemplate="{StaticResource Template}"/>
    <TabControl Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Tabs}" ContentTemplate="{StaticResource Template}"/>

    <TextBlock Grid.Row="2" Grid.Column="0" Margin="5">
      Number of template instantiations: <Run Text="{Binding TemplateInstances}"/>
    </TextBlock>
  </Grid>
</UserControl>
using System;
using Avalonia.Controls;
using TabControlTest.ViewModels;

namespace TabControlTest.Views;

public partial class MainView : UserControl {
    public MainView() {
        InitializeComponent();
    }

    private void OnTemplateInstantiated(object? sender, EventArgs eventArgs) {
        (DataContext as MainViewModel)!.TemplateInstances++;
    }
}
using CommunityToolkit.Mvvm.ComponentModel;

namespace TabControlTest.ViewModels;

public partial class MainViewModel : ViewModelBase {
    [ObservableProperty]
    private int _templateInstances;

    public TabViewModel[] Tabs { get; } = {
        new("Tab 1"),
        new("Tab 2")
    };
}

public record TabViewModel(string Name) {
    public override string ToString() => Name;
}

Environment

stevemonaco commented 9 months ago

The setup here is strange. You should avoid storing DataTemplates in Resources. I'm not sure what the designed behavior here is, but it fixed the issue for me locally. There's a separate place for DataTemplates:

<UserControl.DataTemplates>
    <DataTemplate x:DataType="vm:TabViewModel">

Your TabControl then goes to:

<TabControl Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Tabs}" />

The lookup/creation will be automatic. One other issue, if you're using a ViewLocator, is that record types won't properly go through it (by default) as it can't derive from ViewModelBase.

AtomCrafty commented 9 months ago

I deliberately did not declare the template in UserControl.DataTemplates because that will screw up the tab headers if no ItemTemplate is set. The issue I'm describing occurs either way, no matter whether the template is looked up from an ancestor's DataTemplates list, specified explicitly as a static resource, or declared inline within a <TabControl.ContentTemplate> tag.

The template lookup works without issue in all of these cases. What the TabControl does with the template once it's resolved is what's causing problems. The example is not using a view locator and there is no reason for TabViewModel to derive from ViewModelBase since it has no mutable properties.

AtomCrafty commented 9 months ago

Just found this discussion about a similar issue. Or actually almost the exact opposite. Whereas I take issue with controls being reused when switching between tabs of the same data type, they had trouble because switching between tabs of different data types re-instantiated the templates every time. So basically the scenario I warned about in my "expected behavior" section.

Funnily enough, their solution would probably solve my issue as well. I'll still leave this open since I'm of the opinion that this should be the default behavior for tab controls in the majority of use cases. Materializing all tab contents ahead of time but only ever showing one of them would solve both of these issues. But ultimately that's not my call to make.

timunie commented 9 months ago

@AtomCrafty i haven't tried it yet but probably you could implement your own IDataTemplate and handle the creation to your needs. Im Avalonia.Samples you can find out how to do it.

AtomCrafty commented 9 months ago

You're half right. It's possible to force re-instantiation of the template by implementing a custom data template type that doesn't implement IRecyclingDataTemplate, as that will prevent the content presenter from reusing the existing child here.

But that won't make tabs persistent, meaning that every time you switch to a tab, its controls will be recreated and scroll positions reset. That behavior is a direct consequence of how the TabControl is implemented, since it simply does not preserve the controls for inactive tabs.

I've since switched to the solution from the linked discussion (discard TabControl and instead use TabStrip + ItemsControl with a Panel container) and it works like a charm. I still feel like persistent tabs would be a more useful default behavior for the TabControl, but I also understand if you close this as "by design."

stevemonaco commented 9 months ago

I also hit a roadblock in trying to write an attached behavior for this. TabControl.SelectedContent has an internal setter. So even if I could cache views by subscribing SelectionChanged, I can't do anything with it. Should probably be documented that this scenario isn't possible if there's an intentional design limitation.

KrzysztofDusko commented 9 months ago

this sitution with recreating tab content can be treated as "by desing". But note there are same group of people comes from WinForms not only WPF. My opinion is this behavior should be easy cotiomizable simply becouse that menu avalonia users coms from winforms..

This s not my issue but maybe can be handled here. One of method to make state of tab content is to use onw cache in ViewLocator like this:

    public class ViewLocator : IDataTemplate
    {
        private Dictionary<object, Control> _cache = new Dictionary<object, Control>();
        public Control? Build(object? data)
        {
            if (data is null)
                return null;

            var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
            var type = Type.GetType(name);

            if (type != null)
            {
                if (_cache.TryGetValue(data, out var res))
                {
                    return res;
                }
                var control = (Control)Activator.CreateInstance(type)!;
                control.DataContext = data;
                _cache[control.DataContext] = control;
                return control;
            }

            return new TextBlock { Text = "Not Found: " + name };
        }

        public bool Match(object? data)
        {
            return data is ViewModelBase;
        }
    }

and it works - for example ListBox Vertical scrolling is preserved. But Data grid Vertical scroll IS NOT.. why ? Its data grid inner issue ? But.. ItemsControl + IsVisible sill works for both ListBox and Data grid..

    <TabControl Margin="5"
                ItemsSource="{Binding Tabs}">
        <TabControl.ItemTemplate>
            <DataTemplate x:DataType="vm:TabDataViewModel">
                <TextBlock Text="{Binding HeaderText}" />
            </DataTemplate>
        </TabControl.ItemTemplate>
    </TabControl>

image

change tab and come back to first :

image

AvaloniaTabsTest.zip

AtomCrafty commented 9 months ago

@KrzysztofDusko my best guess is that the data grid somehow resets its scroll position when it's attached or detached from one of the control trees. The IsVisible solution doesn't have that issue since all controls are always attached, just not always visible.

Aside from that, I would not recommend the template caching approach in most cases, since the controls will never be deleted. Once a tab view model is removed from the bound collection, the corresponding control hierarchy should be properly freed, which will never happen since your view locator will keep them alive forever.