TECH

Customized Calendar in Flutter

Hey, have you ever wanted a date-picker in your app but not in a dialog box? And you tried all the dart packages out there but cannot change their UIs

Hitesh Verma May 28, 2023 · 9 min. read

Hey, have you ever wanted a date-picker in your app but not in a dialog box? And you tried all the dart packages out there but cannot change their UIs as per your requirements? I had struck on these problems too! And came out with a solution to build a custom calendar and want to share it with you all :-)

Customized calender in flutter
Customized calender in flutter

So, let’s begin our journey! First, create a new flutter project or use an existing one.

Ok, first of all, let’s figure out what data is required. So, the calendar is all about dates, Right? We have to calculate the days of a month in a year and on which weekdays they occur. That’s pretty much all of the calculations we required. Along with this, we might want to show unfilled weekdays at the start and end of the month’s calendar with the dates of the previous and next month respectively.

Create a model class

Here I have created a model class for each day to contain all this information.

class Calendar{
  final DateTime date;
  final bool thisMonth;
  final bool prevMonth;
  final bool nextMonth;
Calendar({
    this.date,
    this.thisMonth = false,
    this.prevMonth = false,
    this.nextMonth = false
  });
}         

Create a class for all calendar-related methods

class CustomCalendar{
  // number of days in month 
  //[JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC]
  final List<int> _monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

  /// check for leap year
  bool _isLeapYear(int year){
    if(year % 4 == 0){
      if(year % 100 == 0){
        if(year % 400 == 0) return true;
        return false;
      }
      return true;
    }
    return false;
  }
}

This class contains a list of days for each month in order. I have used 28 days for February, but don’t worry it will be handled in later stages. Also, it contains a method for checking whether a year is a leap or not, which is a universal known formula for checking :-)

For some, weeks may start from SUNDAY and for others from MONDAY.

enum StartWeekDay {sunday, monday}

Now the complex calculations begin! Complex or not you can decide at the end :-)

Get date with its week day of a month

Here is a method that will calculate all the days of a month in a year with their occurrence in weekdays, place them in sequence as to display in the month’s calendar, and returns the list of sequence.

// get the month calendar
/// month is between from 1-12 (1 for January and 12 for December)
List<Calendar> getMonthCalendar(
       int month, 
       int year, {
       StartWeekDay startWeekDay = StartWeekDay.sunday,
}){
       // validate
       if(year == null || month == null || month < 1 || month > 12){
              throw ArgumentError('Invalid year or month');
       }

       List<Calendar> calendar = List<Calendar>();

       // get no. of days in the month
       // month-1 because _monthDays starts from index 0 
       // and month starts from 1
       int totalDays = _monthDays[month - 1];

       // if this is a leap year and the month is february, 
       // increment the total days by 1
       if(_isLeapYear(year) && month == DateTime.february) totalDays++;

       // get days for this month
       for(int i=0; i<totalDays; i++){
              calendar.add(Calendar(
                     // i+1 because day starts from 1 in DateTime class
                     date: DateTime(year, month, i+1),
                     thisMonth: true,
              ));
         }
}

In this class, there is a list called calendar which will return us all the days of the month in a sequence and it is of type Calendar which is a model class we have created as our first step.

Well, that’s it! We got all the days in a sequence. Now we are done, right? Yeah, but every month won’t start from Monday or Sunday and ends with Sunday or Saturday. NEVER! We need to fill those missing spots at the start and at the end with previous and next month days respectively.

Add previous and next month's dates in the unfilled positions at the start & end respectively

Here is the calculation to fill missing spots at the start with the previous month’s days. Place these lines of code, right before the closing of the getMonthCalendar method.

// used for previous and next month's calendar days
int otherYear;
int otherMonth;
int leftDays;

// fill the unfilled starting weekdays of this month 
// with the previous month days
if(
 (startWeekDay == StartWeekDay.sunday && 
   calendar.first.date.weekday != DateTime.sunday) ||
 (startWeekDay == StartWeekDay.monday &&  
   calendar.first.date.weekday != DateTime.monday)
 ){
   // if this month is january, 
   // then previous month would be decemeber of previous year
   if(month == DateTime.january){
     otherMonth = DateTime.december;
     otherYear = year-1;
   }
   else{
    otherMonth = month - 1;
    otherYear = year;
   }

   // month-1 because _monthDays starts from index 0 
   // and month starts from 1
   totalDays = _monthDays[otherMonth - 1];
   if(_isLeapYear(otherYear) && otherMonth == DateTime.february)
      totalDays++;
   leftDays = totalDays - calendar.first.date.weekday + 
     ((startWeekDay == StartWeekDay.sunday) ? 0 : 1);
      
   for(int i=totalDays; i>leftDays; i--){
     // add days to the start of the list to maintain the sequence
     calendar.insert(0,
       Calendar(
         date: DateTime(otherYear, otherMonth, i),
         prevMonth: true,
        ),
     );
   }
}

