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...
- 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.
- 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
).
- transitions an item by drag&drop. Transitioning changes the column index of the work item.
- 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.
- 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.
- We add a
LsitBox´ to the
ControlTemplateof the
KanbanBoard. This
ListBoxis used to dynamically create a
KanbanBoardColumncontrols based on the
KanbanBoard.ColumnSource collection property. The client code can define an optional [
StyleSelector`]1 in order to customize columns individually (e.g. colorize workflow states).
- We assign the
KanbanBoard.ItemContainerStyle
to each KanbanBoardColumn.ItemContainerStyle
property (see KanbanBorad
class pseudo code above).
- We assign the
KanbaBoard.ItemTemplate
to each KanbanBoardColumn.Itemtemplate
property (see KanbanBorad
class pseudo code above).
- 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>