Search code examples
c#wpfmvvmdata-bindingobservablecollection

Transfer deeply nested objects between Panels as simply as possible


I'm trying to create a simple Kanban board style app in WPF, using MVVM pattern.

The board has a dynamic amount of columns, each column has a dynamic amount of tasks. Here's how the core structure of it in the View looks like.

<Window.Content>
    <ScrollViewer>
        <ItemsControl ItemsSource="{Binding TaskColumns}">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <ItemsControl ItemsSource="{Binding}">
                        <ItemsControl.ItemsPanel>
                            <ItemsPanelTemplate>
                                <StackPanel Style="{StaticResource taskColumn}"/>
                            </ItemsPanelTemplate>
                        </ItemsControl.ItemsPanel>
                        <ItemsControl.ItemTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource task}">
                                    <TextBox Text="{Binding Text}"/>
                                </Border>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </ScrollViewer>
</Window.Content>

The ViewModel equivalent of this structure is an ObservableCollection<ObservableCollection<Task>> to which these elements bind. Their equivalents in View are StackPanel and Border.

I want to transfer a task between two columns using Drag and Drop events (Drag Border -> Drop StackPanel), and that itself is simple, but what I don't know is how to let ViewModel know which tasks to transfer and to which column. Maybe if possible, do it in View and update ViewModel accordingly.

I've tried finding indices from the parents to pass them like that: _viewModel.TransferTask(int sourceTaskIndex, int sourceColumnIndex, int targetColumnIndex), but with a structure like this this solution quickly got into an overly complicated mess with .Parent, sometimes .TemplateParent and (Panel) conversions, very confusing to both write and read.

I don't know how to get these indices in any other way either. Getting mouse coordinates with canvas seems like a nuclear option that doesn't seem fix the complexity.

Surely there must be a better way?


