russkyc/sortable-avalonia
MVVM sort, swap, and cross-collection transfer for Avalonia
Sortable.Avalonia - MVVM sort, swap, and cross-collection transfer for Avalonia
MVVM-first attached-behavior library for Avalonia ItemsControl enabling drag-and-drop reordering, cross-collection transfers, reversible drop operations, drag handles, sort/swap modes, and animated programmatic updates.
Note
Changes in Version 2.0.0
- Breaking change:
AnimationDurationnow usesTimeSpaninstead ofint(milliseconds). Update your XAML and code to use TimeSpan format (e.g.,0:0:0.500). - New feature: Release behavior with
ReleaseCommandandSortableReleaseEventArgsfor handling items released outside valid drop targets.
Overview
- Demo
- Features
- Installation
- Quickstart
- Item Display Layout (ItemsPanelTemplate)
- Core Concepts
- Properties Reference
- Event Arguments
- Mutation Helper Extensions
- Usage Patterns
- Transfer Modes
- Sortable Modes
- Drag Handles
- Animation Control
- Groups
- 18 Demo Scenarios
- Documentation
- Contributing
- License
Demo
Stack Panel (Horizontal Stack)
Kanban (Sort and Cross-collection drop)
Uniform Grid (Sort and Swap)
Features
| Capability | Description |
|---|---|
| Same-collection sorting | Reorder items within one list via Sortable property |
| Cross-collection transfers | Move/copy/swap items between lists via Droppable property |
| Reversible drops | Accept/reject drops in handler before commit |
| Transfer modes | Move, Copy, Swap |
| Sortable modes | Sort (shift), Swap (exchange) |
| Drag handles | Restrict drag start to marked controls |
| Animation | Smooth transitions for interactive + programmatic changes |
| Groups | Isolate interactions by group name |
| Mouse + Touch | Unified pointer input on all platforms |
| Backward compatible | Old TransferCommand still works |
Installation
Install via NuGet (Sortable.Avalonia):
.NET CLI
dotnet add package Sortable.AvaloniaPackage Manager Console
Install-Package Sortable.AvaloniaXAML namespace:
xmlns:sortable="clr-namespace:Sortable.Avalonia;assembly=Sortable.Avalonia"Quickstart
The control behavior is configured in XAML, while reorder/transfer decisions stay in your ViewModel via commands (UpdateCommand, DropCommand).
1. Sortable list (same-collection only, ViewModel-driven updates):
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.UpdateCommand="{Binding UpdateCmd}"
ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True" Cursor="Hand">
<TextBlock Text="{Binding Name}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>[RelayCommand]
void Update(SortableUpdateEventArgs e)
{
if (!e.ApplyUpdateMutation())
{
return;
}
Console.WriteLine($"Moved from {e.OldIndex} to {e.NewIndex}");
}2. Droppable targets (cross-collection, ViewModel acceptance):
<ItemsControl sortable:Sortable.Group="main"
sortable:Sortable.Droppable="True"
sortable:Sortable.DropCommand="{Binding DropCmd}"
ItemsSource="{Binding TargetItems}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsDroppable="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>[RelayCommand]
void Drop(SortableDropEventArgs e)
{
e.IsAccepted = ValidateItem(e.Item);
e.TransferMode = SortableTransferMode.Move;
var applied = e.ApplyDropMutation();
if (!applied)
{
Debug.WriteLine("Drop was rejected or produced no mutation.");
}
}3. Run demo app:
dotnet run --project .\Sortable.Avalonia.Demo\Sortable.Avalonia.Demo.csprojItem Display Layout (ItemsPanelTemplate)
Sortable works with any ItemsControl panel. You can change how items are displayed by overriding ItemsControl.ItemsPanel with an ItemsPanelTemplate.
Vertical stack (default list):
<ItemsControl sortable:Sortable.Sortable="True"
ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Horizontal stack (lane-style):
<ItemsControl sortable:Sortable.Sortable="True"
ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>Uniform grid (card board):
<ItemsControl sortable:Sortable.Sortable="True"
ItemsSource="{Binding Cards}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="3" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>Wrap panel (responsive flow):
<ItemsControl sortable:Sortable.Sortable="True"
ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>Tip: panel choice affects visual arrangement only; drag/drop behavior still depends on Sortable, Droppable, and item-level flags (IsSortable, IsDroppable), so layout stays in XAML while interaction rules stay in the ViewModel.
Properties Reference
ItemsControl Attached Properties
| Property | Type | Default | Description |
|---|---|---|---|
Sortable |
bool |
false |
Enable same-collection reordering |
Droppable |
bool |
false |
Enable cross-collection drop target |
Group |
string? |
null |
Group name (only same-group collections interact) |
UpdateCommand |
ICommand? |
null |
Fires on same-collection reorder |
DropCommand |
ICommand? |
null |
Fires on cross-collection drop (reversible) |
ReleaseCommand |
ICommand? |
null |
Fires when item is released outside any valid drop target |
TransferCommand |
ICommand? |
null |
Legacy cross-collection (auto-accept, deprecated) |
Mode |
SortableMode |
Sort |
In-collection behavior: Sort or Swap |
CrossCollectionTransferMode |
SortableTransferMode |
Move |
Default transfer mode: Move, Copy, or Swap |
AnimationDuration |
TimeSpan |
0:0:0.250 |
Animation duration (as TimeSpan, e.g. 0:0:0.500 for 500ms) |
Item Attached Properties
| Property | Type | Default | Description |
|---|---|---|---|
IsSortable |
bool |
false |
Item can be sorted within collection |
IsDroppable |
bool |
false |
Item can participate in drops |
IsDragHandle |
bool |
false |
Marks control as drag handle |
Event Arguments
SortableUpdateEventArgs
public class SortableUpdateEventArgs
{
public object? Item { get; set; } // Item being moved
public int OldIndex { get; set; } // Original index
public int NewIndex { get; set; } // Target index
public IList? SourceCollection { get; set; }
public SortableMode Mode { get; set; } // Sort or Swap
}Usage:
[RelayCommand]
void Update(SortableUpdateEventArgs e)
{
if (e.ApplyUpdateMutation())
{
Debug.WriteLine($"{e.Item}: {e.OldIndex} → {e.NewIndex}");
}
}SortableReleaseEventArgs
public class SortableReleaseEventArgs
{
public object? Item { get; set; } // Item released
public int OldIndex { get; set; } // Original index
public IList? SourceCollection { get; set; } // Collection item was dragged from
}Usage:
[RelayCommand]
void Release(SortableReleaseEventArgs e)
{
Debug.WriteLine($"Item '{e.Item}' released at index {e.OldIndex} from collection {e.SourceCollection}");
// Custom logic for when an item is released outside any valid drop target
}SortableDropEventArgs
public class SortableDropEventArgs
{
// Read-only context
public object? Item { get; set; }
public IList? SourceCollection { get; set; }
public IList? TargetCollection { get; set; }
public int OldIndex { get; set; }
public int NewIndex { get; set; }
// Handler control
public bool IsAccepted { get; set; } = true; // Accept/reject
public SortableTransferMode TransferMode { get; set; } = Move; // Move/Copy/Swap
public object? ModifiedItem { get; set; } // Optional clone/modified item
public object? GetItemToInsert() => ModifiedItem ?? Item;
}Usage:
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
e.IsAccepted = Validate(e.Item);
e.TransferMode = SortableTransferMode.Move;
if (!e.ApplyDropMutation())
{
Debug.WriteLine("Drop mutation was not applied.");
}
}Mutation Helper Extensions
The library provides extension methods on event args so you can delegate mutation mechanics while keeping full control of business rules and acceptance logic.
Three approaches:
1. Helper-driven (simplest)
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
e.IsAccepted = true;
e.TransferMode = SortableTransferMode.Move;
if (!e.ApplyDropMutation())
{
Debug.WriteLine("Mutation failed or was rejected.");
}
}2. Absolute control (manual mutations)
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
if (e.Item is not TaskItem task || !ValidateBusinessRules(task))
{
e.IsAccepted = false;
return;
}
e.IsAccepted = true;
e.TransferMode = SortableTransferMode.Move;
// Manually orchestrate the mutation with your exact logic
e.TargetCollection.Insert(e.NewIndex, task);
e.SourceCollection.RemoveAt(e.OldIndex);
LogCustomTelemetry(task, e.SourceCollection, e.TargetCollection);
}3. Hybrid (recommended for most apps)
Validate domain rules yourself, delegate low-level list operations to the helper.
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
if (e.Item is not KanbanCard card)
{
return;
}
var sourceColumn = FindColumn(e.SourceCollection);
var targetColumn = FindColumn(e.TargetCollection);
if (sourceColumn == null || targetColumn == null)
{
return;
}
// Domain rule: prevent duplicates
if (targetColumn.Items.Contains(card))
{
e.IsAccepted = false;
return;
}
e.IsAccepted = true;
e.TransferMode = SortableTransferMode.Move;
// Let helper handle the mutation
if (!e.ApplyDropMutation())
{
return;
}
// Post-mutation side effects
Console.WriteLine($"Moved '{card.Title}' from {sourceColumn.Name} to {targetColumn.Name}");
NotifyTeam(card, targetColumn);
}Return values:
e.ApplyUpdateMutation()→bool(true if mutation applied)e.ApplyDropMutation()→bool(true if mutation applied)
Use the return value to gate logging, telemetry, or conditional side effects.
SortableTransferEventArgs (Deprecated)
public class SortableTransferEventArgs
{
public object? Item { get; set; }
public IList? SourceCollection { get; set; }
public IList? TargetCollection { get; set; }
public int OldIndex { get; set; }
public int NewIndex { get; set; }
}Use DropCommand with SortableDropEventArgs instead.
Usage Patterns
For panel/layout examples (StackPanel, UniformGrid, WrapPanel), see Item Display Layout (ItemsPanelTemplate).
Pattern 1: Sortable-only list
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.UpdateCommand="{Binding UpdateCmd}"
ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True" Cursor="Hand">
<TextBlock Text="{Binding}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Result: Reorder within list ✓, transfer between lists ✗
Pattern 2: Droppable-only zones
<ItemsControl sortable:Sortable.Group="zone"
sortable:Sortable.Droppable="True"
sortable:Sortable.DropCommand="{Binding DropCmd}"
ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsDroppable="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Result: Transfer between zones ✓, reorder within zone ✗
Pattern 3: Full dual-mode
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.Droppable="True"
sortable:Sortable.Group="shared"
sortable:Sortable.UpdateCommand="{Binding UpdateCmd}"
sortable:Sortable.DropCommand="{Binding DropCmd}"
ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True"
sortable:Sortable.IsDroppable="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Result: Reorder within list ✓, transfer between lists ✓
Pattern 4: Conditional acceptance
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
if (e.Item is TaskItem task)
{
// Business rule validation
e.IsAccepted = task.Priority == Priority.Urgent
&& !TargetContains(task)
&& UserHasPermission();
e.TransferMode = SortableTransferMode.Move;
_ = e.ApplyDropMutation();
}
}Pattern 5: Copy mode (duplicate on transfer)
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
if (e.Item is TemplateItem original)
{
e.ModifiedItem = new TemplateItem(original) { Id = Guid.NewGuid() };
e.TransferMode = SortableTransferMode.Copy;
e.IsAccepted = true;
_ = e.ApplyDropMutation();
}
}Pattern 6: Prevent duplicates
[RelayCommand]
void Drop(SortableDropEventArgs e)
{
var target = e.TargetCollection as ObservableCollection<MyItem>;
var item = e.Item as MyItem;
e.IsAccepted = target?.Any(x => x.Id == item?.Id) != true;
e.TransferMode = SortableTransferMode.Move;
_ = e.ApplyDropMutation();
}Pattern 7: Cross-collection swap
<ItemsControl sortable:Sortable.Group="rotation"
sortable:Sortable.Droppable="True"
sortable:Sortable.CrossCollectionTransferMode="Swap"
sortable:Sortable.DropCommand="{Binding DropCmd}"
ItemsSource="{Binding TeamA}" />
<ItemsControl sortable:Sortable.Group="rotation"
sortable:Sortable.Droppable="True"
sortable:Sortable.CrossCollectionTransferMode="Swap"
sortable:Sortable.DropCommand="{Binding DropCmd}"
ItemsSource="{Binding TeamB}" />[RelayCommand]
void Drop(SortableDropEventArgs e)
{
e.TransferMode = SortableTransferMode.Swap; // Exchange items
e.IsAccepted = true;
_ = e.ApplyDropMutation();
}Pattern 8: Drag handles
<ItemsControl sortable:Sortable.Sortable="True" ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Drag handle -->
<PathIcon Grid.Column="0"
sortable:Sortable.IsDragHandle="True"
Data="M8 2v20M16 2v20"
Cursor="Hand" />
<!-- Interactive content -->
<TextBlock Grid.Column="1" Text="{Binding Name}" />
<Button Grid.Column="2" Content="Edit" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Result: Only drag via handle icon. Text and button remain clickable.
Pattern 9: Same-collection swap mode
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.Mode="Swap"
sortable:Sortable.UpdateCommand="{Binding UpdateCmd}"
ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Result: Items swap positions instead of shifting.
Pattern 10: Custom animation speed
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.AnimationDuration="0:0:0.500"
ItemsSource="{Binding Items}">
<!-- Slower 500ms animations -->
</ItemsControl>
<ItemsControl sortable:Sortable.Sortable="True"
sortable:Sortable.AnimationDuration="0:0:0.100"
ItemsSource="{Binding Items}">
<!-- Faster 100ms animations -->
</ItemsControl>Transfer Modes
| Mode | Behavior | Source | Target | Example Use Case |
|---|---|---|---|---|
Move |
Remove from source, add to target | Item removed | Item added | Task workflow, file organization |
Copy |
Keep in source, add to target | Item stays | Copy added | Template duplication, reference sharing |
Swap |
Exchange items | Gets target item | Gets source item | Role rotation, seat assignment |
Set via:
e.TransferMode = SortableTransferMode.Move; // or .Copy or .SwapOr set default for preview animations:
sortable:Sortable.CrossCollectionTransferMode="Swap"Sortable Modes
| Mode | Behavior | Use Case |
|---|---|---|
Sort |
Shift items, insert at drop position | Priority queues, task ordering |
Swap |
Exchange positions with drop target | Role swaps, seat assignments |
Set via:
sortable:Sortable.Mode="Sort" <!-- Default -->
sortable:Sortable.Mode="Swap"Drag Handles
Restrict drag start to specific controls:
<ItemsControl sortable:Sortable.Sortable="True" ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border sortable:Sortable.IsSortable="True">
<Grid ColumnDefinitions="Auto,*">
<PathIcon sortable:Sortable.IsDragHandle="True"
Data="M8 2v20M16 2v20"
Cursor="Hand" />
<TextBlock Grid.Column="1" Text="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>Behavior: Drag only starts from IsDragHandle="True" controls. Text, buttons, etc. remain clickable.
Animation Control
Set duration (TimeSpan):
sortable:Sortable.AnimationDuration="0:0:0.500" <!-- Default: 0:0:0.250 -->Applies to:
- Interactive drag previews (items shifting during drag)
- Programmatic collection changes
- Cross-collection travel animations
Groups
Isolate interactions:
<!-- HR group -->
<ItemsControl sortable:Sortable.Group="hr" sortable:Sortable.Droppable="True" />
<ItemsControl sortable:Sortable.Group="hr" sortable:Sortable.Droppable="True" />
<!-- Engineering group (separate) -->
<ItemsControl sortable:Sortable.Group="eng" sortable:Sortable.Droppable="True" />Rule: Items only transfer between collections with matching Group values.
18 Demo Scenarios
Run the app to explore each demo in a dedicated tab:
| # | Demo | Description |
|---|---|---|
| 1 | Kanban Board | Task cards moving through triage → engineering → release columns |
| 2 | Vertical List | Simple sortable task list with reordering |
| 3 | Drag Handle | Items with icon handles, leaving text/buttons clickable |
| 4 | Grid Layout | Card-based layout with drag-and-drop in grid arrangement |
| 5 | Horizontal Stack | Horizontal lane with left-to-right item ordering |
| 6 | Multiple Groups | Separate HR and Engineering groups (isolated interactions) |
| 7 | Disabled Items | Some items locked (non-draggable) via conditional IsSortable |
| 8 | Sortable Only | Lists allow reordering but reject cross-list transfers |
| 9 | Droppable Only | Drop zones accept items but prevent internal reordering |
| 10 | Cross Drag (Instant) | Instant cross-collection transfers (no animation delay) |
| 11 | Cross Programmatic Animation | Programmatic ObservableCollection changes with smooth travel animation |
| 12 | UniformGrid Interaction | Interactive drag in UniformGrid panel |
| 13 | UniformGrid Programmatic | Programmatic changes in UniformGrid with animation |
| 14 | Copy Mode | Template items duplicated (not moved) on transfer |
| 15 | Conditional Acceptance | Drop validation rules (e.g., only URGENT tasks accepted) |
| 16 | Sort Mode | Default shift-based reordering within same list |
| 17 | Swap Mode | Exchange positions (no shifting) within same list |
| 18 | Cross Swap | Exchange items between two collections in one gesture |
Run:
dotnet run --project .\Sortable.Avalonia.Demo\Sortable.Avalonia.Demo.csprojCore Concepts
MVVM-First Workflow
- View (XAML): Declare
Sortableattached properties,ItemsSource, and command bindings. - ViewModel: Validate rules, choose transfer/sort behavior, and apply mutations via event-arg helpers.
- Model: Remains plain data; no drag/drop UI logic required.
Sortable vs Droppable
| Property | Purpose | Drag Within List | Drag Between Lists |
|---|---|---|---|
Sortable="True" |
Enable reordering | ✓ | ✗ |
Droppable="True" |
Enable drop target | ✗ | ✓ |
Both ="True" |
Enable both | ✓ | ✓ |
Item-Level Control
| Scenario | IsSortable | IsDroppable |
|---|---|---|
| Can reorder and transfer | ✓ | ✓ |
| Can reorder only | ✓ | ✗ |
| Can transfer only | ✗ | ✓ |
| Locked (no drag) | ✗ | ✗ |
Commands
| Command | Fires When | Event Args | Purpose |
|---|---|---|---|
UpdateCommand |
Same-collection reorder | SortableUpdateEventArgs |
ViewModel handles reorder rules and mutation |
DropCommand |
Cross-collection drop | SortableDropEventArgs |
ViewModel accepts/rejects and selects transfer mode |
ReleaseCommand |
Release outside drop target | SortableReleaseEventArgs |
ViewModel handles item release outside valid drop zone |
TransferCommand |
Cross-collection transfer | SortableTransferEventArgs |
Legacy fallback (auto-accept), prefer DropCommand |
Contributing
Contributions are welcome.
- Open an issue for bug reports or feature proposals.
- Share repro steps and expected behavior for drag/drop issues.
- Keep PRs focused and include before/after behavior notes.
License
This project is licensed under the MIT License. See LICENSE for details.



