* This option has some virtues:
1. More 'easy-to-use'.
2. Visual Studio design-time display bug(see 'special notes) - SOLVED.
3. Tested under vs2015
* Special Notes
Introduction
Templating TabControl
raises some issues regarding the behavior and possible improvements of the default window's TabControl
. In this article, I'll share these and show my attempts to improve this control both in look and behavior. I will use 'Graphic-Elements Binding Technique' to achieve 'Cardboard-folder-splitters' look and Two-Mode Toggle UI solution. For convenience, I've placed the default equivalent aside to my templated custom solution.
Background
When the UI of my currently developed app is called for the use of a TabControl
, several drawbacks emerged:
- General look -simple and Winforms like
TabItem
's headers occupy only portion of the available area, and even worse - when overflow - creates a new row/column which is confusing.
- Whenever
TabItem
's content is 'heavy', the TabControl
's UI freezes.
- The previous point has some correlation to the 'unusual manner' of which
TabControl
displays its selected TabItem
's content.
It RE-LOAD the selected TabItem
's content for every selection!
At first glance, this might be considered as a very problematic behavior, actually there are several articles and code samples that aimed to prevent/workaround this exact behavior.
While experimenting on templating the TabControl
, I've actually faced the dilemma of this issue, and surprisingly (after weighing the mentioned alternatives), I've decided to keep it.
The logic for this relays on real time UI scenarios - TabControl
might contain many controls spread within its TabItem
s, as the TabControl
might be only one of many controls in the entire app. Creating and maintaining all the controls inside the TabControl may have substantial burden upon the application while chances that the user will use them might be slim.
In such cases - 'Load on Demand' might be a better approach.
So, rather than altering TabControl
's 'Load on Demand' behavior, I've put my efforts on improving the 'accompanied symptoms' such as UI responsiveness.
Using the Code
The TabControl Templates
The most noticeable feature of this TabControl
is its look, TabItem
s are defined by a shaped border which stretches to consume the entire available area, this is done inside the TabControl
's Style in two places:
TabControl
's Template
TabControl
's ItemContainerStyle
Template
Each of this template consists of a matching Two columned Grid (the first is for the Items-Headers, the second for content).
The TabControl
's Template deals with the right column which holds:
- Content-Presenter - for the selected
TabItem
content:
<ContentPresenter x:Name="content" Opacity="0" ContentSource="SelectedContent"
ContentTemplate="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},
Path=SelectedItem.ContentTemplate}">
<i:Interaction.Triggers >
<i:EventTrigger EventName="Loaded">
<app:actSelectedContentDisplayHandler
ContentTemplate ="{Binding RelativeSource=
{RelativeSource AncestorType=TabControl},Path=SelectedItem.ContentTemplate}"
LoadingTextBlock ="{Binding ElementName=tbLoading}"
/>
</i:EventTrigger>
</i:Interaction.Triggers>
</ContentPresenter>
- Shared border by all
TabItem
s:
<Path Grid.Column="1" Stroke="{TemplateBinding BorderBrush}"
StrokeThickness="{x:Static Member=app:TabListBoxConstants.StrokeThickness}"
Stretch="None" Fill="White">
<Path.Resources>
<app:HeaderContentWidth2ContentTopLinePoint
x:Key="HeaderContentWidth2ContentTopLinePoint"/>
<app:HeaderContentWidth2ContentRTPoint1
x:Key="HeaderContentWidth2ContentRTPoint1"/>
<app:HeaderContentWidth2ContentRTPoint2
x:Key="HeaderContentWidth2ContentRTPoint2"/>
<app:HeaderContentWidthHeight2ContentRightLinePoint
x:Key="HeaderContentWidthHeight2ContentRightLinePoint"/>
<app:HeaderContentWidthHeight2ContentRBPoint1
x:Key="HeaderContentWidthHeight2ContentRBPoint1"/>
<app:HeaderContentWidthHeight2ContentRBPoint2
x:Key="HeaderContentWidthHeight2ContentRBPoint2"/>
<app:HeaderContentHeight2ContentBottomLinePointForContent
x:Key="HeaderContentHeight2ContentBottomLinePointForContent"/>
</Path.Resources>
<Path.Data>
<PathGeometry >
<PathFigure IsClosed="False" >
<PathFigure.StartPoint>
<Point X="{x:Static Member=app:TabListBoxConstants.CornerRadios}"
Y="{x:Static Member=app:TabListBoxConstants.HalfStrokeThickness}"/>
</PathFigure.StartPoint>
<LineSegment Point="{Binding ElementName=gridHeaderContent,
Path=ActualWidth,Converter=
{StaticResource HeaderContentWidth2ContentTopLinePoint}}"/>
<QuadraticBezierSegment
Point1="{Binding ElementName=gridHeaderContent,
Path=ActualWidth,Converter={StaticResource
HeaderContentWidth2ContentRTPoint1}}"
Point2="{Binding ElementName=gridHeaderContent,
Path=ActualWidth,Converter={StaticResource
HeaderContentWidth2ContentRTPoint2}}"
/>
<LineSegment>
<LineSegment.Point >
<MultiBinding Converter="
{StaticResource HeaderContentWidthHeight2ContentRightLinePoint}">
<Binding ElementName="
gridHeaderContent" Path="ActualWidth"/>
<Binding ElementName="
gridHeaderContent" Path="ActualHeight"/>
</MultiBinding>
</LineSegment.Point>
</LineSegment>
<QuadraticBezierSegment >
<QuadraticBezierSegment.Point1>
<MultiBinding Converter="
{StaticResource HeaderContentWidthHeight2ContentRBPoint1}">
<Binding ElementName="
gridHeaderContent" Path="ActualWidth"/>
<Binding ElementName="
gridHeaderContent" Path="ActualHeight"/>
</MultiBinding>
</QuadraticBezierSegment.Point1>
<QuadraticBezierSegment.Point2>
<MultiBinding Converter="
{StaticResource HeaderContentWidthHeight2ContentRBPoint2}">
<Binding ElementName="
gridHeaderContent" Path="ActualWidth"/>
<Binding ElementName="
gridHeaderContent" Path="ActualHeight"/>
</MultiBinding>
</QuadraticBezierSegment.Point2>
</QuadraticBezierSegment>
<LineSegment Point="{Binding ElementName=gridHeaderContent,
Path=ActualHeight,Converter={StaticResource
HeaderContentHeight2ContentBottomLinePointForContent}}"></LineSegment>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
- 'Loading...' message
<TextBlock x:Name="tbLoading" Text="Loading..."
FontSize ="18" HorizontalAlignment ="Center"
VerticalAlignment="Center" Opacity="0"></TextBlock>
The TabControl
's ItemContainerStyle
Template deals with mostly the left column (TabItem
header) but with columns span set to 2 (for reasons I will not get into) which holds :
- Content-Presenter - for the selected
TabItem
Header content:
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Content="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Header}"/>
- Special border-shape for the
TabItem
:
<Path Grid.ColumnSpan="2" x:Name="path"
Stroke="{TemplateBinding BorderBrush}" StrokeThickness="
{x:Static Member=app:TabListBoxConstants.StrokeThickness}" Stretch="None"
Fill="White" >
<Path.Data >
<PathGeometry >
<PathFigure IsClosed="False"
StartPoint="{Binding ElementName=gridHeaderContent,Path=ActualHeight,
Converter={StaticResource HeaderContentHeight2ContentBottomLinePoint}}" >
<QuadraticBezierSegment
Point1="{Binding ElementName=gridHeaderContent,Path=ActualHeight,
Converter={StaticResource HeaderContentHeight2ContentLBPoint1}}"
>
<QuadraticBezierSegment.Point2>
<MultiBinding Converter="{StaticResource
HeaderContentHeightNumOfItemsItemIndex2HeaderContentLBPoint2 }">
<Binding ElementName="gridHeaderContent"
Path="ActualHeight"/>
<Binding RelativeSource="{RelativeSource AncestorType=TabControl}"
Path="Items.Count"/>
<Binding RelativeSource="{RelativeSource AncestorType=TabItem}"
Path="(TabControl.AlternationIndex)"/>
</MultiBinding>
</QuadraticBezierSegment.Point2>
</QuadraticBezierSegment>
...
- Special background-border-shape for the
TabItem
:
<Path x:Name="pathdark" Grid.ColumnSpan="2"
Visibility="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=IsSelected,Converter={StaticResource IsSelected2NotVisibility}}"
Stroke="Gray" StrokeThickness="
{x:Static Member=app:TabListBoxConstants.StrokeThickness}"
Stretch="None" Margin="0" SnapsToDevicePixels="True"
Fill="WhiteSmoke" >
<Path.Data >
<PathGeometry >
<PathFigure IsClosed="False"
StartPoint="{Binding ElementName=gridHeaderContent,
Path=ActualHeight,Converter={StaticResource HeaderContentHeight2ContentBottomLinePoint}}" >
<QuadraticBezierSegment
Point1="{Binding ElementName=gridHeaderContent,
Path=ActualHeight,Converter={StaticResource HeaderContentHeight2ContentLBPoint1}}"
>
<QuadraticBezierSegment.Point2>
<MultiBinding Converter="
{StaticResource HeaderContentHeightNumOfItemsItemIndex2HeaderContentLBPoint2 }">
<Binding ElementName="gridHeaderContent"
Path="ActualHeight"/>
<Binding RelativeSource="{RelativeSource AncestorType=TabControl}"
Path="Items.Count"/>
<Binding RelativeSource="{RelativeSource AncestorType=TabItem}"
Path="(TabControl.AlternationIndex)"/>
</MultiBinding>
</QuadraticBezierSegment.Point2>
</QuadraticBezierSegment>
...
The TabItem
s are visually defined by a silhouette border line.
The line is made by a Path
element which consists of a number of geometry segments of various types.
Each segment's parameters are binded to various variables such as width/height of available area, header width constant, number of items, index of the item, and so on.
Extrapolation of the actual segment's parameter value is done using a set of case-based ValueConverter
s.
For example:
...
<LineSegment >
<LineSegment.Point>
<MultiBinding Converter="
{StaticResource HeaderContentHeightNumOfItemsItemIndex2ContentLeftBottomLinePoint}">
<Binding ElementName="
gridHeaderContent" Path="ActualHeight"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabControl}"
Path="Items.Count"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabItem}"
Path="(TabControl.AlternationIndex)"/>
</MultiBinding>
</LineSegment.Point>
</LineSegment>
<QuadraticBezierSegment>
<QuadraticBezierSegment.Point1 >
<MultiBinding Converter="
{StaticResource HeaderContentHeightNumOfItemsItemIndex2HeaderRBPoint1}">
<Binding ElementName="gridHeaderContent"
Path="ActualHeight"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabControl}"
Path="Items.Count"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabItem}"
Path="(TabControl.AlternationIndex)"/>
</MultiBinding>
</QuadraticBezierSegment.Point1>
<QuadraticBezierSegment.Point2 >
<MultiBinding Converter="
{StaticResource HeaderContentHeightNumOfItemsItemIndex2HeaderRBPoint2}">
<Binding ElementName="gridHeaderContent"
Path="ActualHeight"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabControl}"
Path="Items.Count"/>
<Binding RelativeSource="
{RelativeSource AncestorType=TabItem}"
Path="(TabControl.AlternationIndex)"/>
</MultiBinding>
</QuadraticBezierSegment.Point2>
</QuadraticBezierSegment>
...
* Note
This Template method actually alters the XAML's tree in a way that the Content of the TabItem is placed in a different branch of the XAML's tree.
Subsequently - RoutedEvents
coming from TabItem
's content elements could not be 'caught' at the TabItem
(that has this content) level !
UI Responsiveness while Loading 'heavy' TabItemContent
As mentioned earlier, I have found the TabControl
's 'RE-Load on Demand' behavior quite acceptable, the thing that still troubled me was the UI responsiveness issue especially when loading 'heavy' content.
In such cases, UI becomes frozen even BEFORE TabItem
s redraws as selected, and returns to be active only after all content has been loaded.
I have solved this using a System.Windows.Interactivity TriggerAction
called actSelectedContentDisplayHandler
.
The main idea behind this class is to do the following on each new content which is selected (and needs to be loaded):
- Hide previous selected content
- Show 'Loading...' message
- Perform NOT UI-Thread related loading Async while keeping app's UI active, thus allowing the selected
TabItem
to be redrawn as selected.
- Do UI-Thread related loading while occupying the UI-thread for the minimum necessary time
- When visual elements are loaded - show content & hide the 'Loading...' message
<ContentPresenter x:Name="content" Opacity="0" ContentSource="SelectedContent"
ContentTemplate="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},
Path=SelectedItem.ContentTemplate}">
<i:Interaction.Triggers >
<i:EventTrigger EventName="Loaded">
<app:actSelectedContentDisplayHandler
ContentTemplate ="{Binding RelativeSource={RelativeSource AncestorType=TabControl},
Path=SelectedItem.ContentTemplate}"
LoadingTextBlock ="{Binding ElementName=tbLoading}"
/>
</i:EventTrigger>
</i:Interaction.Triggers>
</ContentPresenter>
private async static void ContentTemplateChanged
(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
ContentPresenter ContPres =
((actSelectedContentDisplayHandler)obj).AssociatedObject; if (ContPres == null)
{
return;
}
TextBlock tbLoading = obj.GetValue(LoadingTextBlockProperty) as TextBlock;
ContPres.Opacity = 0;
tbLoading.Opacity = 1;
CancellationTokenSource cts = new CancellationTokenSource();
Task t = Task.Factory.StartNew(() =>
{
AutoResetEvent are = new AutoResetEvent(false);
Application.Current.Dispatcher.BeginInvoke
(DispatcherPriority.ApplicationIdle, new Action(() =>
{
DataTemplate ContTemp =
obj.GetValue(ContentTemplateProperty) as DataTemplate;
ContPres.ContentTemplate = ContTemp;
ContPres.SetValue(areProperty, are);
}));
are.WaitOne(1000);
}, cts.Token);
ContPres.SetValue(ActiveTaskProperty, t);
await t;
ContPres.SetValue(ActiveTaskProperty, null);
ContPres.Opacity = 1;
tbLoading.Opacity = 0;
}
void AssociatedObject_LayoutUpdated(object sender, EventArgs e)
{
if (this.AssociatedObject.GetValue
(actSelectedContentDisplayHandler.ActiveTaskProperty) != null) {
if (VisualTreeHelper.GetChildrenCount(this.AssociatedObject) > 0)
{
(this.AssociatedObject.GetValue
(actSelectedContentDisplayHandler.areProperty) as AutoResetEvent).Set();
}
}
}
Two Modes Toggling
One of my personal UI-related eye sores is the use of ToggleButton
/Button
to switch between two display modes.
In many cases, the relation between the control and the area being changed is not clear, and to top that, the text on the button is confusing - in some cases, it displays the mode currently not displayed, and in others it displays the currently displayed mode name.
Here, I give an alternative UI solution for this issue, while keeping the same UI Design 'language' which makes this feature much more comprehensive to the user.
The principles for creating this feature are much the same as for the TabControl
, with addition of animated trasaction between modes.
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ShowStates">
<VisualState x:Name="ShowLeftTab">
<Storyboard >
<DoubleAnimationUsingKeyFrames Storyboard.
TargetName="LeftTab" Storyboard.
TargetProperty="Opacity">
<EasingDoubleKeyFrame KeyTime="0:0:0.2"
Value="1"></EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<Int32AnimationUsingKeyFrames Storyboard.TargetName="
LeftTab" Storyboard.TargetProperty="(Panel.ZIndex)">
<DiscreteInt32KeyFrame KeyTime="0:0:0"
Value="4"></DiscreteInt32KeyFrame>
</Int32AnimationUsingKeyFrames>
<DoubleAnimation Storyboard.TargetName="LeftContent"
Storyboard.TargetProperty="Opacity" To="1">
</DoubleAnimation>
<Int32AnimationUsingKeyFrames Storyboard.TargetName="
RightContent" Storyboard.TargetProperty="(Panel.ZIndex)">
<DiscreteInt32KeyFrame KeyTime="0:0:0.2"
Value="1"></DiscreteInt32KeyFrame>
</Int32AnimationUsingKeyFrames>
<DoubleAnimation Storyboard.TargetName="RightContent"
Storyboard.TargetProperty="Opacity" To="0">
</DoubleAnimation>
</Storyboard>
</VisualState>
<VisualState x:Name="ShowRightTab">
<Storyboard >
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="
LeftTab" Storyboard.TargetProperty="Opacity">
<EasingDoubleKeyFrame KeyTime="0:0:0.2"
Value="0"></EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<Int32AnimationUsingKeyFrames Storyboard.TargetName="
LeftTab" Storyboard.TargetProperty="(Panel.ZIndex)">
<DiscreteInt32KeyFrame KeyTime="0:0:0.2"
Value="2"></DiscreteInt32KeyFrame>
</Int32AnimationUsingKeyFrames>
<DoubleAnimation Storyboard.TargetName="LeftContent"
Storyboard.TargetProperty="Opacity" To="0">
</DoubleAnimation>
<Int32AnimationUsingKeyFrames Storyboard.TargetName="
RightContent" Storyboard.TargetProperty="(Panel.ZIndex)">
<DiscreteInt32KeyFrame KeyTime="0:0:0.2"
Value="3"></DiscreteInt32KeyFrame>
</Int32AnimationUsingKeyFrames>
<DoubleAnimation Storyboard.TargetName="RightContent"
Storyboard.TargetProperty="Opacity" To="1">
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="RightTabFront"
Storyboard.TargetProperty="Opacity" To="0"
Duration="0:0:0.1"></DoubleAnimation>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
Points of Interest
The idea of flexible (Binded
) geometry has much more potential beyond the layout-related adjustable borders shown in this article.
Hopefully, in my next article, dealing with this technique, I'll describe methods to incorporate animations into it, in order to achieve elaborate UI features such as controls that 'shape-shift' into dialogs and so.
I hope that you found this article helpful.