Home > Software design >  AvalonDock: Binding to LayoutAnchorablePane position?
AvalonDock: Binding to LayoutAnchorablePane position?

Time:12-22

I am creating a custom theme for my WPF application that uses the AvalonDock docking framework.
I have already opened a GitHub issue for my question on the AvalonDock repo but I'm hoping I can get an answer faster here (and ready to put a bounty on this ASAP).


In my custom theme I have moved the tab items for the LayoutAnchorablePane to stack vertically on the left side, and the pane uses a Grid with column sizes Auto, *, Auto.
I would like to write a Trigger for the style that moves the tabs from the left column to the right column when the LayoutAnchorablePane is attached to the right side of the root layout panel. (So that the tabs are always on the outside)

Here is the relevant section of my theme's XAML that I am trying to put the trigger on. This is almost identical to the LayoutAnchorablePaneControl template from the generic.xaml style in AvalonDock:

<Grid
    ClipToBounds="true"
    KeyboardNavigation.TabNavigation="Local"
    SnapsToDevicePixels="true">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" />
    <ColumnDefinition Width="*" />
    <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
    <!--  Following border is required to catch mouse events  -->
    <Border Grid.ColumnSpan="3" Background="Transparent" />
    <StackPanel
    x:Name="HeaderPanel"
    Width="40"
    Grid.Column="0"
    Panel.ZIndex="1"
    IsItemsHost="true"
    KeyboardNavigation.TabIndex="1" />
    <Border
    x:Name="ContentPanel"
    Grid.Column="1"
    Background="Transparent"
    BorderThickness="2"
    BorderBrush="{StaticResource PrimaryBrush}"
    KeyboardNavigation.DirectionalNavigation="Contained"
    KeyboardNavigation.TabIndex="2"
    KeyboardNavigation.TabNavigation="Cycle">
    <ContentPresenter
            x:Name="PART_SelectedContentHost"
            ContentSource="SelectedContent"
            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
    </Border>
</Grid>
<ControlTemplate.Triggers>
    <DataTrigger Binding="{Binding ??? }">
        <Setter TargetName="HeaderPanel" Property="Grid.Column" Value="2"/>
    </DataTrigger>
</ControlTemplate.Triggers>

As far as I can tell, there is no property on LayoutAnchorablePane or any of its interfaces that exposes which side of the layout the pane is on. So I'm lost for what I can put in {Binding ??? } in my DataTrigger.

It seems like I need to implement the property myself and use my own builds of AvalonDock. I would like to avoid this if it's at all possible; So maybe there's some clever MarkupExtension or Converter idea I could implement in my own code? Maybe my assumption that this can be done with DataTrigger can be challenged too. I would be happy to use a completely code-behind solution for this.

CodePudding user response:

The AvalonDock implementation is pretty weird. I remember having some troubles too when I had to use this control in a project. I think I know this control quite well. It is very badly implemented, in my opinion. Because they, for some reason, decided to implement this control itself using MVVM instead of making it simply MVVM ready. This makes it very inconvenient to use in advanced scenarios.
Semantics are also quite confusing. For example, containers are not the controls that render data. Containers are assigned to the Model property of the controls. Data is not assigned to the DataContext. But it looks good.

Also, the tab header placement behavior is broken (only allows tab headers at the bottom). My fix could be of interest for you, especially in context of dynamic tab header placement. See the Style below to get the expected tab header placement behavior. It simply wraps the content of the LayoutAnchorablePaneControl into a DockingPanel and rotates the header host, so that you get tab header alignment you have in Visual Studio (stacked by width). That's all. If you wish to stack the headers by their height (no rotation) simply replace the AnchorablePaneTabPanel with a e.g., StackPanel and remove the rotation triggers.
The provided example is based on that Style below. Otherwise you won't be able to propagate tab header position to the view.

Another big pain is the lack of events that are exposed by the DockingManager class and AvalonDock in general. This means there is no chance to observe the drag and drop actions. As a matter of fact, DockingManager only exposes three quite uninteresting events. Same to the content hosts like the LayoutAnchorablePaneControl.
Since AvalonDock does not use the WPF framework's Drag & Drop API, handling those events is not a solution.

To overcome the short comings, you must handle one of the few model events, the LayoutRoot.Updated event in this case.

