Search code examples
wpf.net-coretabcontrolcontroltemplate

How to fix this behavior in a WPF TabControl?


I have a WPF .NET Core 5.0 project which has a TabControl that I can add a new TabItem by clicking on a Button represented as [+].

MainWindow.cs:

using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfApp1
{
    public partial class MainWindow : Window
    {
        int TabIndex = 1;
        ObservableCollection<TabVM> Tabs = 
            new ObservableCollection<TabVM>();
        public MainWindow()
        {
            InitializeComponent();
            var tab1 = new TabVM()
            {
                Header = $"Tab {TabIndex}"
            };
            Tabs.Add(tab1);
            AddNewPlusButton();

            MyTabControl.ItemsSource = Tabs;
            MyTabControl.SelectionChanged += 
                MyTabControl_SelectionChanged;

        }

        private void MyTabControl_SelectionChanged(object sender, 
            SelectionChangedEventArgs e)
        {
            if (e.Source is TabControl)
            {
                var pos = MyTabControl.SelectedIndex;
                if (pos != 0 && pos == Tabs.Count - 1) //last tab
                {
                    var tab = Tabs.Last();
                    ConvertPlusToNewTab(tab);
                    AddNewPlusButton();
                }
            }
        }

        void ConvertPlusToNewTab(TabVM tab)
        {
            //Do things to make it a new tab.
            TabIndex++;
            tab.Header = $"Tab {TabIndex}";
            tab.IsPlaceholder = false;
        }

        void AddNewPlusButton()
        {
            var plusTab = new TabVM()
            {
                Header = "+",
                IsPlaceholder = true
            };
            Tabs.Add(plusTab);
        }

        class TabVM : INotifyPropertyChanged
        {
            string _Header;
            public string Header
            {
                get => _Header;
                set
                {
                    _Header = value;
                    OnPropertyChanged();
                }
            }

            bool _IsPlaceholder = false;
            public bool IsPlaceholder
            {
                get => _IsPlaceholder;
                set
                {
                    _IsPlaceholder = value;
                    OnPropertyChanged();
                }
            }

            public event PropertyChangedEventHandler PropertyChanged;
            void OnPropertyChanged([CallerMemberName] string property = "")
            {
                PropertyChanged?.Invoke(this, 
                    new PropertyChangedEventArgs(property));
            }
        }

        private void OnTabCloseClick(object sender, RoutedEventArgs e)
        {
            var tab = (sender as Button).DataContext as TabVM;
            if (Tabs.Count > 2)
            {
                var index = Tabs.IndexOf(tab);
                if (index == Tabs.Count - 2)//last tab before [+]
                {
                    MyTabControl.SelectedIndex--;
                }
                Tabs.RemoveAt(index);
            }
        }
    }
}

XAML:

<TabControl x:Name="MyTabControl">
    <TabControl.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding Header, Mode=OneWay}"/>
                <Button Click="OnTabCloseClick" 
                        Width="20" 
                        Padding="0" 
                        Margin="8 0 0 0" 
                        Content="X">
                    <Button.Style>
                        <Style TargetType="Button" 
                               x:Key="CloseButtonStyle">
                            <Setter Property="Visibility" 
                                    Value="Visible"/>
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding IsPlaceholder}" 
                                             Value="True">
                                    <Setter Property="Visibility" 
                                            Value="Collapsed"/>
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </Button.Style>
                </Button>
            </StackPanel>
        </DataTemplate>
    </TabControl.ItemTemplate>    
    <TabControl.ContentTemplate>
        <DataTemplate>
            <ContentControl>
                <ContentControl.Resources>
                    <ContentControl x:Key="TabContentTemplate">
                        <Grid>
                            <Label Content="Enter your text here:" 
                                    HorizontalAlignment="Left" 
                                    Margin="30,101,0,0" 
                                    VerticalAlignment="Top" 
                                    Width="298" 
                                    FontSize="18"/>
                            <RichTextBox HorizontalAlignment="Left"
                                            Height="191" 
                                            Margin="8,135,0,0" 
                                            VerticalAlignment="Top" 
                                            Width="330">
                                <FlowDocument/>
                            </RichTextBox>
                        </Grid>
                    </ContentControl>
                </ContentControl.Resources>
                <ContentControl.Style>
                    <Style TargetType="ContentControl">
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding IsPlaceholder}" 
                                            Value="True">
                                <Setter Property="Content"
                                        Value="{x:Null}"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding IsPlaceholder}" 
                                            Value="False">
                                <Setter Property="Content"
                                        Value="{StaticResource TabContentTemplate}"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </ContentControl.Style>
            </ContentControl>
        </DataTemplate>
    </TabControl.ContentTemplate>
