Creating Kanban Board using Xamarin Forms 5

Creating Kanban Board using Xamarin Forms 5

22 February 2021 10:41:20 AM

MAUI/Xamarin

Share:

Hi there!

I devoted this week to creating a small utility - Kanban board. I will skip steps for creating a local database and will focus on Xamarin controls.

So let's create the app step by step:

1. Create a blank Xamarin.Forms project

2. Create models in the shared project

  • Card.cs
public class Card
{
	public int Id { get; set; }
	public string Name { get; set; }
	public string Description { get; set; }
	public int Order { get; set; }
	public int ColumnId { get; set; }
	public Column Column { get; set; }
}
  • Column.cs
public class Column
{
	public Column()
	{
		Cards = new ObservableCollection<Card>();
	}

	public int Id { get; set; }
	public string Name { get; set; }
	public int Wip { get; set; } = int.MaxValue;
	public ICollection<Card> Cards { get; set; }
	public int Order { get; set; }
}
  • ColumnInfo.cs
public class ColumnInfo
{
	public ColumnInfo(int index, Column column)
	{
		Index = index;
		Column = column;
	}

	public Column Column { get; }
	public int Index { get; }
	public bool IsWipReached => Column.Cards.Count >= Column.Wip;
}

3. It's time to create a ViewModel. Create a new file MainPageViewModel.cs. (I use Refractored.MvvmHelpers for BaseViewModel)

public class MainPageViewModel : MvvmHelpers.BaseViewModel
	{
		private ObservableCollection<ColumnInfo> _columns;
		private int _position;
		private Card _dragCard;

		public MainPageViewModel()
		{
			RefreshCommand.Execute(null);
		}

		public ICommand RefreshCommand => new Command(UpdateCollection);

		public ICommand DropCommand => new Command<ColumnInfo>(columnInfo =>
		{
			if (_dragCard is not null && columnInfo.Column.Cards.Count < columnInfo.Column.Wip)
			{
				// Update you card here store info in the database.
				// UPDATE Cards SET ColumnId = _dragCard.ColumnId WHERE Id = _dragCard.Id;
				
				UpdateCollection();
				Position = columnInfo.Index; // Set CarouselView position
			}
		});

		public ICommand DragStartingCommand => new Command<Card>(card =>
		{
			_dragCard = card; // Store our card
		});

		public ICommand DropCompletedCommand => new Command(() =>
		{
			_dragCard = null; // Reset the card
		});

		public ICommand AddColumn => new Command(() =>
		{
			// Add new column here.
			// INSERT INTO Columns (Name, Wip) VALUES ("Name", 5);
			UpdateCollection();
		});

		public ICommand AddCard => new Command<int>(columnId =>
		{
			// Add new card here. You can also check if WIP is reached.
			// INSERT INTO Cards (Name, ColumnId) VALUES ("Name", columnId);
			UpdateCollection();
		});

		public ICommand DeleteCard => new Command<Card>(async card =>
		{
			var result = await Application.Current.MainPage.DisplayAlert("Delete card", $"Do you want to delete card \"{card.Name}\"?", "Yes", "No");
			if (!result)
			{
				return;
			}

			// Delete card from the database
			// DELETE FROM Cards WHERE Id=card.Id

			UpdateCollection();
		});

		public ICommand DeleteColumn => new Command<ColumnInfo>(async columnInfo =>
		{
			var result = await Application.Current.MainPage.DisplayAlert("Delete column", $"Do you want to delete column \"{columnInfo.Column.Name}\" and all its cards?", "Yes", "No");
			if (!result)
			{
				return;
			}

			// Delete column from the database
			// DELETE FROM Columns WHERE Id=columnInfo.Column.Id

			UpdateCollection();
		});

		public ObservableCollection<ColumnInfo> Columns
		{
			get => _columns;
			set => SetProperty(ref _columns, value);
		}

		public int Position
		{
			get => _position;
			set => SetProperty(ref _position, value);
		}

		private void UpdateCollection()
		{
			IsBusy = true;
			using (var db = new ApplicationContext(App.DbPath))
			{
				Columns = db.Columns.Include(c => c.Cards)
				                                        .OrderBy(c => c.Order)
				                                        .ToList()
				                                        .Select(OrderCards)
				                                        .ToObservableCollection();
				Position = 0;
			}

			IsBusy = false;
		}
		
		private static ColumnInfo OrderCards(Column c, int columnNumber)
		{
			c.Cards = c.Cards.OrderBy(card => card.Order).ToList();
			return new ColumnInfo(columnNumber, c);
		}
	}