The solution only targets the LayoutAnchorablePane and LayoutAnchorableGroupPane. To address advanced grouping or the LayoutDocumentPane you can simple extend the example by following the pattern.
Since you only require/requested a two column layout, the algorithm will do the job. Other more advanced layout arrangements are supported, but the behavior is not perfect as not all conditions are currently tracked. The focus is on a two column layout. It's a quick (but not so dirty) and very simple solution.
You should consider to disallow any layout arrangement other than the two column layout explicitly.

Additionally, AvalonDock does not provide an event to indicate when the visual layout process is completed. You only get a notification via the LayoutRoot.Updated event when the layout model is added/removed to/from the layout model tree. But you never know when exactly the visual tree is updated. We need to have access to the visual containers in order to set the LayoutPanelControl.TabStripPlacement property based on the new position of this control.
To overcome this, I used the Dispatcher to defer the access to the then initialized and rendered LayoutAnchorablePaneControl. Otherwise the tab header arrangement would be premature, because the control's layout index is yet to change. AvalonDock only allows to track very few layout model modifications but no observation of actual docking operations at all.

So the algorithm is basically

  1. Handle the LayoutRoot.Updated event and start the actual positioning algorithm deferred using the Dispatcher
  2. Iterate over all pane controls to update the tab header placement. In case nesting is allowed, you will have a layout tree that you have to traverse recursively, like it is done in this example for group panes.
  3. Identify the position of the pane in the layout based on their index.
  4. Set the LayoutPanelControl.TabStripPlacement property according to the index: an index of 0 means left and an index that equals the item count means right. Every other index is in between. The tab headers are placed based on the pane's position in the layout.
  5. The DockingPanel will layout the tab items accordingly. Triggers are used to rotate the tab headers if they are positioned left or right.

There can be multiple LayoutPanelControl elements in the layout (except you disallow "illegal" layout arrangements to enforce the two column layout).

MainWindow.xaml.cs

public partial class MainWindow : Window
{
  private const Dock DefaultDockPosition = Dock.Bottom;

  private void InitializeOnDockingManager_Loaded(object sender, RoutedEventArgs e)
  {
    var dockingManager = sender as DockingManager;
    this.Dispatcher.InvokeAsync(() =>
    {
      ArrangePanel(dockingManager.LayoutRootPanel);
    },
    DispatcherPriority.Background);

    dockingManager.Layout.Updated  = OnLayoutUpdated;
  }

  private void OnLayoutUpdated(object sender, EventArgs e)
  {
    var layoutRoot = sender as LayoutRoot;
    var dockingManager = layoutRoot.Manager;
    this.Dispatcher.InvokeAsync(() =>
    {
      ArrangePanel(dockingManager.LayoutRootPanel);
    },
    DispatcherPriority.ContextIdle);
  }

  private void ArrangePanel(LayoutPanelControl layoutPanelControl)
  {
    IEnumerable<ILayoutControl> layoutControls = layoutPanelControl.Children
      .OfType<ILayoutControl>()
      .Where(control =>
        control is LayoutAnchorablePaneControl paneControl
          && (paneControl.Model as ILayoutContainer).Children.Any()
        || control is LayoutAnchorablePaneGroupControl or LayoutPanelControl);

    int paneControlCount = layoutControls.Count(control => control is not LayoutPanelControl);
    int paneControlLayoutPosition = 0;

    foreach (ILayoutControl layoutControl in layoutControls)
    {
      if (layoutControl is LayoutPanelControl layoutPanel)
      {
        ArrangePanel(layoutPanel);
        continue;
      }

      paneControlLayoutPosition  ;
      bool isFirst = paneControlLayoutPosition == 1;
      bool isLast = paneControlCount == paneControlLayoutPosition;

      if (layoutControl is LayoutAnchorablePaneGroupControl paneGroupControl)
      {
        PositiontabHeadersInPaneGroup((isFirst, isLast), paneGroupControl);
      }
      else if (layoutControl is LayoutAnchorablePaneControl paneControl)
      {
        if (paneControlCount == 1)
        {
          paneControl.TabStripPlacement = DefaultDockPosition;
        }
        else
        {
          PositionTabHeadersInPane(paneControl, isFirst, isLast);
        }
      }
    }
  }

