DEV Community

Serhii Korol
Serhii Korol

Posted on

Calendar Control from scratch on .NET MAUI

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
{

}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Inject the BindingContext into the constructor:

public CalendarView()
{
    InitializeComponent();
    BindingContext = this;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

The calendar example

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);
}

Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Third, I added a collection when we iterate our key-value pairs:

<CollectionView ItemsSource="{Binding Source={x:Reference this}, Path=Weeks}">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

The calendar screenshot on Android

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.

The current month on 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.

The empty screen on iOS

If you rotate the screen, you'll see fragments of the calendar.

The 90 degrees rotate

If I return to the start position, you'll see June month with distortion.

The start position on iOS

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!

Buy Me A Beer

Top comments (1)

Collapse
 
begemotkin profile image
Begafix

Hi. I think you can rewrite your xaml with FlexLayout. I have some idea about it.