That was hell a lot of conditions and calculations. But I did my best on putting comments to the code to make it understandable. You may be wondering how the leftDays are calculated, but honestly, I did it in a copy with a pen and got the pattern right and now don’t have an explanation for that. But I assure you that it was simple and everyone can easily figure out this pattern (and even better than it), just give it a try!

Now moving to the other part of the calculation and the last one, to fill the missing spots at the end with the days of next month. Place these lines of code, right before the closing of the getMonthCalendar method.

// fill the unfilled ending weekdays of this month 
// with the next month days
if(
  (startWeekDay == StartWeekDay.sunday && 
    calendar.last.date.weekday != DateTime.saturday) ||
  (startWeekDay == StartWeekDay.monday && 
    calendar.last.date.weekday != DateTime.sunday)
){
  // if this month is december, 
  // then next month would be january of next year
  if(month == DateTime.december){
    otherMonth = DateTime.january;
    otherYear = year+1;
  }
  else{
    otherMonth = month+1;
    otherYear = year;
  }
  // month-1 because _monthDays starts from index 0 
  // and month starts from 1
  totalDays = _monthDays[otherMonth-1];
  if(_isLeapYear(otherYear) && otherMonth == DateTime.february)  
    totalDays++;
  leftDays = 7 - calendar.last.date.weekday - 
    ((startWeekDay == StartWeekDay.sunday) ? 1 : 0);
  if(leftDays == -1) leftDays = 6;
  for(int i=0; i<leftDays; i++){
    calendar.add(
      Calendar(
        date: DateTime(otherYear, otherMonth, i+1),
        nextMonth: true,
      ),
    );
  }
}

Now just return this list of our calendar else all our hard work will go to vain. Place these lines of code, right before the closing of the getMonthCalendar method. return calendar;

That was all our calculation part! Now let’s do some UI stuff :-)

Set the variables and do some initState job

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
  DateTime _currentDateTime;
  DateTime _selectedDateTime;
  List<Calendar> _sequentialDates;
  int midYear;
  final List<String> _weekDays = 
    ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
  final List<String> _monthNames = 
    ['January', 'February', 'March', 'April', 'May', 'June', 'July',  
     'August', 'September', 'October', 'November', 'December'];
  @override
  void initState() {
    super.initState();
    final date = DateTime.now();
    _currentDateTime = DateTime(date.year, date.month);
    _selectedDateTime = DateTime(date.year, date.month, date.day);
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
     setState(() => _getCalendar());
    });
  }
  // get calendar for current month
  void _getCalendar(){
    _sequentialDates = 
      CustomCalendar().getMonthCalendar(_currentDateTime.month,   
        _currentDateTime.year, startWeekDay: StartWeekDay.monday);
  }
  @verride
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          margin: EdgeInsets.all(16),
          padding: EdgeInsets.all(16),
          height: MediaQuery.of(context).size.height * 0.6,
          decoration: BoxDecoration(
            color: Colors.black,
            borderRadius: BorderRadius.circular(20),
          ),
          child: _datesView() // to be added in next step
        ),
      ),
    );
  }
}

Add months toggle button

Add the calendar body to display dates of the month and toggle buttons to move to the next and previous months.

