Stretching the WinUI 3 Expander control

In this article we take the new WinUI 3 Expander control through a couple of scenarios that you don’t find in the documentation. We have built a Windows Desktop app that demonstrates

  • an attempt to build a horizontal Expander,
  • grouping Expanders into an Accordion control, and
  • grouping Expanders into a Wizard control, using data templates and binding.

Here’s how that app looks like:

From the -excellent- design document we learn that the Expander control lets you show or hide less important content that’s related to a piece of primary content that’s always visible. The Header is always visible and behaves like a ToggleButton. It allows the user to expand and collapse the Content area with the secondary content. The Expander UI is commonly used when display space is limited and when information or options can be grouped together.

Most UI stacks and component libraries have an Expander control – in Bootstrap it’s called Collapsible Panel. In the WinUI ecosystem the Expander is relatively new. Its proposal came only at the end of 2020. The control was built to these specs, and eventually appeared in the latest releases of WinUI 3. Before, an expander control was available via Community Toolkit.

We decided to take this new control for a test drive through some scenarios.

Getting started with the Expander Control

Before starting to experiment with the Expander control, take your time to read its documentation and open the WinUI 3 Gallery app. Here’s how the Expander demo looks like – pretty basic:

Our own sample app starts with an Intro page. Here’s the XAML definition of the very first Expander – again pretty basic:

<Expander>
    <Expander.Header>
        <TextBlock Foreground="Red">Red</TextBlock>
    </Expander.Header>
    <Grid Background="Red"
            Height="100"
            Width="200"
            CornerRadius="4" />
</Expander>

As you see in the next composite screenshot, the Header width is not aligned to the width of the Content. This looks more like a Button with a Flyout than like a ‘classic’ Expander:

If you don’t like this default, you can give the control or the Header a Width, or place it in a parent Panel that has its HorizontalContentAlignment set to Stretch. Here’s the XAML of the second expander on the Intro page – the one with the orange. The header has a fixed width that corresponds to the width of the Content. We also added an icon in that Header:

<Expander Width="234">
    <Expander.Header>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="auto" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <BitmapIcon UriSource="/Assets/Orange.png"
                        ShowAsMonochrome="False"
                        Height="24" />
            <TextBlock Foreground="Orange"
                        HorizontalAlignment="Right"
                        VerticalAlignment="Center"
                        Grid.Column="1">Orange</TextBlock>
        </Grid>
    </Expander.Header>
    <Grid Background="Orange"
            Height="100"
            Width="200"
            CornerRadius="4" />
</Expander>

In the Intro page we stacked several expanders on top of each other – a common use of expanders that we elaborate on later in this article.

Building a Horizontal Expander

Unlike the ‘old’ WPF Expander, the WinUI 3 Expander only expands vertically: its ExpandDirection is limited to up and down. We made an attempt to implement a horizontal Expander – without altering the original XAML template. We hosted the control in a ContentControl that we submitted to a RenderTransform with a 90° Rotation to the left, and then rotated its Content back 90° to the right:

<ContentControl x:Name="HorizontalExpander"
                VerticalContentAlignment="Top">
    <ContentControl.RenderTransform>
        <RotateTransform Angle="270"
                            CenterX="117"
                            CenterY="117" />
    </ContentControl.RenderTransform>
    <Expander Width="234">
        <Grid Background="LightPink"
                Height="100"
                Width="200"
                CornerRadius="4">
            <BitmapIcon UriSource="/Assets/Heart.png"
                        ShowAsMonochrome="False"
                        VerticalAlignment="Center"
                        HorizontalAlignment="Center"
                        Height="80">
                <BitmapIcon.RenderTransform>
                    <RotateTransform Angle="90"
                                        CenterX="40"
                                        CenterY="40" />
                </BitmapIcon.RenderTransform>
            </BitmapIcon>
        </Grid>
    </Expander>
</ContentControl>

This is how the result looks like – it’s the pink one:

This horizontal expander looks functional, but it does not behave the same as the original. Under the hood, it has become a square control. We had to give the text box on its right a negative margin:

<TextBlock Text="Hello there"
            RelativePanel.RightOf="HorizontalExpander"
            Margin="-166 0 0 0" />

Also, it does not push its neighbors aside when expanding.

