Let’s go through implementing several scenarios for the TabControl with quick, short explanations:
1. How to databind the TabControl?
2. How to enable drag-rearrange of items?
3. How to animate rearranging items?
4. How to add a Close Button to the TabItems using a Command?
5. How to animate content changes?
6. How to add a “New Tab” button when databound?
7. How to animate item add / removes?
Here is the end result:
And the demo project with everything included:
And of course the details, in short -
The TabControl is a standard ItemsControl and can be databound to any IEnumerable. It is preferable to bind to an observable collection since any changes in it will be reflected by the TabControl.
In this case we are going to bind to a simple Document class:
public
class
Document
{
public
string
Title {
get
;
set
; }
public
string
Content {
get
;
set
; }
}
The TabControl will use its ItemTemplate for the Headers of the TabItems and its ContentTemplate for their content. We can simply define them as:
<
DataTemplate
x:Name
=
"content"
>
<
Grid
>
<
TextBox
Text
=
"{Binding Content, Mode=TwoWay}"
Margin
=
"5"
/>
</
Grid
>
</
DataTemplate
>
and
<
DataTemplate
>
<
Grid
>
<
TextBlock
Text
=
"{Binding Title}"
/>
</
Grid
>
</
DataTemplate
>
A small note: When a control is databound we refer to our business objects as its “items” and to the generated TabItems as “item containers” or just “containers”.
This can be enabled by setting a property:
<
telerik:RadTabControl
x:Name
=
"tabControl"
AllowDragReorder
=
"True"
/>
Every ItemsControl does not arrange its containers but uses a panel for this purpose. This panel can be changed by setting an ItemsPanel. There we can make use of Silverlight 4’s FluidLayout:
<
telerik:RadTabControl.ItemsPanel
>
<
ItemsPanelTemplate
>
<
tabCotnrol:TabWrapPanel
>
<
i:Interaction.Behaviors
>
<
in:FluidMoveBehavior
AppliesTo
=
"Children"
InitialTag
=
"DataContext"
Tag
=
"DataContext"
Duration
=
"0:0:0.2"
>
<
in:FluidMoveBehavior.EaseX
>
<
CubicEase
/>
</
in:FluidMoveBehavior.EaseX
>
</
in:FluidMoveBehavior
>
</
i:Interaction.Behaviors
>
</
tabCotnrol:TabWrapPanel
>
</
ItemsPanelTemplate
>
</
telerik:RadTabControl.ItemsPanel
>
Please note that the fluid layout behavior will normally compare the containers of items to but when we rearrange them we need to add & remove an item. This means that a new container will be generated for it. We need to tell the FluidLayout behavior to compare the items (Tag=”DataContext”) and not containers (Tag=”Element”).
Adding an ‘x’ button in the ItemTemplate should be straightforward. Then comes the question how to handle the Click event on the button.
Generally the Click can indeed be handled if the DataTemplate is in the resources of a UserControl for example. This way the DataTemplate becomes stuck to the particular user control and cannot be easily moved and shared - not at all the nature of DataTemplates.
One way around this is to use the Telerik’s RadButton and its routed Click event. Since it is a routed event, it can be handled at any of the visual parents of the button, e.g. the TabControl itself. This not bad be we can go one better – use a command.
You may have seen examples of DelegateCommands - they simply wrap two methods and must be exposed on the ViewModel.
In this case we are going to use Telerik’s Routed Commands which work very much like the commands in WPF. They are slightly more abstract but offer greater flexibility. Which you use is up to you.
We define the commands like so:
And we add commands bindings in xaml, attaching them to the MainPage user control:
<
telerik:CommandManager.CommandBindings
>
<
telerik:CommandBindingCollection
>
<
telerik:CommandBinding
Command
=
"local:DocumentCommands.AddItem"
CanExecute
=
"OnAddCanExecute"
Executed
=
"OnAddExecute"
/>
<
telerik:CommandBinding
Command
=
"local:DocumentCommands.CloseItem"
CanExecute
=
"OnCloseCanExecute"
Executed
=
"OnCloseExecute"
/>
</
telerik:CommandBindingCollection
>
</
telerik:CommandManager.CommandBindings
>
Note that the command bindings can be added in code as well. They are just a shortcut for handling the CommandManager.CanExecuteEvent and CommandManager.ExecutedEvent.
We handle the removal of items like so:
private
void
OnCloseCanExecute(
object
sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute =
true
;
e.Handled =
true
;
}
private
void
OnCloseExecute(
object
sender, ExecutedRoutedEventArgs e)
{
this
.source.Remove(e.Parameter);
}
We can use Telerik’s TransitioningContentControl for this purpose. It provides several built-in content transitions. We can replace the TabControl’s ContentPresenter with this control by editing its Template and replacing the “ContentElement” with this:
<
telerik:RadTransitionControl
x:Name
=
"ContentElement"
Content
=
"{TemplateBinding SelectedContent}"
Duration
=
"0:0:01"
IsTabStop
=
"False"
ContentTemplate
=
"{TemplateBinding SelectedContentTemplate}"
>
<
telerik:RadTransitionControl.Easing
>
<
CubicEase
EasingMode
=
"EaseOut"
/>
</
telerik:RadTransitionControl.Easing
>
<
telerik:RadTransitionControl.Transition
>
<
telerik:SlideAndZoomTransition
SlideDirection
=
"RightToLeft"
MinAlpha
=
"1"
MinZoom
=
"1"
/>
</
telerik:RadTransitionControl.Transition
>
</
telerik:RadTransitionControl
>
Note that there are other available transitions as well, they can be previewed in the demos.
In this case the SlideAndZoomTransition as a simple slide by setting its MinZoom and MinAlpha properties to 1.
This animates the content changes always in a single direction.
There are two tricky bits here – when to handle the selection change and how to get to the Transitioning control.
We can use the PreviewSelectionChanged, but when we are not implementing an instance-specific handler, we can use Class Routed Event Handlers. They are called every time a particular event passes though an instance of the control. They are registered statically and in essence can be used for extending the functionality of all controls of a given type.
In our case:
EventManager.RegisterClassHandler(
typeof
(RadTabControl),
RadTabControl.PreviewSelectionChangedEvent,
new
Telerik.Windows.Controls.SelectionChangedEventHandler(OnTabSelectionSetSlideDirection));
and the handler itself:
private
static
void
OnTabSelectionSetSlideDirection(
object
sender, Telerik.Windows.Controls.SelectionChangedEventArgs e)
{
var tabControl = sender
as
RadTabControl;
if
(VisualTreeHelper.GetChildrenCount(tabControl) > 0
&& e.RemovedItems.Count > 0
&& e.AddedItems.Count > 0)
{
var firstChild = VisualTreeHelper.GetChild(tabControl, 0)
as
FrameworkElement;
var transitioningControl = firstChild.FindName(
"ContentElement"
)
as
RadTransitionControl;
if
(transitioningControl !=
null
)
{
var slideTransition = transitioningControl.Transition
as
SlideAndZoomTransition;
if
(slideTransition !=
null
)
{
var oldIndex = tabControl.Items.IndexOf(e.RemovedItems[0]);
var newIndex = tabControl.Items.IndexOf(e.AddedItems[0]);
slideTransition.SlideDirection = oldIndex < newIndex ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
}
}
}
}
Note how we can retrieve a template element from outside a control. The FindName method works looks for an element in the current NameScope and the first visual child of a control comes from its ControlTemplate and has the namescope we need.
It is not a problem to make a TabItem to look like an “Add” button:
<
DataTemplate
>
<
Image
Stretch
=
"None"
Source
=
"Images/Add.png"
/>
</
DataTemplate
>
Adding a tab after all other containers will be difficult it the “Add” button is not an item itself. But the alternative this will mean to include an alien object in our ItemsSource collection and make sure that it always stays a last element.
An easy way to achieve this is to have a wrapper for the ItemsSource that will pretend to be a collection but it will just delegate all collection method to the ItemsSource – it will just append a special item to end of the collection and it will make sure it stays there (abridged):
public
sealed
class
AddTabCollection : IList, INotifyCollectionChanged
{
private
IList originalCollection;
private
object
customAddTabItem;
public
AddTabCollection(IList originalCollection,
object
addTabItem)
{
Contract.Requires<ArgumentNullException>(originalCollection !=
null
);
this
.originalCollection = originalCollection;
this
.customAddTabItem = addTabItem;
var observable = originalCollection
as
INotifyCollectionChanged;
if
(observable !=
null
)
{
observable.CollectionChanged +=
this
.OnOriginalCollectionChanged;
}
}
public
int
Count
{
get
{
return
this
.originalCollection.Count + 1; }
}
public
IEnumerator GetEnumerator()
{
foreach
(var item
in
this
.originalCollection)
{
yield
return
item;
}
yield
return
this
.customAddTabItem ?? AddTabItemValue;
}
}
Then we can use it like so:
var wrappedSource =
new
AddTabCollection(
this
.source, DocumentCommands.AddItem);
tabControl.ItemsSource = wrappedSource;
If we have ViewModel for the page, this may mean creating a second ItemsSource property that just wraps the original.
This way the “add” item will always be there but we need to make it look different as well. Here we can use a custom template selector that will pick between two templates based on whether it is an “Add” button:
public
class
AddTabTemplateSelector : DataTemplateSelector
{
public
DataTemplate DefaultTemplate {
get
;
set
; }
public
DataTemplate AddTabTemplate {
get
;
set
; }
public
object
AddTabItem {
get
;
set
; }
public
override
DataTemplate SelectTemplate(
object
item, DependencyObject container)
{
if
(item == (
this
.AddTabItem ?? AddTabCollection.AddTabItemValue))
{
return
this
.AddTabTemplate;
}
else
{
return
this
.DefaultTemplate;
}
}
}
Here is how we use it in xaml:
<
local:AddTabTemplateSelector
x:Name
=
"selector"
>
<
local:AddTabTemplateSelector.AddTabTemplate
>
<
DataTemplate
>
<
Image
Stretch
=
"None"
Source
=
"Images/Add.png"
/>
</
DataTemplate
>
</
local:AddTabTemplateSelector.AddTabTemplate
>
<
local:AddTabTemplateSelector.DefaultTemplate
>
<
DataTemplate
>
<
TextBlock
Text
=
"{Binding Title}"
/>
</
DataTemplate
>
</
local:AddTabTemplateSelector.DefaultTemplate
>
</
local:AddTabTemplateSelector
>
We then need to make sure that the “add” tab is never selected. We can do this by handling the PreviewSelectionChanged event. We will go one better and use commands. Thus if the item is a command, we will execute it and cancel the selection:
private
static
void
OnTabSelectionRunCommand(
object
sender,
Telerik.Windows.Controls.SelectionChangedEventArgs e)
{
var command = e.AddedItems.OfType<ICommand>().FirstOrDefault();
if
(command !=
null
)
{
e.Handled =
true
;
if
(command.CanExecute(
null
))
{
command.Execute(
null
);
}
}
}
Then we will pass the command to our wrapper and tell the TemplateSelector that this is our “Add Tab”:
(
this
.Resources[
"selector"
]
as
AddTabTemplateSelector).AddTabItem = wrappedSource.AddTabItem;
Here is how we handle the command itself:
private
void
OnAddCanExecute(
object
sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute =
true
;
}
private
void
OnAddExecute(
object
sender, ExecutedRoutedEventArgs e)
{
var newDocument =
new
Document { Title =
"Untitled.txt"
, Content =
"New Document..."
};
Dispatcher.BeginInvoke(() =>
{
source.Add(newDocument);
tabControl.SelectedItem = newDocument;
tabControl.ScrollIntoView(tabControl.Items[tabControl.Items.Count - 1]);
});
}
Note that the adding happens in a Dispatcher. This means that the given action will not be executed in the same callstack. This helps in cases when the ItemsSource needs to be changed while it is in the process of changing.
E.g. the user removes the last standard tab. Immediately the TabControl selects the last remaining item, the “Add” item. This fires the command and a new item is added. This will raise an exception since an observable collection cannot be changed in its CollectionChanged handler.
One last thing to do is make sure that our other changes work as well. In particular the DragDrop can be a problem since the “Add” item should not be rearranged / replaced.
A class handler can solve this for every instance:
private
static
void
OnTabItemDropQuery(
object
sender, DragDropQueryEventArgs e)
{
var tabItem = sender
as
RadTabItem;
var tab = Telerik.Windows.Controls.ItemsControl.ItemsControlFromItemContainer(tabItem);
if
(tab ==
null
)
{
return
;
}
var itemsSource = tab.ItemsSource
as
AddTabCollection;
if
(itemsSource !=
null
)
{
if
(itemsSource.AddTabItem == tabItem.DataContext)
{
e.QueryResult =
false
;
e.Handled =
true
;
}
}
}
Silverlight 4 comes with a rarely used feature – LayoutStates. They allow us to animate items in and out of view when they are added or removed in an items control.
The states are available for any container but they do not quite appear in Blend so you just need to know that they exist.
To change the visual states we need to edit the TabItem’s Control Template and assign the style to TabControl’s ItemContainerStyle. The only change in the control template is the addition of the three visual states:
<
VisualStateGroup
x:Name
=
"LayoutStates"
>
<
VisualStateGroup.Transitions
>
<
VisualTransition
GeneratedDuration
=
"0:0:0.2"
>
<
VisualTransition.GeneratedEasingFunction
>
<
CubicEase
EasingMode
=
"EaseOut"
/>
</
VisualTransition.GeneratedEasingFunction
>
</
VisualTransition
>
</
VisualStateGroup.Transitions
>
<
VisualState
x:Name
=
"BeforeUnloaded"
>
<
Storyboard
>
<
DoubleAnimation
Duration
=
"0"
To
=
"23"
Storyboard.TargetProperty
=
"(UIElement.RenderTransform).(CompositeTransform.TranslateY)"
Storyboard.TargetName
=
"wrapper"
/>
</
Storyboard
>
</
VisualState
>
<
VisualState
x:Name
=
"AfterLoaded"
/>
<
VisualState
x:Name
=
"BeforeLoaded"
>
<
Storyboard
>
<
DoubleAnimation
Duration
=
"0"
To
=
"24"
Storyboard.TargetProperty
=
"(UIElement.RenderTransform).(CompositeTransform.TranslateY)"
Storyboard.TargetName
=
"wrapper"
/>
</
Storyboard
>
</
VisualState
>
</
VisualStateGroup
>
At this point you may notice that the LayoutStates clash with the FluidLayout during drag rearrange since they both are trying to animate the tab items in a similar way. To work around this we can use a custom VisualStateManager that will not play state transitions in the TabItems when DragDrop is active. We define the state manager like so:
public
class
CustomTabVisualStateManager : VisualStateManager
{
protected
override
bool
GoToStateCore(
Control control,
FrameworkElement templateRoot,
string
stateName,
VisualStateGroup group,
VisualState state,
bool
useTransitions)
{
var useTransitionsOverride = !RadDragAndDropManager.IsDragging && useTransitions;
return
base
.GoToStateCore(control, templateRoot, stateName, group, state, useTransitionsOverride);
}
}
Then we need to assign it xaml:
<
VisualStateManager.CustomVisualStateManager
>
<
local:CustomTabVisualStateManager
/>
</
VisualStateManager.CustomVisualStateManager
>
Hopefully you will be able to use some of these ideas in your projects.
Hi, I'm Kiril and I'm the Product Manager of Telerik UI for Android, Windows Universal and Windows Phone. Feel free to ping me on +KirilStanoev or @KirilStanoev