  private static void PositionTabHeadersInPane(LayoutAnchorablePaneControl paneControl, bool isFirst, bool isLast)
    => paneControl.TabStripPlacement =
      (isFirst, isLast) switch
      {
        (true, _) => Dock.Left,
        (_, true) => Dock.Right,
        _ => DefaultDockPosition
      };

  private void PositiontabHeadersInPaneGroup((bool IsGroupFirst, bool IsGroupLast) parentPaneGroupPosition, LayoutAnchorablePaneGroupControl paneGroupControl)
  {
    IEnumerable<ILayoutControl> groupMembers = paneGroupControl.Children
      .OfType<ILayoutControl>();
    int groupMemberCount = groupMembers.Count();
    int layoutPosition = 0;

    foreach (ILayoutControl groupMember in groupMembers)
    {
      layoutPosition  ;
      bool isFirst = layoutPosition == 1;
      bool isLast = layoutPosition == groupMemberCount;

      if (groupMember is LayoutAnchorablePaneGroupControl childGroupControl)
      {
        PositiontabHeadersInPaneGroup((isFirst, isLast), childGroupControl);
      }
      else if (groupMember is LayoutAnchorablePaneControl paneControl)
      {
        (bool IsPaneFirstInGroup, bool IsPaneLastInGroup) panePositionInGroup = (isFirst, isLast);

        paneControl.TabStripPlacement =
          !parentPaneGroupPosition.IsGroupFirst && !parentPaneGroupPosition.IsGroupLast
          || groupMemberCount == 1
            ? DefaultDockPosition
            : (parentPaneGroupPosition, panePositionInGroup, paneGroupControl.Orientation) switch
            {
              ({ IsGroupFirst: true }, { IsPaneFirstInGroup: true }, Orientation.Horizontal) => Dock.Left,
              ({ IsGroupLast: true }, { IsPaneLastInGroup: true }, Orientation.Horizontal) => Dock.Right,
              ({ IsGroupFirst: true }, _, Orientation.Vertical) => Dock.Left,
              ({ IsGroupLast: true }, _, Orientation.Vertical) => Dock.Right,
              _ => DefaultDockPosition
            };
      }
    }
  }
}

MainWindow.xaml
The required AnchorablePaneControlStyle is defined below.

<xcad:DockingManager Loaded="InitializeOnDockingManager_Loaded"
                     AnchorablePaneControlStyle="{StaticResource AnchorablePaneControlStyle}"
                     Height="500"
                     Width="500"
                     HorizontalAlignment="Left">
  <xcad:LayoutRoot>
    <xcad:LayoutPanel Orientation="Horizontal">
      <xcad:LayoutAnchorablePane>
        <xcad:LayoutAnchorable ContentId="properties"
                               Title="Properties">
          <TextBlock Text="123abc" />
        </xcad:LayoutAnchorable>
        <xcad:LayoutAnchorable Title="AgendaLeft"
                               ContentId="agendaLeft">
          <TextBlock Text="Agenda Content" />
        </xcad:LayoutAnchorable>
        <xcad:LayoutAnchorable Title="ContactsLeft"
                               ContentId="contactsLeft">
          <TextBlock Text="Contacts Content" />
        </xcad:LayoutAnchorable>
      </xcad:LayoutAnchorablePane>
    </xcad:LayoutPanel>
  </xcad:LayoutRoot>
</xcad:DockingManager>

AnchorablePaneControlStyle

