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?
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...
KanbanBoard.ItemsSource
property of type IList
. The data type of the collection is unknown to the KanbanBoard
. This is the data part.KanbaBoard
. A column is defined by index and name.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
).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
.KanbanBoard
will adjust the KanbaBoardItem.ColumnIndex
property accordingly. It's important to always work on the container and not on the data items.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.
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.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).KanbanBoard.ItemContainerStyle
to each KanbanBoardColumn.ItemContainerStyle
property (see KanbanBorad
class pseudo code above).KanbaBoard.ItemTemplate
to each KanbanBoardColumn.Itemtemplate
property (see KanbanBorad
class pseudo code above).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>
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>