4. Finally the UI part.

4.1. Add base style and set the view model for the MainPage.

<ContentPage.Resources>
	<Color x:Key="CardBackgroundColor">White</Color>
	<Color x:Key="BoardBackgroundColor">DimGray</Color>
	<Color x:Key="BoardTitleFontColor">White</Color>
	<Color x:Key="FontColor">Black</Color>
	<Color x:Key="ColumnBackgroundColor">DarkGray</Color>
	<Color x:Key="ColumnWipReachedBackgroundColor">IndianRed</Color>
	<x:Double x:Key="BoardTitleSize">20</x:Double>
	<x:Double x:Key="CardTitleSize">14</x:Double>
	<x:Double x:Key="CardDescriptionSize">12</x:Double>
	<x:Double x:Key="ColumnTitleSize">18</x:Double>
	<OnPlatform x:Key="FontAwesomeSolid" x:TypeArguments="x:String">
		<On Platform="Android" Value="FontAwesome5Solid.otf#Regular" />
		<On Platform="iOS" Value="FontAwesome5Free-Solid" />
	</OnPlatform>
</ContentPage.Resources>
<ContentPage.BindingContext>
	<kanbanboard:MainPageViewModel />
</ContentPage.BindingContext>

4.2. Add the RefreshView so we can swipe to update the content.

<RefreshView Command="{Binding RefreshCommand}"
		        IsRefreshing="{Binding IsBusy}">
...
<RefreshView>
<StackLayout BackgroundColor="{DynamicResource BoardBackgroundColor}">
	<Label
		FontSize="{StaticResource BoardTitleSize}"
		HorizontalOptions="Center"
		Text="Test project name"
		TextColor="{DynamicResource BoardTitleFontColor}" />

	<!-- The Kanban board will be here -->

	<Button
		BackgroundColor="Transparent"
		Command="{Binding AddColumn}"
		Text="Add new column" />
</StackLayout>

4.4. We will use CarouselView for Columns.

<CarouselView
	x:Name="Board"
	IndicatorView="Indicator"
	EmptyView="No columns to display"
	ItemsSource="{Binding Columns}"
	Position="{Binding Position}"
	Loop="False"
	PeekAreaInsets="10">
		<CarouselView.ItemsLayout>
			<LinearItemsLayout ItemSpacing="10" Orientation="Horizontal" />
		</CarouselView.ItemsLayout>
		<CarouselView.ItemTemplate>
			<DataTemplate>
				<!-- We will add a template with the next step -->
			</DataTemplate>
		</CarouselView.ItemTemplate>
</CarouselView>

<IndicatorView
	x:Name="Indicator"
	HorizontalOptions="Center"
	IndicatorColor="White"
	SelectedIndicatorColor="Black" />

4.5. Let's create a Column template