// dates view
Widget _datesView(){
  return Column(
    mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        // header
        Row(
          children: <Widget>[
            // prev month button
            _toggleBtn(false),
            // month and year
            Expanded(
              child: InkWell(
                onTap: (){
                  setState((){
                    // explained in later stages
                    _currentView = CalendarViews.months
                  });
                },
                child: Center(
                  child: Text(
                    '${_monthNames[_currentDateTime.month-1]} 
                     ${_currentDateTime.year}',
                     style: TextStyle(
                       color: Colors.white, 
                       fontSize: 18, 
                       fontWeight: FontWeight.w700
                     ),
                   ),
                 ),
               ),
             ),
             // next month button
             _toggleBtn(true),
           ],
         ),
         SizedBox(height: 20,),
         Divider(color: Colors.white,),
         SizedBox(height: 20,),
         Flexible(child: _calendarBody()),
      ],
   );
}
// next / prev month buttons
Widget _toggleBtn(bool next) {
  return InkWell(
    // explained in later stages
    onTap: (){
      if(_currentView == CalendarViews.dates){
        setState(() => (next) ? _getNextMonth() : _getPrevMonth());
      }
      else if(_currentView == CalendarViews.year){
        if(next){
          midYear = (midYear == null) 
            ? _currentDateTime.year + 9 : midYear + 9;
        }
        else{
          midYear = (midYear == null) 
            ? _currentDateTime.year - 9 : midYear - 9;
        }
        setState(() {});
      }
    },
    child: Container(
      width: 50,
      height: 50,
      decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(25),
      border: Border.all(color: Colors.white),
      boxShadow: [
        BoxShadow(
          color: Colors.white.withOpacity(0.5),
          offset: Offset(3, 3),
          blurRadius: 3,
          spreadRadius: 0,
        ),
      ],
      gradient: LinearGradient(
        colors: [Colors.black, Colors.black.withOpacity(0.1)],
        stops: [0.5, 1],
        begin: Alignment.bottomRight,
        end: Alignment.topLeft,
      )
    ),
    child: Icon((next) 
      ? Icons.arrow_forward_ios : Icons.arrow_back_ios, 
      color: Colors.white,),
    ),
  );
}
// calendar body
Widget _calendarBody() {
  if(_sequentialDates == null) return Container();
  return GridView.builder(
    shrinkWrap: true,
    padding: EdgeInsets.zero,
    itemCount: _sequentialDates.length + 7,
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      mainAxisSpacing: 20,
      crossAxisCount: 7,
      crossAxisSpacing: 20,
    ), 
    itemBuilder: (context, index){
      if(index < 7) return _weekDayTitle(index);
      if(_sequentialDates[index - 7].date == _selectedDateTime) 
        return _selector(_sequentialDates[index - 7]);
      return _calendarDates(_sequentialDates[index - 7]);
    },
  );
}
// calendar header
Widget _weekDayTitle(int index){
  return Text(_weekDays[index], 
    style: TextStyle(color: Colors.yellow, fontSize: 12),);
}
// calendar element
Widget _calendarDates(Calendar calendarDate){
  return InkWell(
    onTap: (){
      if(_selectedDateTime != calendarDate.date){
        if(calendarDate.nextMonth){
          _getNextMonth();
        }
        else if(calendarDate.prevMonth){
          _getPrevMonth();
        }
        setState(() => _selectedDateTime = calendarDate.date);
     } 
   },
   child: Center(
   child: Text(
     '${calendarDate.date.day}', 
     style: TextStyle(
       color: (calendarDate.thisMonth) 
         ? (calendarDate.date.weekday == DateTime.sunday) 
         ? Colors.yellow : Colors.white 
         : (calendarDate.date.weekday == DateTime.sunday) 
         ? Colors.yellow.withOpacity(0.5) 
         : Colors.white.withOpacity(0.5),
       ),
     )
   ),
 );
}
// date selector
Widget _selector(Calendar calendarDate) {
  return Container(
    width: 30,
    height: 30,
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(50),
      border: Border.all(color: Colors.white, width: 4),
      gradient: LinearGradient(
        colors: [Colors.black.withOpacity(0.1), Colors.white],
        stops: [0.1, 1],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ),
    ),
    child: Container(
      decoration: BoxDecoration(
        color: Colors.white.withOpacity(0.9),
        borderRadius: BorderRadius.circular(50),
      ),
      child: Center(
        child: Text(
          '${calendarDate.date.day}',
          style: TextStyle(
            color: Colors.black,
            fontWeight: FontWeight.w700
          ),
        ),
      ),
    ),
  );
}

Add methods to change the current date-time while navigating to the next or previous month.

// get next month calendar
void _getNextMonth(){
  if(_currentDateTime.month == 12) {
      _currentDateTime = DateTime(_currentDateTime.year+1, 1);
  }
  else{
      _currentDateTime = DateTime(_currentDateTime.year, 
           _currentDateTime.month+1);
  }
  _getCalendar();
}
// get previous month calendar
void _getPrevMonth(){
  if(_currentDateTime.month == 1){
      _currentDateTime = DateTime(_currentDateTime.year-1, 12);
  }
  else{
      _currentDateTime = DateTime(_currentDateTime.year,  
          _currentDateTime.month-1);
  }
  _getCalendar();
}

Navigating through next/previous month using a button can be tiresome if we want to jump through many months and years.