If you want to build a better WinUI 3 horizontal expander, you may get some inspiration in the source code of the Windows Community Toolkit UWP Expander. Just like its WPF and Silverlight ancestor, this one also expands to left and right.

Building an Accordion Control

A traditional usage of Expander controls is stacking some instances on top of each other to form an Accordion. Silverlight and WPF had one in their Toolkit, Syncfusion has an Accordion for UWP, and ComponentOne has one for WinUI.

Let’s see how far we can get with building an Expander-based WinUI 3 Accordion from scratch. [Spoiler alert: pretty far.]

Here are our requirements:

  • there should always be one accordion item open,
  • all other items should be closed, and
  • the accordion should always fill its vertical space.

Here’s how the AccordionPage in our sample app looks like:

The core XAML structure of the control consists of Expanders inside an ItemsControl inside some Panel to horizontally stretch them all, and decorated with a ScrollViewer:

<RelativePanel x:Name="Host"
                VerticalAlignment="Stretch"
                HorizontalAlignment="Stretch">
    <ScrollViewer x:Name="ScrollViewer">
        <ItemsControl x:Name="Accordion"
                        Width="480">
            <Expander HorizontalAlignment="Stretch"
                        HorizontalContentAlignment="Stretch">
                <!-- Header and Content here ... -->
            </Expander>
            <!-- More Expanders here ... -->
        </ItemsControl>
    </ScrollViewer>
</RelativePanel>

When the page opens, we add all expanders to a list, register an event handler, and open the first one by setting IsExpanded:

readonly List<Expander> _expanders = new();

private void AccordionPage_Loaded(object sender, RoutedEventArgs e)
{
    foreach (Expander expander in Accordion.Items)
    {
        _expanders.Add(expander);
        expander.Expanding += Expander_Expanding;
    }

    // Open the first one.
    ApplyScrollBar();
    _expanders[0].IsExpanded = true;
}

In the Expanding event handler, we loop through the list to close all expanders except the selected one. The open expander cannot be manually closed by clicking the header or the toggle button. It is forced to remain open by ‘locking’ its header:

private void Expander_Expanding(
     Expander sender, 
     ExpanderExpandingEventArgs args)
{
    foreach (var expander in _expanders)
    {
        // Close the others.
        expander.IsExpanded = sender == expander;

        // Force current to remain open by disabling the header.
        expander.IsLocked(sender != expander);
    }

    FillHeight(sender);
}

The IsLocked() in that code snippet is an extension method that we wrote:

public static void IsLocked(this Expander expander, bool locked)
{
    ((expander.Header as FrameworkElement).Parent as Control).IsEnabled = locked;
}

[Note: In a full-fledged Accordion control we would implement IsLocked as a dependency property.]

To ensure that the Accordion always fills its vertical space, we manipulate the height of the Content of the one open Expander. We couldn’t find a way to do this with stretching and binding, so we ended up calculating and explicitly setting the Height. When you look at the default XAML template there’s padding on top, padding on the bottom, and a border around the core content to consider in the calculation. So, when the app starts, we measure the height of the Accordion with all its expanders closed, and we store the total vertical padding around the content:

double _closedAccordionHeight;
readonly double _minimumContentHeight = 48;
double _contentPadding;

private void AccordionPage_Loaded(object sender, RoutedEventArgs e)
{
    _closedAccordionHeight = Accordion.ActualHeight;

    // ...

    _contentPadding = _expanders[0].Padding.Top + _expanders[0].Padding.Bottom + 2; // Border?

    Host.SizeChanged += Host_SizeChanged;
}

When the hosting panel is resized, and when a new expander opens, we calculate and set the height of the current item, respecting a minimum height for the content:

private void FillHeight(Expander expander)
{
    expander.SetContentHeight(Math.Max(
        _minimumContentHeight, 
        Host.ActualHeight - _closedAccordionHeight - _contentPadding));
}

Here’s the implementation of our SetContentHeight() extension method:

public static void SetContentHeight(this Expander expander, double contentHeight)
{
    (expander.Content as FrameworkElement).Height = contentHeight;
}

Here’s the event handler that’s fired when the page is resized;

private void Host_SizeChanged(object sender, SizeChangedEventArgs e)
{
    ApplyScrollBar();
    FillHeight(Current);
}