<Style x:Key="AnchorablePaneControlStyle"
       TargetType="{x:Type xcad:LayoutAnchorablePaneControl}">
  <Setter Property="Foreground"
          Value="{Binding Model.Root.Manager.Foreground, RelativeSource={RelativeSource Self}}" />
  <Setter Property="Background"
          Value="{Binding Model.Root.Manager.Background, RelativeSource={RelativeSource Self}}" />
  <Setter Property="TabStripPlacement"
          Value="Bottom" />
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type xcad:LayoutAnchorablePaneControl}">
        <Grid ClipToBounds="true"
              SnapsToDevicePixels="true"
              KeyboardNavigation.TabNavigation="Local">
          
          <!--Following border is required to catch mouse events-->
          <Border Background="Transparent"
                  Grid.RowSpan="2" />
          
          <DockPanel>
            <xcad:AnchorablePaneTabPanel x:Name="HeaderPanel"
                                         DockPanel.Dock="{TemplateBinding TabStripPlacement}"
                                         Margin="2,0,2,2"
                                         IsItemsHost="true"
                                         KeyboardNavigation.TabIndex="1"
                                         KeyboardNavigation.DirectionalNavigation="Cycle">
              <xcad:AnchorablePaneTabPanel.LayoutTransform>
                <RotateTransform x:Name="TabPanelRotateTransform" />
              </xcad:AnchorablePaneTabPanel.LayoutTransform>
            </xcad:AnchorablePaneTabPanel>
            
          <Border x:Name="ContentPanel" DockPanel.Dock="{TemplateBinding TabStripPlacement}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    KeyboardNavigation.DirectionalNavigation="Contained"
                    KeyboardNavigation.TabIndex="2"
                    KeyboardNavigation.TabNavigation="Cycle">
              <ContentPresenter x:Name="PART_SelectedContentHost"                                    
                                ContentSource="SelectedContent"
                                Margin="{TemplateBinding Padding}"
                                SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
            </Border>
          </DockPanel>
        </Grid>
        
        <ControlTemplate.Triggers>
          <Trigger Property="TabStripPlacement"
                   Value="Top">
            <Trigger.EnterActions>
              <StopStoryboard BeginStoryboardName="LeftTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="RightTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="BottomTabStripPlacementAnimation" />
              <BeginStoryboard x:Name="TopTabStripPlacementAnimation">
                <Storyboard>
                  <DoubleAnimation Storyboard.TargetName="TabPanelRotateTransform"
                                   Storyboard.TargetProperty="Angle"
                                   To="90"
                                   Duration="0" />
                </Storyboard>
              </BeginStoryboard>
            </Trigger.EnterActions>
          </Trigger>
          <Trigger Property="TabStripPlacement"
                   Value="Bottom">
            <Trigger.EnterActions>
              <StopStoryboard BeginStoryboardName="LeftTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="TopTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="RightTabStripPlacementAnimation" />
              <BeginStoryboard x:Name="BottomTabStripPlacementAnimation">
                <Storyboard>
                  <DoubleAnimation Storyboard.TargetName="TabPanelRotateTransform"
                                   Storyboard.TargetProperty="Angle"
                                   To="0"
                                   Duration="0" />
                </Storyboard>
              </BeginStoryboard>
            </Trigger.EnterActions>
          </Trigger>
          <Trigger Property="TabStripPlacement"
                   Value="Left">
            <Trigger.EnterActions>
              <StopStoryboard BeginStoryboardName="TopTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="RightTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="BottomTabStripPlacementAnimation" />
              <BeginStoryboard x:Name="LeftTabStripPlacementAnimation">
                <Storyboard>
                  <DoubleAnimation Storyboard.TargetName="TabPanelRotateTransform"
                                   Storyboard.TargetProperty="Angle"
                                   To="90" 
                                   Duration="0" />
                </Storyboard>
              </BeginStoryboard>
            </Trigger.EnterActions>
          </Trigger>
          <Trigger Property="TabStripPlacement"
                   Value="Right">
            <Trigger.EnterActions>
              <StopStoryboard BeginStoryboardName="LeftTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="TopTabStripPlacementAnimation" />
              <StopStoryboard BeginStoryboardName="BottomTabStripPlacementAnimation" />
              <BeginStoryboard x:Name="RightTabStripPlacementAnimation">
                <Storyboard>
                  <DoubleAnimation Storyboard.TargetName="TabPanelRotateTransform"
                                   Storyboard.TargetProperty="Angle"
                                   To="90"
                                   Duration="0" />
                </Storyboard>
              </BeginStoryboard>
            </Trigger.EnterActions>
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>

  <Setter Property="ItemContainerStyle">
    <Setter.Value>
      <Style TargetType="{x:Type TabItem}">
        <Setter Property="IsSelected"
                Value="{Binding IsSelected, Mode=TwoWay}" />
        <Setter Property="IsEnabled"
                Value="{Binding IsEnabled}" />
        <Setter Property="ToolTip"
                Value="{Binding ToolTip}" />
        <Setter Property="Template">
          <Setter.Value>
            <ControlTemplate TargetType="{x:Type TabItem}">
              <Grid SnapsToDevicePixels="true">
                <Border x:Name="Bd"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="1,0,1,1"
                        Background="{TemplateBinding Background}">
                  <ContentPresenter x:Name="Content"
                                    ContentSource="Header"
                                    HorizontalAlignment="{Binding HorizontalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"
                                    VerticalAlignment="{Binding VerticalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"
                                    RecognizesAccessKey="True"
                                    SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
                </Border>
              </Grid>
              <ControlTemplate.Triggers>
                <Trigger Property="Selector.IsSelected"
                         Value="true">
                  <Setter Property="Background"
                          Value="White" />
                  <Setter Property="Panel.ZIndex"
                          Value="1" />
                  <Setter Property="Margin"
                          Value="0,-1,-1,-2" />
                </Trigger>
                <MultiTrigger>
                  <MultiTrigger.Conditions>
                    <Condition Property="IsMouseOver"
                               Value="true" />
                    <Condition Property="Selector.IsSelected"
                               Value="false" />
                  </MultiTrigger.Conditions>
                  <Setter Property="Background"
                          Value="{DynamicResource {x:Static SystemColors.GradientInactiveCaptionBrushKey}}" />
                  <Setter Property="BorderBrush"
                          Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
                  <Setter Property="Panel.ZIndex"
                          Value="0" />
                </MultiTrigger>
                <Trigger Property="IsEnabled"
                         Value="false">
                  <Setter Property="Foreground"
                          Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
                </Trigger>
              </ControlTemplate.Triggers>
            </ControlTemplate>
          </Setter.Value>
        </Setter>
        <Style.Triggers>
          <DataTrigger Binding="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type TabControl}}, Path=Items.Count, FallbackValue=1}"
                       Value="1">
            <Setter Property="Visibility"
                    Value="Collapsed" />
          </DataTrigger>
        </Style.Triggers>
      </Style>
    </Setter.Value>
  </Setter>

  <Setter Property="ItemTemplate">
    <Setter.Value>
      <DataTemplate>
        <xcad:LayoutAnchorableTabItem Model="{Binding}" />
      </DataTemplate>
    </Setter.Value>
  </Setter>

  <Setter Property="ContentTemplate"
          Value="{StaticResource AnchorablePaneControlContentTemplate}" />

