This article will show how to create a calendar from scratch. In case you don't need many functionalities or if you cannot use third-party libraries, then this article is for you.
Before we start, it would be better if you check your environment and ensure you have all the needed emulators.
First of all, let's create a new MAUI project:
dotnet new maui -n CustomCalendar
The next step is to create a calendar model. It is needed to store the date and the status of the current date.
public class CalendarModel : PropertyChangedModel
{
public DateTime Date { get; set; }
private bool _isCurrentDate;
public bool IsCurrentDate
{
get => _isCurrentDate;
set => SetField(ref _isCurrentDate, value);
}
}
You can see that CalendarModel class is inherited from PropertyChangedModel class. This is needed to listen if the IsCurrentDate property is changed. So, next, let's create PropertyChangedModel class in the same folder:
public class PropertyChangedModel : INotifyPropertyChanged
{
}
The PropertyChangedModel is inherited from the INotifyPropertyChanged interface. You should implement it. After implementation, you'll see this code:
public class PropertyChangedModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
}
Next step, you need to add a couple of methods for invoking and setting the IsCurrentDate property. Insert these methods into your class:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace CalendarMaui.Models;
public class PropertyChangedModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
OnPropertyChanged(propertyName);
}
}
Since I will be using key-value pairs instead of a normal collection for this reason I need to use an observable collection that does not exist. So I need to create my collection. I will inherit from ObservableCollection. Of course, you can choose not to create and use this ObservableCollection> type if you wish.
And so you should get this code:
using System.Collections.ObjectModel;
namespace CalendarMaui.Observable;
public class ObservableDictionary<TKey, TValue> : ObservableCollection<KeyValuePair<TKey, TValue>>
{
public void Add(TKey key, TValue value)
{
Add(new KeyValuePair<TKey, TValue>(key, value));
}
public bool Remove(TKey key)
{
var item = this.FirstOrDefault(i => i.Key.Equals(key));
return item.Key != null && Remove(item);
}
public bool ContainsKey(TKey key)
{
return this.Any(item => item.Key.Equals(key));
}
public void Clear()
{
ClearItems();
}
public bool TryGetValue(TKey key, out TValue value)
{
var item = this.FirstOrDefault(i => i.Key.Equals(key));
if (item.Key != null)
{
value = item.Value;
return true;
}
value = default(TValue);
return false;
}
public TValue this[TKey key]
{
get
{
var item = this.FirstOrDefault(i => i.Key.Equals(key));
return item.Value;
}
set
{
var item = this.FirstOrDefault(i => i.Key.Equals(key));
if (item.Key != null)
{
var index = IndexOf(item);
this[index] = new KeyValuePair<TKey, TValue>(key, value);
}
else
{
Add(new KeyValuePair<TKey, TValue>(key, value));
}
}
}
}
And now, let's go to creating the control. You can create a separate folder for it. You'll have something like this:
namespace CalendarMaui.CustomControls;
public partial class CalendarView
{
public CalendarView()
{
InitializeComponent();
}
}
Inject the BindingContext into the constructor:
public CalendarView()
{
InitializeComponent();
BindingContext = this;
}
In the following step, let's create a method where we'll bind the data, and also you need to make a couple of properties:
public ObservableDictionary<int, List<CalendarModel>> Weeks
{
get => (ObservableDictionary<int, List<CalendarModel>>)GetValue(WeeksProperty);
set => SetValue(WeeksProperty, value);
}
public DateTime SelectedDate
{
get => (DateTime)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}
private DateTime _bufferDate;
public static readonly BindableProperty WeeksProperty =
BindableProperty.Create(nameof(Weeks), typeof(ObservableDictionary<int, List<CalendarModel>>), typeof(CalendarView));
public void BindDates(DateTime date)
{
SetWeeks(date);
var choseDate = Weeks.SelectMany(x => x.Value).FirstOrDefault(f => f.Date.Date == date.Date);
if (choseDate != null)
{
choseDate.IsCurrentDate = true;
_bufferDate = choseDate.Date;
SelectedDate = choseDate.Date;
}
}
In this method, we set the current date after the start application and after when the date will change. The SelectedDate is needed when we select another or current date. The _bufferDate needs to pass the date inside the class. Next, let's create the SetWeeks method:
private void SetWeeks(DateTime date)
{
DateTime firstDayOfMonth = new DateTime(date.Year, date.Month, 1);
int daysInMonth = DateTime.DaysInMonth(date.Year, date.Month);
int weekNumber = 1;
if (Weeks is null)
{
Weeks = new ObservableDictionary<int, List<CalendarModel>>();
}
else
{
Weeks.Clear();
}
// Add days from previous month to first week
for (int i = 0; i < (int)firstDayOfMonth.DayOfWeek; i++)
{
DateTime firstDate = firstDayOfMonth.AddDays(-((int)firstDayOfMonth.DayOfWeek - i));
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = firstDate });
}
// Add days from current month
for (int day = 1; day <= daysInMonth; day++)
{
DateTime dateInMonth = new DateTime(date.Year, date.Month, day);
if (dateInMonth.DayOfWeek == DayOfWeek.Sunday && day != 1)
{
weekNumber++;
}
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = dateInMonth });
}
// Add days from next month to last week
DateTime lastDayOfMonth = new DateTime(date.Year, date.Month, daysInMonth);
for (int i = 1; i <= 6 - (int)lastDayOfMonth.DayOfWeek; i++)
{
DateTime lastDate = lastDayOfMonth.AddDays(i);
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = lastDate });
}
}
This method sets dates by weeks and sets transitions from the current month to the previous and the next month. The months don't always start on Sundays or Mondays. Any calendar should be a correct view. Like that:
Let's create properties that must handle when was selected the date and when the date was changed. For this, you should paste this code:Let's create properties that must handle when was selected the date and when the date was changed. For this, you should paste this code:
public static readonly BindableProperty SelectedDateProperty = BindableProperty.Create(
nameof(SelectedDate),
typeof(DateTime),
typeof(CalendarView),
DateTime.Now,
BindingMode.TwoWay,
propertyChanged: SelectedDatePropertyChanged);
private static void SelectedDatePropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
{
var controls = (CalendarView)bindable;
if (newvalue != null)
{
var newDate = (DateTime)newvalue;
if (controls._bufferDate.Month == newDate.Month && controls._bufferDate.Year == newDate.Year)
{
var currentDate = controls.Weeks.FirstOrDefault(f => f.Value.FirstOrDefault(x => x.Date == newDate.Date) != null).Value.FirstOrDefault(f => f.Date == newDate.Date);
if (currentDate != null)
{
controls.Weeks.ToList().ForEach(x => x.Value.ToList().ForEach(y => y.IsCurrentDate = false));
currentDate.IsCurrentDate = true;
}
}
else
{
controls.BindDates(newDate);
}
}
}
public static readonly BindableProperty SelectedDateCommandProperty = BindableProperty.Create(
nameof(SelectedDateCommand),
typeof(ICommand),
typeof(CalendarView));
public ICommand SelectedDateCommand
{
get => (ICommand)GetValue(SelectedDateCommandProperty);
set => SetValue(SelectedDateCommandProperty, value);
}
As you might see, I created the SelectedDateCommand property. With this command, we'll change the date. And now, let's implement the command:
public ICommand CurrentDateCommand => new Command<CalendarModel>((currentDate) =>
{
_bufferDate = currentDate.Date;
SelectedDate = currentDate.Date;
SelectedDateCommand?.Execute(currentDate.Date);
});
As you can see, we set the new value and execute the command. A similar approach with switch the month:
public ICommand NextMonthCommand => new Command(() =>
{
_bufferDate = _bufferDate.AddMonths(1);
BindDates(_bufferDate);
});
public ICommand PreviousMonthCommand => new Command(() =>
{
_bufferDate = _bufferDate.AddMonths(-1);
BindDates(_bufferDate);
});
Check your CalendarView class:
using System.Windows.Input;
using CalendarMaui.Models;
using CalendarMaui.Observable;
namespace CalendarMaui.CustomControls;
public partial class CalendarView
{
#region BindableProperty
public static readonly BindableProperty SelectedDateProperty = BindableProperty.Create(
nameof(SelectedDate),
typeof(DateTime),
typeof(CalendarView),
DateTime.Now,
BindingMode.TwoWay,
propertyChanged: SelectedDatePropertyChanged);
private static void SelectedDatePropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
{
var controls = (CalendarView)bindable;
if (newvalue != null)
{
var newDate = (DateTime)newvalue;
if (controls._bufferDate.Month == newDate.Month && controls._bufferDate.Year == newDate.Year)
{
var currentDate = controls.Weeks.FirstOrDefault(f => f.Value.FirstOrDefault(x => x.Date == newDate.Date) != null).Value.FirstOrDefault(f => f.Date == newDate.Date);
if (currentDate != null)
{
controls.Weeks.ToList().ForEach(x => x.Value.ToList().ForEach(y => y.IsCurrentDate = false));
currentDate.IsCurrentDate = true;
}
}
else
{
controls.BindDates(newDate);
}
}
}
//
public static readonly BindableProperty WeeksProperty =
BindableProperty.Create(nameof(Weeks), typeof(ObservableDictionary<int, List<CalendarModel>>), typeof(CalendarView));
public DateTime SelectedDate
{
get => (DateTime)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}
public ObservableDictionary<int, List<CalendarModel>> Weeks
{
get => (ObservableDictionary<int, List<CalendarModel>>)GetValue(WeeksProperty);
set => SetValue(WeeksProperty, value);
}
//
public static readonly BindableProperty SelectedDateCommandProperty = BindableProperty.Create(
nameof(SelectedDateCommand),
typeof(ICommand),
typeof(CalendarView));
public ICommand SelectedDateCommand
{
get => (ICommand)GetValue(SelectedDateCommandProperty);
set => SetValue(SelectedDateCommandProperty, value);
}
#endregion
//
private DateTime _bufferDate;
public CalendarView()
{
InitializeComponent();
BindDates(DateTime.Now);
BindingContext = this;
}
public void BindDates(DateTime date)
{
SetWeeks(date);
var choseDate = Weeks.SelectMany(x => x.Value).FirstOrDefault(f => f.Date.Date == date.Date);
if (choseDate != null)
{
choseDate.IsCurrentDate = true;
_bufferDate = choseDate.Date;
SelectedDate = choseDate.Date;
}
}
private void SetWeeks(DateTime date)
{
DateTime firstDayOfMonth = new DateTime(date.Year, date.Month, 1);
int daysInMonth = DateTime.DaysInMonth(date.Year, date.Month);
int weekNumber = 1;
if (Weeks is null)
{
Weeks = new ObservableDictionary<int, List<CalendarModel>>();
}
else
{
Weeks.Clear();
}
// Add days from previous month to first week
for (int i = 0; i < (int)firstDayOfMonth.DayOfWeek; i++)
{
DateTime firstDate = firstDayOfMonth.AddDays(-((int)firstDayOfMonth.DayOfWeek - i));
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = firstDate });
}
// Add days from current month
for (int day = 1; day <= daysInMonth; day++)
{
DateTime dateInMonth = new DateTime(date.Year, date.Month, day);
if (dateInMonth.DayOfWeek == DayOfWeek.Sunday && day != 1)
{
weekNumber++;
}
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = dateInMonth });
}
// Add days from next month to last week
DateTime lastDayOfMonth = new DateTime(date.Year, date.Month, daysInMonth);
for (int i = 1; i <= 6 - (int)lastDayOfMonth.DayOfWeek; i++)
{
DateTime lastDate = lastDayOfMonth.AddDays(i);
if (!Weeks.ContainsKey(weekNumber))
{
Weeks.Add(weekNumber, new List<CalendarModel>());
}
Weeks[weekNumber].Add(new CalendarModel { Date = lastDate });
}
}
#region Commands
public ICommand CurrentDateCommand => new Command<CalendarModel>((currentDate) =>
{
_bufferDate = currentDate.Date;
SelectedDate = currentDate.Date;
SelectedDateCommand?.Execute(currentDate.Date);
});
public ICommand NextMonthCommand => new Command(() =>
{
_bufferDate = _bufferDate.AddMonths(1);
BindDates(_bufferDate);
});
public ICommand PreviousMonthCommand => new Command(() =>
{
_bufferDate = _bufferDate.AddMonths(-1);
BindDates(_bufferDate);
});
#endregion
}
And now, we can start with creating the layout. For start, go to the XAML file of your control and paste this code:
<?xml version="1.0" encoding="utf-8" ?>
<StackLayout
Spacing="10"
x:Class="CalendarMaui.CustomControls.CalendarView"
x:Name="this"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Label
FontAttributes="Bold"
FontSize="22"
Grid.Column="0"
Text="{Binding Source={x:Reference this}, Path=SelectedDate, StringFormat='{0: MMM dd yyyy}'}" />
<StackLayout
Grid.Column="1"
HorizontalOptions="End"
Orientation="Horizontal"
Spacing="20">
<Image
HeightRequest="25"
Source="left.png"
WidthRequest="25">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=PreviousMonthCommand}" />
</Image.GestureRecognizers>
</Image>
<Image
HeightRequest="25"
Source="right.png"
WidthRequest="25">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=NextMonthCommand}" />
</Image.GestureRecognizers>
</Image>
</StackLayout>
</Grid>
<CollectionView ItemsSource="{Binding Source={x:Reference this}, Path=Weeks}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid
HeightRequest="65"
Padding="3"
RowSpacing="3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<CollectionView Grid.Column="1" ItemsSource="{Binding Value}">
<CollectionView.ItemsLayout>
<LinearItemsLayout ItemSpacing="1" Orientation="Horizontal" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate>
<Border
Background="#2B0B98"
HeightRequest="55"
Stroke="#C49B33"
StrokeShape="RoundRectangle 40,0,0,40"
StrokeThickness="4"
VerticalOptions="Start"
WidthRequest="55">
<VerticalStackLayout Padding="5">
<Label
FontSize="16"
HorizontalTextAlignment="Center"
Text="{Binding Date, StringFormat='{0:ddd}'}"
TextColor="White">
<Label.Triggers>
<DataTrigger
Binding="{Binding IsCurrentDate}"
TargetType="Label"
Value="true">
<Setter Property="TextColor" Value="Yellow" />
</DataTrigger>
</Label.Triggers>
</Label>
<Label
FontAttributes="Bold"
FontSize="12"
HorizontalTextAlignment="Center"
Text="{Binding Date, StringFormat='{0:d }'}"
TextColor="White">
<Label.Triggers>
<DataTrigger
Binding="{Binding IsCurrentDate}"
TargetType="Label"
Value="true">
<Setter Property="TextColor" Value="Yellow" />
</DataTrigger>
</Label.Triggers>
</Label>
</VerticalStackLayout>
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=CurrentDateCommand}" CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
Let me explain what we are doing here. First, I placed the layout in the StackLayout and set some properties. Second, I split the structure into two parts. The first part needs to switch months and show the current date. Finally, I placed it into Grid, and don't forget to add arrow pictures with the .png extension in Resources/Images.
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Label
FontAttributes="Bold"
FontSize="22"
Grid.Column="0"
Text="{Binding Source={x:Reference this}, Path=SelectedDate, StringFormat='{0: MMM dd yyyy}'}" />
<StackLayout
Grid.Column="1"
HorizontalOptions="End"
Orientation="Horizontal"
Spacing="20">
<Image
HeightRequest="25"
Source="left.png"
WidthRequest="25">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=PreviousMonthCommand}" />
</Image.GestureRecognizers>
</Image>
<Image
HeightRequest="25"
Source="right.png"
WidthRequest="25">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=NextMonthCommand}" />
</Image.GestureRecognizers>
</Image>
</StackLayout>
</Grid>
Third, I added a collection when we iterate our key-value pairs:
<CollectionView ItemsSource="{Binding Source={x:Reference this}, Path=Weeks}">
I also added the Grid into the collection and set properties. I placed the nested collection into the Grid, showing the parts of the calendar and set styles. Pay attention to this code:
<CollectionView.ItemsLayout>
<LinearItemsLayout ItemSpacing="1" Orientation="Horizontal" />
</CollectionView.ItemsLayout>
This is needed for days of each week to be shown horizontally. The labels contain this code:
<Label.Triggers>
<DataTrigger
Binding="{Binding IsCurrentDate}"
TargetType="Label"
Value="true">
<Setter Property="TextColor" Value="Yellow" />
</DataTrigger>
</Label.Triggers>
It needs to change the text color when the selected date is changed. In the Border block was added this code below. This is needed to select a date in the calendar.
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this}, Path=CurrentDateCommand}" CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
And finally, we need to inject our control into the main page. Into the MainPage.xaml.cs to the constructor, add this code:
calendar.SelectedDate = DateTime.Now;
And go to the layout and replace to this code:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="CalendarMaui.MainPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:customControls="clr-namespace:CalendarMaui.CustomControls"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<customControls:CalendarView Weeks="{Binding Weeks}" x:Name="calendar" />
</ContentPage>
You can launch the project if everything is correct and you have no errors.
I tested it on the Android emulator, and the calendar looks like that:
However, you'll most likely face difficulties on the iOS platform. I researched the issue for two days and decided that it is the bug when you'll be switching to the next or previous month. You might see an empty layout despite the date being updated at the top of the screen. If you rotate the screen around 360 degrees, you can see layout fragments of the following month.
You can see the screenshot of the current month on the iOS.
Let's switch to the next month. You can see that the month was changed at the top of the screen. However, the screen is empty.
If you rotate the screen, you'll see fragments of the calendar.
If I return to the start position, you'll see June month with distortion.
If you know what the problem, lets me know, I would be very appreciated. Or if you have any ideas, please write in comments.
The source code you can find by the link.
Happy coding!