The ApplyScrollBar() call is there to ensure that the scroll bar does not pop up unnecessarily during the Expanding event where two of the expanders may be open simultaneously. Here’s the code:

private void ApplyScrollBar()
{
    if (Host.ActualHeight >= _closedAccordionHeight + _minimumContentHeight + _contentPadding)
    {
        ScrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
    }
    else
    {
        ScrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
    }
}

Here’s how the height adjustment looks like at runtime:

We did not refactor the code into a stand-alone custom control, but we believe that this would be a relatively easy task. A future ‘WinUI 3 Community Toolkit’ could have an Accordion control…

In meantime we’re taking our Expander test a step further, to build the core code for a Wizard control.

Building a Wizard Control

In this last Expander experiment we tried to figure out how Expanders deals with data templates and data binding. We reenacted the old ASP.NET Wizard control, designed to guide the user through a sequence of steps. It looks like an Accordion with extra controls in each expander’s Header (to display the status/result of the step) and a set of fixed controls in each expander’s Content (description of the step, buttons to move back and forth). Here’s how it looks like:

Here are some or our requirements. Most of these were at least partly implemented:

  • multiple steps may be open,
  • the control occupies all available horizontal space,
  • the result of a step appears in its header,
  • a description of the step appears in its content,
  • the content shows the navigation buttons,
  • the navigation button text depends on the position (e.g., no ‘go back’ in the first step),
  • steps may be defined as ‘execute only once’ (e.g., payment)

[Disclaimer: our code base was written to evaluate the Expander. The Wizard code is not complete and it contains mockups.]

We started with designing a set of lightweight bindable viewmodels for wizard and steps. Here’s a class diagram:

[Note: here’s how to add the Class Diagram feature to your Visual Studio 2022]

We used Microsoft MVVM Toolkit to implement change notification and ‘bindability’. Here’s part of the code for the WizardViewModel. It hosts a list of viewmodels for the Steps and the logic to determine the Next and Previous of a particular step:

internal partial class WizardViewModel : ObservableObject
{
    [ObservableProperty]
    private List<WizardStepViewModel> _steps = new();

    internal WizardStepViewModel NextStep(WizardStepViewModel step)
    {
        var stepIndex = _steps.IndexOf(step);
        if (stepIndex < _steps.Count - 1)
        {
            return _steps[stepIndex + 1];
        }

        return null;
    }

    internal WizardStepViewModel PreviousStep(WizardStepViewModel step)
    {
        var stepIndex = _steps.IndexOf(step);
        if (stepIndex > 0)
        {
            return _steps[stepIndex - 1];
        }

        return null;
    }
}

The WizardStepViewModel contains the properties and button labels to be displayed, as well as the commands behind the navigation buttons and whether these buttons should be enabled. When the ‘continue’ button is clicked, we validate and commit the current step and then navigate forward:

private void Next_Executed()
{
    if (!Commit())
    {
        return;
    }

    var next = _wizard.NextStep(this);

    if (next is null)
    {
        return;
    }

    IsActive = false;
    next.IsActive = true;
}

public bool Commit()
{
    // Validate and persist Model
    // ...

    // Update Status - Mockup
    Status = "Succes";

    // Return result
    return true;
}

The basic XAML structure of the Wizard is the same as in the Accordion: Panel, ScrollViewer, ItemsControl with Expanders. It just comes with more data binding and data templates:

<RelativePanel x:Name="Host"
                VerticalAlignment="Stretch"
                HorizontalAlignment="Stretch">
    <ScrollViewer x:Name="ScrollViewer"
                    HorizontalAlignment="Stretch"
                    HorizontalContentAlignment="Stretch">
        <ItemsControl x:Name="Wizard"
                        HorizontalAlignment="Stretch"
                        HorizontalContentAlignment="Stretch">
            <Expander HeaderTemplate="{StaticResource WizardHeaderTemplate}"
                        ContentTemplate="{StaticResource WizardContentTemplate}"
                        HorizontalAlignment="Stretch"
                        HorizontalContentAlignment="Stretch"
                        DataContext="{x:Bind ViewModel.Steps[0], Mode=OneWay}"
                        IsExpanded="{x:Bind ViewModel.Steps[0].IsActive, Mode=TwoWay}">
                <!-- Custom Content here ... -->
            </Expander>
            <!-- More Expanders here ... -->
        </ItemsControl>
    </ScrollViewer>
