Create a Flexible and Customizable Calendar View in Android with Jetpack Compose

Daniela Romano
4 min readFeb 26, 2024

Jetpack Compose Material 3 currently provides a DatePicker, a UI component that is great for selecting a date or a date range, but not so great for displaying a simple calendar that highlights a specific date. However, it is not difficult to create a customizable one using a Custom Layout and the Calendar class.

Although a LazyGrid may seem like a simpler option, its lazy nature prevents it from being placed inside a scrollable view, which can be a problem in some cases.

The layout utilizes the color schemes available in Material 3, allowing you to also take advantage of the dynamic color function available from Android 31 onwards.

Creating the Calendar

Let’s start with the atomic components: the date cell and the weekday cell. Both must have aspectRatio(1f) to align correctly in the grid. In the cell, we add the signal parameter which, if true, will display a small circle behind the day number.


private fun Date.formatToCalendarDay(): String = SimpleDateFormat("d", Locale.getDefault()).format(this)

@Composable
private fun CalendarCell(
date: Date,
signal: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val text = date.formatToCalendarDay()
Box(
modifier = modifier
.aspectRatio(1f)
.fillMaxSize()
.padding(2.dp)
.background(
shape = RoundedCornerShape(CornerSize(8.dp)),
color = colorScheme.secondaryContainer,
)
.clip(RoundedCornerShape(CornerSize(8.dp)))
.clickable(onClick = onClick)
) {
if (signal) {
Box(
modifier = Modifier
.aspectRatio(1f)
.fillMaxSize()
.padding(8.dp)
.background(
shape = CircleShape,
color = colorScheme.tertiaryContainer.copy(alpha = 0.7f)
)
)
}
Text(
text = text,
color = colorScheme.onSecondaryContainer,
modifier = Modifier.align(Alignment.Center)
)
}
}


private fun Int.getDayOfWeek3Letters(): String? = Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, this@getDayOfWeek3Letters)
}.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.getDefault())

@Composable
private fun WeekdayCell(weekday: Int, modifier: Modifier = Modifier) {
val text = weekday.getDayOfWeek3Letters()
Box(
modifier = modifier
.aspectRatio(1f)
.fillMaxSize()
) {
Text(
text = text.orEmpty(),
color = colorScheme.onPrimaryContainer,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}

To get the weekdays formatted in three letters, we use a function that formats them based on the Local.

Now let’s consider all the cells that will be present in the view:

  • One for each day of the week (assuming we can start the week on Sunday or Monday)
  • One for each day of the month
  • Spacer cells to align the first day of the month with the correct weekday
@Composable
private fun CalendarGrid(
date: ImmutableList<Pair<Date, Boolean>>,
onClick: (Date) -> Unit,
startFromSunday: Boolean,
modifier: Modifier = Modifier,
) {
val weekdayFirstDay = date.first().first.formatToWeekDay()
val weekdays = getWeekDays(startFromSunday)
CalendarCustomLayout(modifier = modifier) {
weekdays.forEach {
WeekdayCell(weekday = it)
}
// Adds Spacers to align the first day of the month to the correct weekday
repeat(if (!startFromSunday) weekdayFirstDay - 2 else weekdayFirstDay - 1) {
Spacer(modifier = Modifier)
}
date.forEach {
CalendarCell(date = it.first, signal = it.second, onClick = { onClick(it.first) })
}
}
}

fun getWeekDays(startFromSunday: Boolean): ImmutableList<Int> {
val lista = (1..7).toList()
return (if (startFromSunday) lista else lista.drop(1) + lista.take(1)).toImmutableList()
}

Finally, we can focus on the customLayout. Its operation is simple:

  1. Calculate the maximum width and divide it by 7, subtracting 6 times the horizontalGap. This value will be the width of each cell.
  2. Position all the cells, 7 per row.

@Composable
private fun CalendarCustomLayout(
modifier: Modifier = Modifier,
horizontalGapDp: Dp = 2.dp,
verticalGapDp: Dp = 2.dp,
content: @Composable () -> Unit,
) {
val horizontalGap = with(LocalDensity.current) {
horizontalGapDp.roundToPx()
}
val verticalGap = with(LocalDensity.current) {
verticalGapDp.roundToPx()
}
Layout(
content = content,
modifier = modifier,
) { measurables, constraints ->
val totalWidthWithoutGap = constraints.maxWidth - (horizontalGap * 6)
val singleWidth = totalWidthWithoutGap / 7

val xPos: MutableList<Int> = mutableListOf()
val yPos: MutableList<Int> = mutableListOf()
var currentX = 0
var currentY = 0
measurables.forEach { _ ->
xPos.add(currentX)
yPos.add(currentY)
if (currentX + singleWidth + horizontalGap > totalWidthWithoutGap) {
currentX = 0
currentY += singleWidth + verticalGap
} else {
currentX += singleWidth + horizontalGap
}
}

val placeables: List<Placeable> = measurables.map { measurable ->
measurable.measure(constraints.copy(maxHeight = singleWidth, maxWidth = singleWidth))
}

layout(
width = constraints.maxWidth,
height = currentY + singleWidth + verticalGap,
) {
placeables.forEachIndexed { index, placeable ->
placeable.placeRelative(
x = xPos[index],
y = yPos[index],
)
}
}
}
}

Let’s conclude the view by adding a few elements:

  • Month: Display the current month at the top, formatted based on the local language. We can use the SimpleDateFormat function to get the correctly formatted string.
  • Navigation: Add two arrows, one pointing left and one right, to allow the user to change the displayed month. We can implement this functionality by updating the calendar state with the previous or next month.

@Composable
fun CalendarView(
month: Date,
date: ImmutableList<Pair<Date, Boolean>>?,
displayNext: Boolean,
displayPrev: Boolean,
onClickNext: () -> Unit,
onClickPrev: () -> Unit,
onClick: (Date) -> Unit,
startFromSunday: Boolean,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Box(modifier = Modifier.fillMaxWidth()) {
if (displayPrev)
CustomIconButton(
onClick = onClickPrev,
modifier = Modifier.align(Alignment.CenterStart),
icon = Icons.Filled.KeyboardArrowLeft,
contentDescription = "navigate to previous month"
)
if (displayNext)
CustomIconButton(
onClick = onClickNext,
modifier = Modifier.align(Alignment.CenterEnd),
icon = Icons.Filled.KeyboardArrowRight,
contentDescription = "navigate to next month"
)
Text(
text = month.formatToMonthString(),
style = typography.headlineMedium,
color = colorScheme.onPrimaryContainer,
modifier = Modifier.align(Alignment.Center),
)
}
Spacer(modifier = Modifier.size(16.dp))
if (!date.isNullOrEmpty()) {
CalendarGrid(
date = date,
onClick = onClick,
startFromSunday = startFromSunday,
modifier = Modifier
.wrapContentHeight()
.padding(horizontal = 16.dp)
.align(Alignment.CenterHorizontally)
)
}
}
}

fun Date.formatToMonthString(): String = SimpleDateFormat("MMMM", Locale.getDefault()).format(this)

Keep in mind we used ImmutableList as it is immutable, and avoids unnecessary recompositions.

Conclusion

With this approach, you can create a flexible and customizable calendar based on your needs. Using a CustomLayout gives you more control over the positioning and formatting of the cells, while using parameters like signal allows you to add advanced functionality.

--

--