Solution

  • You should consider to replace the ItemsControl with a ListBox. ListBox is an advanced ItemsControl in that it allows scrolling and has performance features like UI virtualization (which is enabled by default).
    It would also make very much sense to create a dedicated control e.g. KanbanBoard that wraps those columns and implements the drag&drop logic. Just to encapsulate the related logic. It will also help to maintain/extend the features and to reuse the control.

    Your design is too complicated and should be changed in order to implement the drag&drop feature more conveniently.

    Next step is to simplify your data structure. A Kanban board reflects a workflow whereas each column represents an activity. There is no reason that the data structure already reflects those columns. It should be a simple flat collection of items.
    Consider the items to be the "physical" data entities while the columns are just an abstraction or an abstract mapping like an index/state of the data item that is transitioning through the workflow.

    So instead of having a data structure that reflects the workflow itself, the simple work item data models must have a state or an index that maps to their current workflow state (column). Now when the work item transitions from workflow state to workflow state according to specific transitioning rules, you only modify the data model's attributes to reflect the new state.
    For example, when transitioned into the last column, the state of the work item changes e.g. from "Review" to "Closed". It still remains in the same initial collection.

    The following example code is kind of a pseudo code to give an idea of the class design and class responsibilities.

    This means you separate data from visualization.
    The idea is that the client (e.g. a view model class or data models) of the KanbanBoard control...

    1. binds a collection of work items to the KanbanBoard.ItemsSource property of type IList. The data type of the collection is unknown to the KanbanBoard. This is the data part.
    2. defines columns using the API exposed by the KanbaBoard. A column is defined by index and name.
      The API could be another KanbanBoard.ColumnSource collection property of type IList, allowing to bind another e.g. string collection. Each string represents a column name and the index of the string maps to the column's index (optionally, KanbanBoard could allow the definition of a DataTemplate assigned to a ColumnTemplate property that enables the support of any data type for the column definitions). This means rearranging the column source collection will rearrange the columns in the KanbanBoard (if the source collection is a ObservableCollection).
    3. transitions an item by drag&drop. Transitioning changes the column index of the work item.
    4. can define a e.g. WorkflowItem.Index property (given that the source collection holds a set of WorkflowItem data models) that is bound to the item container via a KanbaBoard.ItemContainerStyle property. This requires the KanbaBoard to introduce a custom item container type KanbanBoardItem that could extend ListBoxItem and add a ColumnIndex property to enable the model binding of WorkflowIndex.
      Now when an item transitions, the KanbanBoard will adjust the KanbaBoardItem.ColumnIndex property accordingly. It's important to always work on the container and not on the data items.
      As a result, to know the position of the work item in the workflow, you would have to read the e.g. WorkflowItem.Index property. You can add additional information for example by adding a State property etc. to the KanbaBoardItem (and data models).

    KanbanBoard.cs

    // Pseudo code class
    class KanbanBoard : Control
    {
      /* Dependenciy properties */
      public IList ItemsSource { get; set; }
      public Style ItemContainerStyle { get; set; }
      public StyleSelector ItemContainerStyleSelector { get; set }
      public DataTemplate ItemTemplate { get; set; }
      public IList ColumnSource { get; set; }
    
      // Style for all columns (target KanbanBoardColumn)
      public Style ColumnStyle { get; set; }
    
      // StyleSelector to apply individual styling for each column (target KanbanBoardColumn)
      public StyleSelector ColumnStyleSelector { get; set }
    
      protected override void OnApplyTemplate()
      {
        var PART_ColumnHost = (ListBox)GetTemplateChild("PART_ColumnHost");
        PART_ColumnHost.ItemsSource = this.ColumnSource;
        for (int columnIndex = 0; columnIndex < this.ColumnSource.Count; columnIndex++)
        {
          object columnItem = this.ColumnSource[columnIndex];
          var columnItemContainer = (KanbanBoardColumn)PART_ColumnHost.ItemContainerGenerator.ContainerFromItem(columnItem);
          var columnStyle = this.ColumnStyleSelector?.SelectStyle(columnItem, columnItemContainer)
            ?? this.ColumnStyle;
    
          // The item container is a KanbanBoardColumn 
          // which is an extended ListBox (see definition below)
          columnItemContainer.Style = columnStyle;
          columnItemContainer.ItemContainerStyle = this.ItemContainerStyle;
          columnItemContainer.ItemContainerStyleSelector = this.ItemContainerStyleSelector;
          columnItemContainer.ItemTemplate = this.ItemTemplate;
          columnItemContainer.ColumnIndex = columnIndex;
          
          // Initialize first column with work items
          if (columnIndex == 0)
          {
            columnItemContainer.LoadItems(this.ItemsSource);
          }
        }
      }
    }
    

    KanbanBoardItem.cs

    // Pseudo code class
    class KanbanBoardItem : ListBoxItem
    {
      /* Dependenciy properties */
      public int ColumnIndex { get; set; }
      public Status Status { get; set; }
    }
    

    Status.cs

    // Pseudo code class
    enum Status
    {
      Inactive = 0,
      Open,
      Closed
    }
    

    Now that the KanbanBoard API is defined, we need to design the internals.

    1. To display the columns, we can use ListBox controls. In order to utilize the custom KanbanBoardItem container we need to extend ListBox in order to override the ItemsControl.GetContainerForItemOverride and ItemsControl.PrepareContainerForItemOverride methods. ItemsControl.GetContainerForItemOverride simply returns our custom KanbanBoardItem which is then used by the extended ListBox. The extended ListBox for example the KanbanBoardColumn is responsible to handle item drop. If an item is dropped it will set the KanbanBoardItem.Index property. Because of the additional features like a header it makes sense to modify the default Style of the ListBox too.
    2. We add a LsitBox´ to the ControlTemplateof theKanbanBoard. This ListBoxis used to dynamically create aKanbanBoardColumncontrols based on theKanbanBoard.ColumnSource collection property. The client code can define an optional [StyleSelector`]1 in order to customize columns individually (e.g. colorize workflow states).
    3. We assign the KanbanBoard.ItemContainerStyle to each KanbanBoardColumn.ItemContainerStyle property (see KanbanBorad class pseudo code above).
    4. We assign the KanbaBoard.ItemTemplate to each KanbanBoardColumn.Itemtemplate property (see KanbanBorad class pseudo code above).
    5. Drag&drop is implemented in the KanbanBoardColumn. It removes the item on drag start and insert it on drop or re-inserts it on drag cancelled. The ´KanbanBoardItem is the data payload of the drag&drop event data. On drop it also sets the ´KanbanBoardItem.ColumnIndex to finalize the transition.

    KanbaBoardColumn.cs

    // Pseudo code class
    class KanbanBoardColumn : ListBox
    {
      /* Dependenciy properties */
      public object ColumnHeader { get; set; }
      public int ColumnIndex { get; set; }
      private ItemsInternal { get; set; }
    
      public KanbanBoradColumn()
      {
        this.Items = new ObservableCollection<object>();
        this.ItemsSource = this.ItemsInternal;
      }
    
      public void LoadItems(IEnumerable newItems)
      {
        this.ItemsInternal = new ObservableCollection<object>(newItems);
        this.ItemsSource = this.ItemsInternal;
      }
     
      protected override void GetContainerForItemOverride()
        => new KanbanBoardItem();
     
      protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
      {
        base.PrepareContainerForItemOverride(element, item);
        if (element is KanbanBoardItem kanbanBoardItem)
        {
          kanbanBoardItem.Index = this.ColumnIndex;
        }
      }
    
      protected override OnDrop(DragEventArgs e)
      {
        var droppedItemContainer = e.Data.GetData(typeof(KanbanBoardItem)) as KanbanBoardItem;
        var item = droppedItemContainer.Content;
        this.ItemsInternal.Add(item);
        var generatedItemContainer = this.ItemContaionerGenerator.ContainerFromItem(item) as KanbanBoardItem;
        generatedItemContainer.ColumnIndex = this.ColumnIndex;
      }
    
      protected override OnMouseLeftButtonDown(MouseEventArgs e)
      {
        object clickedItem = GetClickedItem();
        this.ItemsInternal.Remmove(clickedItem);
        var clickedItemContainer = this.ItemContaionerGenerator.ContainerFromItem(clickedItem) as KanbanBoardItem;
       
        // TODO:: Start drag operation and use the clickedItemContainer as payload.
        // If cancelled re-insert the item back into the ItemsInternal collection.
      }   
    }
    

    KanbanBoardColumnStyle.xaml

    <Style TargetType="KanbanBoardColumn">
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="KanbanBoradColumn">
            <StackPanel>
              <TextBlock Text="{TemplateBinding Columnheader}" />
              
              <ScrollViewer CanContentScroll="True">
                <ItemsPresenter />
              </ScrollViewer>
            </StackPanel>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
    

    KanbanBoardStyle.xaml

    <Style TargetType="KanbanBoard">
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="KanbanBorad">
            <listBox x:Name="PART_ColumnHost" />
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
    

    Usage example

    MainViewModel.cs

    class MainViewModel : INotifyPropertyChanged
    {
      public ObservableCollection<WorkItem> WorkItems { get; }
      public ObservableCollection<string> ColumnItems { get; }
    }
    

    MainWindow.xaml

    <Window>
      <Window.DataContext>
        <MainViewModel />
      </Window.DataContext>
    
      <KanbanBoard ItemsSource="{Binding WorkItems}"
                   ColumnSource="{Binding ColumnItems}">
        <KanbanBoard.ItemContainerStyle>
          <Style TargetType="KanbanBoardItem">
    
            <!-- Connect item container to item 
                 to transfer data like column index -->
            <Setter Property="ColumnIndex" Value="{Binding Index, Mode=TwoWay}" />
          </Style>
        </KanbanBoard.ItemContainerStyle>
      </KanbanBoard>
    </Window>