</TabControl>

In the XAML I set a Grid in the ContentControl TabContentTemplate, but any control of the Grid like that RichTextBox if I change its text so it reflects to the RichTextBox of all tabs. How to add a Grid there without reflecting its control values to the other tabs?

The gif below shows the problem, whatever I type in the RichTextBox reflects in the other tabs.

enter image description here


Solution

  • A simplified example.

    Collection item:

    using Simplified;
    
    namespace AddTabItem
    {
        public class TabVm : BaseInpc
        {
            string _header;
            bool _isPlaceholder;
            private string _text;
    
            public string Header { get => _header; set => Set(ref _header, value); }
    
            public bool IsPlaceholder { get => _isPlaceholder; set => Set(ref _isPlaceholder, value); }
    
            public string Text { get => _text; set => Set(ref _text, value); }
        }
    }
    

    ViewModel:

    using Simplified;
    using System.Collections.ObjectModel;
    
    namespace AddTabItem
    {
        public class TabsCollectionViewModel : BaseInpc
        {
            private TabVm _selectedTab;
            private RelayCommand _addNewTabCommand;
            private RelayCommand _removeTabCommand;
    
            public ObservableCollection<TabVm> Tabs { get; } = new ObservableCollection<TabVm>();
    
            public TabVm SelectedTab { get => _selectedTab; set => Set(ref _selectedTab, value); }
    
            public RelayCommand AddNewTabCommand => _addNewTabCommand
                ?? (_addNewTabCommand = new RelayCommand(
                    () =>
                    {
                        TabVm tab = new TabVm() { Header = $"Tab{Tabs.Count}" };
                        Tabs.Add(tab);
                        SelectedTab = tab;
                    }));
    
            public RelayCommand RemoveTabCommand => _removeTabCommand
                ?? (_removeTabCommand = new RelayCommand<TabVm>(
                    tab =>
                    {
                        int index = Tabs.IndexOf(tab);
                        if (index >= 0)
                        {
                            Tabs.RemoveAt(index);
                            if (index >= Tabs.Count)
                                index = Tabs.Count - 1;
                            if (index < 0)
                                SelectedTab = null;
                            else
                                SelectedTab = Tabs[index];
                        }
                    },
                    tab => Tabs.Contains(tab)));
        }
    }
    

    Window XAML:

    <Window x:Class="AddTabItem.AddTabExamleWindow"
            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:AddTabItem"
            mc:Ignorable="d"
            Title="AddTabExamleWindow" Height="450" Width="800"
            DataContext="{DynamicResource viewModel}">
        <FrameworkElement.Resources>
            <local:TabsCollectionViewModel x:Key="viewModel"/>
            <local:TabVm x:Key="newTab"/>
            <CollectionViewSource x:Key="tabsCollectionView"
                                  Source="{Binding Tabs}"/>
            <CompositeCollection x:Key="tabs">
                <CollectionContainer Collection="{Binding Mode=OneWay, Source={StaticResource tabsCollectionView}}"/>
                <StaticResource ResourceKey="newTab"/>
            </CompositeCollection>
            <DataTemplate x:Key="TabItem.HeaderTemplate"
                          DataType="{x:Type local:TabVm}">
                <Grid>
                    <StackPanel Orientation="Horizontal">
                        <Panel.Style>
                            <Style TargetType="StackPanel">
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding}" Value="{StaticResource newTab}">
                                        <Setter Property="Visibility" Value="Collapsed"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Panel.Style>
                        <TextBlock Text="{Binding Header}"
                                   Margin="2"/>
                        <Button Content="❌" FontWeight="Bold" Foreground="Red"
                                Command="{Binding RemoveTabCommand, Mode=OneWay, Source={StaticResource viewModel}}"
                                CommandParameter="{Binding Mode=OneWay}"/>
                    </StackPanel>
                    <Button Content="✚" FontWeight="Bold" Foreground="Green"
                            Command="{Binding AddNewTabCommand, Mode=OneWay, Source={StaticResource viewModel}}">
                        <Button.Style>
                            <Style TargetType="Button">
                                <Setter Property="Visibility" Value="Collapsed"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding}" Value="{StaticResource newTab}">
                                        <Setter Property="Visibility" Value="Visible"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Button.Style>
                    </Button>
                </Grid>
            </DataTemplate>
            <DataTemplate x:Key="TabItem.ContentTemplate"
                          DataType="{x:Type local:TabVm}">
                <TextBox Text="{Binding Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
                    <TextBox.Style>
                        <Style TargetType="TextBox">
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding}" Value="{StaticResource newTab}">
                                    <Setter Property="Visibility" Value="Collapsed"/>
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </TextBox.Style>
                </TextBox>
            </DataTemplate>
        </FrameworkElement.Resources>
        <Grid>
            <TabControl ItemsSource="{DynamicResource tabs}"
                        ItemTemplate="{DynamicResource TabItem.HeaderTemplate}"
                        ContentTemplate="{DynamicResource TabItem.ContentTemplate}"
                        SelectedItem="{Binding SelectedTab, Mode=TwoWay}"/>
        </Grid>
    </Window>
    

    To eliminate ambiguities, I give the codes of the classes used in the example: BaseInpc:

    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    
    namespace Simplified
    {
        /// <summary>Base class with implementation of the <see cref="INotifyPropertyChanged"/> interface.</summary>
        public abstract class BaseInpc : INotifyPropertyChanged
        {
            /// <inheritdoc cref="INotifyPropertyChanged"/>
            public event PropertyChangedEventHandler PropertyChanged;
    
            /// <summary>The protected method for raising the event <see cref = "PropertyChanged"/>.</summary>
            /// <param name="propertyName">The name of the changed property.
            /// If the value is not specified, the name of the method in which the call was made is used.</param>
            protected void RaisePropertyChanged([CallerMemberName] string propertyName = null)
            {
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
    
            /// <summary> Protected method for assigning a value to a field and raising 
            /// an event <see cref = "PropertyChanged" />. </summary>
            /// <typeparam name = "T"> The type of the field and assigned value. </typeparam>
            /// <param name = "propertyFiled"> Field reference. </param>
            /// <param name = "newValue"> The value to assign. </param>
            /// <param name = "propertyName"> The name of the changed property.
            /// If no value is specified, then the name of the method 
            /// in which the call was made is used. </param>
            /// <returns>Returns <see langword="true"/> if the value being assigned
            /// was not equal to the value of the field and
            /// therefore the value of the field was changed.</returns>
            /// <remarks> The method is intended for use in the property setter. <br/>
            /// To check for changes,
            /// used the <see cref = "object.Equals (object, object)" /> method.
            /// If the assigned value is not equivalent to the field value,
            /// then it is assigned to the field. <br/>
            /// After the assignment, an event is created <see cref = "PropertyChanged" />
            /// by calling the method <see cref = "RaisePropertyChanged (string)" />
            /// passing the parameter <paramref name = "propertyName" />. <br/>
            /// After the event is created,
            /// the <see cref = "OnPropertyChanged (string, object, object)" />
            /// method is called. </remarks>
            protected bool Set<T>(ref T propertyFiled, T newValue, [CallerMemberName] string propertyName = null)
            {
                bool notEquals = !object.Equals(propertyFiled, newValue);
                if (notEquals)
                {
                    T oldValue = propertyFiled;
                    propertyFiled = newValue;
                    RaisePropertyChanged(propertyName);
    
                    OnPropertyChanged(propertyName, oldValue, newValue);
                }
                return notEquals;
            }
    
            /// <summary> The protected virtual method is called after the property has been assigned a value and after the event is raised <see cref = "PropertyChanged" />. </summary>
            /// <param name = "propertyName"> The name of the changed property. </param>
            /// <param name = "oldValue"> The old value of the property. </param>
            /// <param name = "newValue"> The new value of the property. </param>
            /// <remarks> Can be overridden in derived classes to respond to property value changes. <br/>
            /// It is recommended to call the base method as the first operator in the overridden method. <br/>
            /// If the overridden method does not call the base class, then an unwanted change in the base class logic is possible. </remarks>
            protected virtual void OnPropertyChanged(string propertyName, object oldValue, object newValue) { }
        }
    }
    

    RelayCommand:

    using System;
    using System.Windows;
    using System.Windows.Input;
    using System.Windows.Threading;
    
    namespace Simplified
    {
        #region Delegates for WPF Command Methods
        public delegate void ExecuteHandler(object parameter);
        public delegate bool CanExecuteHandler(object parameter);
        #endregion
    
        #region Класс команд - RelayCommand
        /// <summary> A class that implements <see cref = "ICommand" />. <br/>
        /// Implementation taken from <see href = "https://www.cyberforum.ru/wpf-silverlight/thread2390714-page4.html#post13535649" />
        /// and added a constructor for methods without a parameter.</summary>
        public class RelayCommand : ICommand
        {
            private readonly CanExecuteHandler canExecute;
            private readonly ExecuteHandler execute;
            private readonly EventHandler requerySuggested;
    
            /// <inheritdoc cref="ICommand.CanExecuteChanged"/>
            public event EventHandler CanExecuteChanged;
    
            /// <summary> Command constructor. </summary>
            /// <param name = "execute"> Command method to execute. </param>
            /// <param name = "canExecute"> Method that returns the state of the command. </param>
            public RelayCommand(ExecuteHandler execute, CanExecuteHandler canExecute = null)
               : this()
            {
                this.execute = execute ?? throw new ArgumentNullException(nameof(execute));
                this.canExecute = canExecute;
    
                requerySuggested = (o, e) => Invalidate();
                CommandManager.RequerySuggested += requerySuggested;
            }
    
            /// <inheritdoc cref="RelayCommand(ExecuteHandler, CanExecuteHandler)"/>
            public RelayCommand(Action execute, Func<bool> canExecute = null)
                    : this
                    (
                          p => execute(),
                          p => canExecute?.Invoke() ?? true
                    )
            { }
    
            private RelayCommand()
                => dispatcher = Application.Current.Dispatcher;
    
            private readonly Dispatcher dispatcher;
    
            /// <summary> The method that raises the event <see cref = "CanExecuteChanged" />. </summary>
            public void RaiseCanExecuteChanged()
            {
                if (dispatcher.CheckAccess())
                    Invalidate();
                else
                    dispatcher.BeginInvoke((Action)Invalidate);
            }
            private void Invalidate()
                => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    
            /// <inheritdoc cref="ICommand.CanExecute(object)"/>
            public bool CanExecute(object parameter) => canExecute?.Invoke(parameter) ?? true;
    
            /// <inheritdoc cref="ICommand.Execute(object)"/>
            public void Execute(object parameter) => execute?.Invoke(parameter);
        }
        #endregion
    }
    

    RelayCommand<T>:

    using System;
    using System.Windows.Input;
    namespace Simplified
    {
        #region Delegates for WPF Command Methods
        public delegate void ExecuteHandler<T>(T parameter);
        public delegate bool CanExecuteHandler<T>(T parameter);
        #endregion
    
        /// <summary> RelayCommand implementation for generic parameter methods. </summary>
        /// <typeparam name = "T"> Method parameter type. </typeparam>  
        public class RelayCommand<T> : RelayCommand
        {
            /// <inheritdoc cref="RelayCommand(ExecuteHandler, CanExecuteHandler)"/>
            public RelayCommand(ExecuteHandler<T> execute, CanExecuteHandler<T> canExecute = null)
                : base
                (
                      p =>
                      {
                          if (p is T t)
                              execute(t);
                      },
                      p => (p is T t) && (canExecute?.Invoke(t) ?? true)
                )
            { }
        }
    }