We need 2 views on selecting a month and a year. Now, our calendar will have 3 views dates’ view, months’ view, and years’ view. Creating the enum for the same.
enum CalendarViews{ dates, months, year }

Also, we need to store the current view to display and it will change whenever the user selects the different views. So, creating a class variable in _MyAppState class with default view as dates view.

CalendarViews _currentView = CalendarViews.dates;

We will switch from dates view to months view by clicking on the header of dates view. The onTap function of the dates’ view header is already configured above.

onTap: () => setState(() => _currentView = CalendarViews.months),

Create the months’ view

Months view
Months view

Create a widget for displaying the list of months. A user can select from these months.

// show months list
Widget _showMonthsList(){
  return Column(
    children: <Widget>[
      InkWell(
        onTap: (){
          setState((){
            //switch to years views
            _currentView = CalendarViews.year;
          });
        },
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Text('${_currentDateTime.year}', 
            style: TextStyle(fontSize: 18, 
              fontWeight: FontWeight.w700, color: Colors.white),
          ),
        ),
      ),
      Divider(color: Colors.white,),
      Expanded(
        child: ListView.builder(
          padding: EdgeInsets.zero,
          itemCount: _monthNames.length,
          itemBuilder: (context, index) => ListTile(
          onTap: (){
            // change month of currentDateTime 
            _currentDateTime = 
              DateTime(_currentDateTime.year, index+1);
            _getCalendar();
            // switch back to dates view
            setState(() => _currentView = CalendarViews.dates);
          },
          title: Center(
            child: Text(
              _monthNames[index], 
              style: TextStyle(fontSize: 18, 
                color: (index == _currentDateTime.month-1) 
                  ? Colors.yellow : Colors.white),
            ),
          ),
        ),
      ),
    ),
   ],
 );
}

Users can navigate through the years by tapping the header of months’ view. The onTap function of months view header is already configured above.

Create the years’ view

Years View
Years View
// years list views
Widget _yearsView(int midYear){
  return Column(
    children: <Widget>[
      Row(
        children: <Widget>[
          _toggleBtn(false),
          Spacer(),
          _toggleBtn(true),
         ],
       ),
       Expanded(
         child: GridView.builder(
           shrinkWrap: true,
           itemCount: 9,
           physics: NeverScrollableScrollPhysics(),
           gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
             crossAxisCount: 3,
           ), 
           itemBuilder: (context, index){
             int thisYear;
             if(index < 4){
               thisYear = midYear - (4 - index);
             }
             else if(index > 4){
               thisYear = midYear + (index - 4);
             }
             else{
               thisYear = midYear;
             }
             return ListTile(
               onTap: (){
                 // change year of currentDateTime
                 _currentDateTime = 
                   DateTime(thisYear, _currentDateTime.month);
                 _getCalendar();
                 // switch back to months view 
                 setState(() => 
                   _currentView = CalendarViews.months);
               },
               title: Text(
                 '$thisYear', 
                 style: TextStyle(fontSize: 18, 
                   color: (thisYear == _currentDateTime.year) 
                     ? Colors.yellow : Colors.white),
               ),
             );
           }
         ),
       ),
     ],
   );
}

The toggle buttons has two sets of functionalities. i. Toggling to next or previous month in dates view ii. Displaying previous or next sets of years in years view. These functionalities are already configured in onTap function of the toggle button above.

Now we have to place the condition for displaying the views according to the current view’s value. This will be done where we have simply given _datesView as the child for the container in the build method.

Updated build method

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: Container(
        margin: EdgeInsets.all(16),
        padding: EdgeInsets.all(16),
        height: MediaQuery.of(context).size.height * 0.6,
        decoration: BoxDecoration(
        color: Colors.black,
        borderRadius: BorderRadius.circular(20),
      ),
      child: (_currentView == CalendarViews.dates) ? _datesView() 
        : (_currentView == CalendarViews.months) ? _showMonthsList() 
        : _yearsView(midYear ?? _currentDateTime.year)
      ),
    ),
  );
}

That’s all! Now you have a custom calendar that can be molded to any extent as per your requirements :-)

Get the full source code from this link.

Read next

Flutter: Custom Cupertino Date Picker

Hey guys, do you ever required to build a Cupertino date picker, similar to the one shown in the image below? But got frustrated because CupertinoDate

Hitesh Verma May 28, 2023 · 6 min read

Flutter | Highlight searched text in results

Hitesh Verma in TECH
May 12, 2023 · 4 min read

Flutter Theming | The Right Way

Hitesh Verma in TECH
May 12, 2023 · 5 min read