Search code examples
wpfxamlaccordionhorizontal-accordion

Horizontal accordion control?


I want a control whose behavior is as follows:

  • Act like a Grid
  • Each child control is embedded in a horizontal Expander (whose header is binded to the control's Tag property)
  • Each of these Expander has its own ColumnDefinition
  • Only one of these expanders can be expanded at a time
  • Non-expanded Expanders' ColumnDefinition have a width set to Auto
  • The expanded Expander's one is * (Star)

It has to use these exact controls (Grid/Expander), and not some custom ones, so my application's style can automatically apply to them.

I can't seem to find something already made, no built-in solution seems to exist (if only there was a "filling" StackPanel...) and the only solution I can come up with is to make my own Grid implementation, which seems... daunting.

Is there a solution to find or implement such a control?

Here's what I have for now. It doesn't handle the "single-expanded" nor the filling. I don't really know if StackPanel or Expander is to blame for this.

<ItemsControl>
    <ItemsControl.Resources>
        <DataTemplate x:Key="verticalHeader">
            <ItemsControl ItemsSource="{Binding RelativeSource={RelativeSource AncestorType={x:Type Expander}}, Path=Header}" />
        </DataTemplate>
        <Style TargetType="{x:Type Expander}"
               BasedOn="{StaticResource {x:Type Expander}}">
            <Setter Property="HeaderTemplate"
                    Value="{StaticResource verticalHeader}" />
            <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
            <Setter Property="HorizontalAlignment" Value="Stretch"/>
            <Setter Property="ExpandDirection"
                    Value="Right" />
        </Style>
    </ItemsControl.Resources>
    <ItemsControl.Template>
        <ControlTemplate>
            <!-- Damn you, StackPanel! -->
            <StackPanel Orientation="Horizontal" IsItemsHost="True"/>
        </ControlTemplate>
    </ItemsControl.Template>
    <Expander Header="Exp1">
        <TextBlock Text="111111111" Background="Red"/>
    </Expander>
    <Expander Header="Exp2">
        <TextBlock Text="222222222" Background="Blue"/>
    </Expander>
    <Expander Header="Exp3">
        <TextBlock Text="333333333" Background="Green"/>
    </Expander>
</ItemsControl>

Solution

  • My first thought is to perform this kind of action with a Behavior. This is some functionality that you can add to existing XAML controls that give you some additional customization.

    I've only looked at it for something that's not using an ItemsSource as I used a Grid with Columns etc. But in just a plain grid, you can add a behavior that listens for it's childrens Expanded and Collapsed events like this:

    public class ExpanderBehavior : Behavior<Grid>
    {
        private List<Expander> childExpanders = new List<Expander>();
    
        protected override void OnAttached()
        {
            //since we are accessing it's children, we have to wait until initialise is complete for it's children to be added
            AssociatedObject.Initialized += (gridOvject, e) =>
            {
                foreach (Expander expander in AssociatedObject.Children)
                {
                    //store this so we can quickly contract other expanders (though we could just access Children again)
                    childExpanders.Add(expander);
    
                    //track expanded events
                    expander.Expanded += (expanderObject, e2) =>
                    {
                        //contract all other expanders
                        foreach (Expander otherExpander in childExpanders)
                        {
                            if (expander != otherExpander && otherExpander.IsExpanded)
                            {
                                otherExpander.IsExpanded = false;
                            }
                        }
    
                        //set width to star for the correct column
                        int index = Grid.GetColum(expanderObject as Expander);
    
                        AssociatedObject.ColumnDefinitions[index].Width = new GridLength(1, GridUnitType.Star);
                    };
    
                    //track Collapsed events
                    expander.Collapsed += (o2, e2) =>
                    {
                        //reset all to auto
                        foreach (ColumnDefinition colDef in AssociatedObject.ColumnDefinitions)
                        {
                            colDef.Width = GridLength.Auto;
                        }
                    };
                }
            };
        }
    }
    

    Use it like this, note you have to add System.Windows.Interactivity as a reference to your project:

    <Window ...
            xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity" 
            xmlns:local="...">
        <Window.Resources>
            <DataTemplate x:Key="verticalHeader">
                <ItemsControl ItemsSource="{Binding RelativeSource={RelativeSource AncestorType={x:Type Expander}}, Path=Header}" />
            </DataTemplate>
            <Style TargetType="{x:Type Expander}"
                   BasedOn="{StaticResource {x:Type Expander}}">
                <Setter Property="HeaderTemplate"
                        Value="{StaticResource verticalHeader}" />
                <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
                <Setter Property="HorizontalAlignment" Value="Stretch"/>
                <Setter Property="ExpandDirection"
                        Value="Right" />
            </Style>
    
            <local:ExpanderBehavior x:Key="ExpanderBehavor"/>
        </Window.Resources>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
    
            <i:Interaction.Behaviors>
                <local:ExpanderBehavior/>
            </i:Interaction.Behaviors>
    
            <Expander Header="Exp1">
                <TextBlock Text="111111111" Background="Red"/>
            </Expander>
            <Expander Header="Exp2" Grid.Column="1">
                <TextBlock Text="222222222" Background="Blue"/>
            </Expander>
            <Expander Header="Exp3" Grid.Column="2">
                <TextBlock Text="333333333" Background="Green"/>
            </Expander>
        </Grid>
    </Window>
    

    The final result:

    enter image description here


    Edit: Working with ItemsControl - add it to the grid that hosts the items, and add a little to manage the column mapping

    public class ItemsSourceExpanderBehavior : Behavior<Grid>
    {
        private List<Expander> childExpanders = new List<Expander>();
    
        protected override void OnAttached()
        {
            AssociatedObject.Initialized += (gridOvject, e) =>
            {
                //since we are accessing it's children, we have to wait until initialise is complete for it's children to be added
                for (int i = 0; i < AssociatedObject.Children.Count; i++)
                {
                    Expander expander = AssociatedObject.Children[i] as Expander;
    
                    //sort out the grid columns
                    AssociatedObject.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
                    Grid.SetColumn(expander, i);
    
                    childExpanders.Add(expander);
    
                    //track expanded events
                    expander.Expanded += (expanderObject, e2) =>
                    {
                        foreach (Expander otherExpander in childExpanders)
                        {
                            if (expander != otherExpander && otherExpander.IsExpanded)
                            {
                                otherExpander.IsExpanded = false;
                            }
                        }
    
                        //set width to auto
                        int index = AssociatedObject.Children.IndexOf(expanderObject as Expander);
    
                        AssociatedObject.ColumnDefinitions[index].Width = new GridLength(1, GridUnitType.Star);
                    };
    
                    //track Collapsed events
                    expander.Collapsed += (o2, e2) =>
                    {
                        foreach (ColumnDefinition colDef in AssociatedObject.ColumnDefinitions)
                        {
                            colDef.Width = GridLength.Auto;
                        }
                    };
                }
            };
        }
    }
    

    Used:

    <ItemsControl>
        <ItemsControl.Template>
            <ControlTemplate>
                <Grid IsItemsHost="True">
                    <i:Interaction.Behaviors>
                        <local:ItemsSourceExpanderBehavior/>
                    </i:Interaction.Behaviors>
                </Grid>
            </ControlTemplate>
        </ItemsControl.Template>
        <Expander Header="Exp1">
            <TextBlock Text="111111111" Background="Red"/>
        </Expander>
        <Expander Header="Exp2">
            <TextBlock Text="222222222" Background="Blue"/>
        </Expander>
        <Expander Header="Exp3">
            <TextBlock Text="333333333" Background="Green"/>
        </Expander>
    </ItemsControl>
    

    Note, that you'll have to add some logic to manage new/removed children if you have any changes to your ItemsSource!