</Style>

CodePudding user response:

Thanks to BionicCode for the really detailed answer, I ended up only needing a few hints from that answer to solve the problem in my own way. So I thought it would be worth sharing my code too.


LayoutAnchorablePaneControl inherits from TabControl so it already has the TabStripPlacement property that the style can bind to on its templated parent.

So the new style replaces Grid with DockPanel and looks like this:

<DockPanel
    ClipToBounds="true"
    KeyboardNavigation.TabNavigation="Local"
    SnapsToDevicePixels="true">
    <!--  Following border is required to catch mouse events  -->
    <Border Background="Transparent" />
    <StackPanel
    x:Name="HeaderPanel"
    Width="40"
    DockPanel.Dock="{TemplateBinding TapStripPlacement}"
    Panel.ZIndex="1"
    IsItemsHost="true"
    KeyboardNavigation.TabIndex="1" />
    <Border
    x:Name="ContentPanel"
    Background="Transparent"
    BorderThickness="2"
    BorderBrush="{StaticResource PrimaryBrush}"
    KeyboardNavigation.DirectionalNavigation="Contained"
    KeyboardNavigation.TabIndex="2"
    KeyboardNavigation.TabNavigation="Cycle">
    <ContentPresenter
            x:Name="PART_SelectedContentHost"
            ContentSource="SelectedContent"
            SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
    </Border>
</DockPanel>

Now this style will move the tabs to any of the sides (left/right/top/bottom) depending on the LayoutAnchorablePaneControl.TabStripPlacement property.

In code behind (for the Window that has the DockingManager) I attached an event handler to DockingManager.Layout.Updated that runs the following method:

private void UpdateTabSides()
{
    foreach (LayoutAnchorablePaneControl apc in DockManager.LayoutRootPanel.FindLogicalChildren<LayoutAnchorablePaneControl>())
    {
        var side = apc.Model.GetSide();
        if (side == AnchorSide.Right)
        {
            apc.TabStripPlacement = Dock.Right;
        }
        else
        {
            apc.TabStripPlacement = Dock.Left;
        }
    }
}

I find this approach to be a lot simpler than BionicCode's answer but they deserve the bounty for pushing me in the right direction.

  • Related