</RelativePanel>

Via a DataTemplate the Header of each Expander has an extra text block displaying the Status (e.g. execution status, result, validation error) of the corresponding step:

<DataTemplate x:Name="WizardHeaderTemplate"
                x:DataType="viewmodels:WizardStepViewModel">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="auto" />
        </Grid.ColumnDefinitions>
        <TextBlock Text="{x:Bind Name, Mode=OneWay}" />
        <TextBlock Text="{x:Bind Status, Mode=OneWay}"
                    FontStyle="Italic"
                    Grid.Column="1" />
    </Grid>
</DataTemplate>

The content is also displayed through a data template. It has a description for the current step, buttons to move through the scenario, and a ContentPresenter for the core content:

<DataTemplate x:Name="WizardContentTemplate"
                x:DataType="viewmodels:WizardStepViewModel">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition Height="auto" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="auto" />
        </Grid.ColumnDefinitions>

        <!-- Custom Content -->
        <ContentPresenter Content="{Binding Content, RelativeSource={RelativeSource TemplatedParent}}" />

        <!-- Default Content -->
        <TextBlock Text="{x:Bind Description, Mode=OneWay}"
                    Grid.Column="1" />
        <StackPanel Orientation="Horizontal"
                    HorizontalAlignment="Right"
                    VerticalAlignment="Bottom"
                    Grid.Row="1"
                    Grid.Column="1">
            <Button Command="{x:Bind PreviousCommand, Mode=OneWay}"
                    Content="{x:Bind PreviousLabel, Mode=OneWay}" />
            <Button Command="{x:Bind NextCommand, Mode=OneWay}"
                    Content="{x:Bind NextLabel, Mode=OneWay}"
                    Margin="10 0 0 0" />
        </StackPanel>
    </Grid>
</DataTemplate>

For custom content we went for simple images. Here’s an example:

<Expander HeaderTemplate="{StaticResource WizardHeaderTemplate}"
            ContentTemplate="{StaticResource WizardContentTemplate}"
            HorizontalAlignment="Stretch"
            HorizontalContentAlignment="Stretch"
            DataContext="{x:Bind ViewModel.Steps[1], Mode=OneWay}"
            IsExpanded="{x:Bind ViewModel.Steps[1].IsActive, Mode=TwoWay}"
            Margin="0 4 0 0">
    <BitmapIcon UriSource="/Assets/Seating.png"
                ShowAsMonochrome="False"
                VerticalAlignment="Stretch"
                HorizontalAlignment="Center"
                Height="240" />
</Expander>

When the page opens, we populate the main ViewModel:

var viewModel = new WizardViewModel();
viewModel.Steps = new List<WizardStepViewModel>()
    {
    new WizardStepViewModel(viewModel)
    {
        IsActive = true,
        AllowReturn = true,
        Name = "Your movie",
        Description = "Select a movie and a time"
    },
    new WizardStepViewModel(viewModel)
    {
        AllowReturn = true,
        Name = "Your seat",
        Description = "Select a row and a seat"
    },
    new WizardStepViewModel(viewModel)
    {
        AllowReturn = false,
        Name = "Yourself",
        Description = "Provide contact information"
    },
    new WizardStepViewModel(viewModel)
    {
        AllowReturn = false,
        Name = "Your money",
        Description = "Select a payment method and ... pay"
    }
};
DataContext = viewModel;

There’s no further code behind, except to stretch the Wizard horizontally:

private void Host_SizeChanged(object sender, SizeChangedEventArgs e)
{
    ScrollViewer.Width = Host.ActualWidth;
}

By leveraging the new Expander control we were able to build the foundations of a Wizard control with remarkably few lines of code. Here’s how our Wizard looks like in action:

Not too bad, right?

The Verdict

The Expander control is a great new member of the WinUI 3 ecosystem. It definitely passed our stress test. We identified some room for improvement however. Future versions of the control could benefit from

  • horizontal expansion out-of-the-box,
  • a more complete set of events (Expanded, Collapsing), and
  • an easy way to lock the Header in closed or open position.

Our sample app lives here on GitHub.

Enjoy!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s