<Frame Padding="0" CornerRadius="10">
	<Frame.GestureRecognizers>
		<DropGestureRecognizer AllowDrop="True"
	                       DropCommand="{Binding BindingContext.DropCommand, Source={x:Reference Board}}"
	                       DropCommandParameter="{Binding .}"/>
	</Frame.GestureRecognizers>

	<Frame.Triggers>
		<DataTrigger
			Binding="{Binding IsWipReached}"
			TargetType="Frame"
			Value="True">
				<Setter Property="BackgroundColor" Value="{DynamicResource ColumnWipReachedBackgroundColor}" />
		</DataTrigger>

		<DataTrigger
			Binding="{Binding IsWipReached}"
			TargetType="Frame"
			Value="False">
				<Setter Property="BackgroundColor" Value="{DynamicResource ColumnBackgroundColor}" />
		</DataTrigger>
	</Frame.Triggers>

	<StackLayout Margin="10">
		<StackLayout Margin="10" Orientation="Horizontal">
			<Label
				FontAttributes="Bold"
				FontSize="{StaticResource ColumnTitleSize}"
				HorizontalOptions="Start"
				Text="{Binding Column.Name}"
				VerticalOptions="Center" />
			<Label
				FontSize="{StaticResource ColumnTitleSize}"
				HorizontalOptions="CenterAndExpand"
				VerticalOptions="Center">
					<Label.FormattedText>
						<FormattedString>
							<Span Text="{Binding Column.Cards.Count}" />
							<Span>/</Span>
							<Span Text="{Binding Column.Wip}" />
						</FormattedString>
					</Label.FormattedText>
			</Label>
			<ImageButton
				Command="{Binding BindingContext.DeleteColumn, Source={x:Reference Board}}"
				CommandParameter="{Binding .}"
				HorizontalOptions="EndAndExpand"
				VerticalOptions="Center">
					<ImageButton.Source>
						<FontImageSource
							FontFamily="{StaticResource FontAwesomeSolid}"
							Glyph="&#xf2ed;"
							Size="{StaticResource ColumnTitleSize}"
							Color="{DynamicResource FontColor}" />
					</ImageButton.Source>
			</ImageButton>
		</StackLayout>
		
		<CollectionView EmptyView="No cards to display" ItemsSource="{Binding Column.Cards}">
			<CollectionView.ItemsLayout>
				<LinearItemsLayout ItemSpacing="5" Orientation="Vertical" />
			</CollectionView.ItemsLayout>
			<CollectionView.ItemTemplate>
				<DataTemplate>
					<!-- Card template will be here -->
				</DataTemplate>
			</CollectionView.ItemTemplate>
		</CollectionView>

		<Button
			BackgroundColor="Transparent"
			Command="{Binding BindingContext.AddCard, Source={x:Reference Board}}"
			CommandParameter="{Binding Column.Id}"
			Text="Add new card" />
	</StackLayout>
</Frame>

4.6 The Final part - Card Template

<Frame
	Margin="5,0"
	Padding="0"
	BackgroundColor="{DynamicResource CardBackgroundColor}"
	CornerRadius="10">
		<Frame.GestureRecognizers>
			<DragGestureRecognizer CanDrag="True"
				   DragStartingCommand="{Binding BindingContext.DragStartingCommand, Source={x:Reference Board}}"
				   DragStartingCommandParameter="{Binding .}"
				   DropCompletedCommand="{Binding BindingContext.DropCompletedCommand, Source={x:Reference Board}}"/>
		</Frame.GestureRecognizers>
		
		<Grid Margin="10">
			<Grid.RowDefinitions>
				<RowDefinition Height="Auto" />
				<RowDefinition Height="Auto" />
			</Grid.RowDefinitions>
			<Grid.ColumnDefinitions>
				<ColumnDefinition Width="*" />
				<ColumnDefinition Width="Auto" />
			</Grid.ColumnDefinitions>
			<Label
				Grid.Column="0"
				FontAttributes="Bold"
				FontSize="{StaticResource CardTitleSize}"
				Text="{Binding Name}" />
			<Label
				Grid.Row="1"
				Grid.Column="0"
				FontSize="{StaticResource CardDescriptionSize}"
				Text="{Binding Description}"
				VerticalOptions="End" />
			<ImageButton
				Grid.Row="0"
				Grid.RowSpan="2"
				Grid.Column="1"
				Command="{Binding BindingContext.DeleteCard, Source={x:Reference Board}}"
				CommandParameter="{Binding .}"
				WidthRequest="{StaticResource CardTitleSize}"
				HeightRequest="{StaticResource CardTitleSize}"
				HorizontalOptions="EndAndExpand">
					<ImageButton.Source>
						<FontImageSource
							FontFamily="{StaticResource FontAwesomeSolid}"
							Glyph="&#xf2ed;"
							Size="{StaticResource CardTitleSize}"
							Color="{DynamicResource FontColor}" />
					</ImageButton.Source>
			</ImageButton>
		</Grid>
</Frame>

The result looks like this:

Drag & Drop

WIP is reached

The full source code is available on GitHub: https://github.com/VladislavAntonyuk/KanbanBoard.

Related:

The first project with .NET MAUI

The first project with .NET MAUI

How to migrate Xamarin.Forms app to .NET MAUI step by step

How to show SnackBar and Toast using Xamarin Community Toolkit

How to show SnackBar and Toast using Xamarin Community Toolkit

Demonstrate how to configure SnackBar and Toast using Xamarin Community Toolkit

An error has occurred. This application may no longer respond until reloaded. Reload 🗙