Android — Simple Calendar with Jetpack Compose

Meyta Taliti
8 min readMay 21, 2023

--

Photo by Samantha Gades on Unsplash

Hello, guys! I’ve created an upgraded version of this simple calendar. In case you need it, here is the link:

This time, we’ll create a Simple Calendar with Jetpack Compose. You can check out the full source code implementation if you want.

Mock-up Design

As we can see above, the design is quite simple 😃. To make it easier, we call the top section is Header and the bottom is Content.

Creating UI 👨🏼‍🎨

First, we can start to write function Header to show label Saturday, 20 May 2023:

@Composable
fun Header() {
Text(text = "Saturday 20, May 2023")
}

Things to notice:

Next, we want to add Previous and Next buttons:

Mock-up Design: Prev & Next button

This time I will use IconButton composable. Thanks to Jetpack Compose Basics | Google Codelabs, they showed me how to use IconButton.

@Composable
fun Header() {
Row {
Text(
text = "Saturday 20, May 2023",
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "Previous"
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = "Next"
)
}
}
}

The IconButton needs anonClick = { } listener which we’ll use later. And I’m using Icon component which draws an image vector. (To learn more about icons, see: androidx.compose.material.icons)

To use the available icon we need to include material-icons-extended dependency in our app/build.gradle.

implementation "androidx.compose.material:material-icons-extended:1.2.0"

There are not only IconButton for Previous and Next buttons, but also:

  • Row is one of the compose’s standard layout elements. I’m using Row to place items horizontally on the screen.
Compose’s standard layout elements (https://developer.android.com/jetpack/compose/layouts/basics)

Next, we will wrap Header with Column which to place items vertically on the screen so later we can just add Content below Header composable. And we call it CalendarApp.

@Composable
fun CalendarApp(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxSize()) {
Header()
}
}

One of the tools that Jetpack Compose prepared for us is Compose previews. So we can see the preview of our code without running our app. All we need to add @Preview annotation before the composable function,

@Preview(showSystemUi = true)
@Composable
fun CalendarAppPreview() {
CalendarApp(
modifier = Modifier.padding(16.dp)
)
}

and click the Split to see both code and preview.

Jetpack Compose Preview (https://www.jetpackcompose.net/jetpack-compose-preview)

Next, is to create the Content section. My plan is to create a ContentItem containing day & date, and show them with LazyRow (*June 11, I changed it by using LazyVerticalGrid, learn more about Lazy layouts).

The ContentItem composable function accepts day & date as its arguments. Inside, we’re using a Card component and so that the day & date are placed vertically we’re wrapping them with Columns.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContentItem(day: String, date: String) {
Card(
modifier = Modifier
.padding(vertical = 4.dp, horizontal = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary
),
) {
Column(
modifier = Modifier
.width(40.dp)
.height(48.dp)
.padding(4.dp)
) {
Text(
text = day,
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodySmall
)
Text(
text = date,
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}

And, we call ContentItem inside LazyRow.

@Composable
fun Content() {
LazyRow {
items(items = List(7) { Pair("Sun", "21") }) { date ->
ContentItem(date.first, date.second)
}
}
}

Since now we have the Content, we can add Content below Header inside CalendarApp:

@Composable
fun CalendarApp(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxSize()) {
Header()
Content()
}
}

⏳ wait a moment, and Android Studio will render the preview.

Simple Calendar with Jetpack Compose Preview

Yes! we have finished making the UI. Now, we just need to put the real calendar data there ~

Build and Show the real Calendar Data to the UI 👩🏽‍🔧

I think it will be easier if we create one data class to hold real calendar data. Let’s call it CalendarUiModel:

data class CalendarUiModel(
val selectedDate: Date, // the date selected by the User. by default is Today.
val visibleDates: List<Date> // the dates shown on the screen
) {

val startDate: Date = visibleDates.first() // the first of the visible dates
val endDate: Date = visibleDates.last() // the last of the visible dates

data class Date(
val date: LocalDate,
val isSelected: Boolean,
val isToday: Boolean
) {
val day: String = date.format(DateTimeFormatter.ofPattern("E")) // get the day by formatting the date
}
}

Now, let’s move on to actually building the CalendarUiModel by creating one class which we call it CalendarDataSource:

class CalendarDataSource {

val today: LocalDate
get() {
return LocalDate.now()
}


fun getData(startDate: LocalDate = today, lastSelectedDate: LocalDate): CalendarUiModel {
val firstDayOfWeek = startDate.with(DayOfWeek.MONDAY)
val endDayOfWeek = firstDayOfWeek.plusDays(7)
val visibleDates = getDatesBetween(firstDayOfWeek, endDayOfWeek)
return toUiModel(visibleDates, lastSelectedDate)
}

private fun getDatesBetween(startDate: LocalDate, endDate: LocalDate): List<LocalDate> {
val numOfDays = ChronoUnit.DAYS.between(startDate, endDate)
return Stream.iterate(startDate) { date ->
date.plusDays(/* daysToAdd = */ 1)
}
.limit(numOfDays)
.collect(Collectors.toList())
}

private fun toUiModel(
dateList: List<LocalDate>,
lastSelectedDate: LocalDate
): CalendarUiModel {
return CalendarUiModel(
selectedDate = toItemUiModel(lastSelectedDate, true),
visibleDates = dateList.map {
toItemUiModel(it, it.isEqual(lastSelectedDate))
},
)
}

private fun toItemUiModel(date: LocalDate, isSelectedDate: Boolean) = CalendarUiModel.Date(
isSelected = isSelectedDate,
isToday = date.isEqual(today),
date = date,
)
}

Now, we can map the data to the UI:

@Composable
fun CalendarApp(modifier: Modifier = Modifier) {
val dataSource = CalendarDataSource()
// get CalendarUiModel from CalendarDataSource, and the lastSelectedDate is Today.
val calendarUiModel = dataSource.getData(lastSelectedDate = dataSource.today)
Column(modifier = modifier.fillMaxSize()) {
Header(data = calendarUiModel)
Content(data = calendarUiModel)
}
}

@Composable
fun Header(data: CalendarUiModel) {
Row {
Text(
// show "Today" if user selects today's date
// else, show the full format of the date
text = if (data.selectedDate.isToday) {
"Today"
} else {
data.selectedDate.date.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
)
},
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "Back"
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = "Next"
)
}
}
}

@Composable
fun Content(data: CalendarUiModel) {
LazyRow {
// pass the visibleDates to the UI
items(items = data.visibleDates) { date ->
ContentItem(date)
}
}
}

@Composable
fun ContentItem(date: CalendarUiModel.Date) {
Card(
modifier = Modifier
.padding(vertical = 4.dp, horizontal = 4.dp)
,
colors = CardDefaults.cardColors(
// background colors of the selected date
// and the non-selected date are different
containerColor = if (date.isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
}
),
) {
Column(
modifier = Modifier
.width(40.dp)
.height(48.dp)
.padding(4.dp)
) {
Text(
text = date.day, // day "Mon", "Tue"
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodySmall
)
Text(
text = date.date.dayOfMonth.toString(), // date "15", "16"
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}

Now, Let’s run the code:

Yay! 🎉

Hooray! we managed to make a Simple Calendar with Jetpack Compose 🎉. However, nothing happens when we select a date, click on the previous button or click on the next button. That’s because, we are not registering any callbacks.

Enable User Interaction 🧙🏾‍♂️

We need at least 3 callbacks, it is when:

  • The previous button clicked;
  • The next button clicked;
  • and, one of the dates is selected.
@Composable
fun Header(
data: CalendarUiModel,
// calbacks to click previous & back button should be registered outside
onPrevClickListener: (LocalDate) -> Unit,
onNextClickListener: (LocalDate) -> Unit,
) {
Row {
IconButton(onClick = {
// invoke previous callback when its button clicked
onPrevClickListener(data.startDate.date)
}) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
...
}
IconButton(onClick = {
// invoke next callback when this button is clicked
onNextClickListener(data.endDate.date)
}) {
Icon(
imageVector = Icons.Filled.ChevronRight,
...
}
}
}

@Composable
fun Content(
data: CalendarUiModel,
// callback should be registered from outside
onDateClickListener: (CalendarUiModel.Date) -> Unit,
) {
LazyRow {
items(items = data.visibleDates) { date ->
ContentItem(
date = date,
onDateClickListener
)
}
}
}

@Composable
fun ContentItem(
date: CalendarUiModel.Date,
onClickListener: (CalendarUiModel.Date) -> Unit, // still, callback should be registered from outside
) {
Card(
modifier = Modifier
.padding(vertical = 4.dp, horizontal = 4.dp)
.clickable { // making the element clickable, by adding 'clickable' modifier
onClickListener(date)
}
...
)
...

Now, let’s register all the callbacks inside CalendarApp function:

@Composable
fun CalendarApp(modifier: Modifier = Modifier) {
val dataSource = CalendarDataSource()
// we use `mutableStateOf` and `remember` inside composable function to schedules recomposition
var calendarUiModel by remember { mutableStateOf(dataSource.getData(lastSelectedDate = dataSource.today)) }

Column(modifier = modifier.fillMaxSize()) {
Header(
data = calendarUiModel,
onPrevClickListener = { startDate ->
// refresh the CalendarUiModel with new data
// by get data with new Start Date (which is the startDate-1 from the visibleDates)
val finalStartDate = startDate.minusDays(1)
calendarUiModel = dataSource.getData(startDate = finalStartDate, lastSelectedDate = calendarUiModel.selectedDate.date)
},
onNextClickListener = { endDate ->
// refresh the CalendarUiModel with new data
// by get data with new Start Date (which is the endDate+2 from the visibleDates)
val finalStartDate = endDate.plusDays(2)
calendarUiModel = dataSource.getData(startDate = finalStartDate, lastSelectedDate = calendarUiModel.selectedDate.date)
}
)
Content(data = calendarUiModel, onDateClickListener = { date ->
// refresh the CalendarUiModel with new data
// by changing only the `selectedDate` with the date selected by User
calendarUiModel = calendarUiModel.copy(
selectedDate = date,
visibleDates = calendarUiModel.visibleDates.map {
it.copy(
isSelected = it.date.isEqual(date.date)
)
}
)
})
}
}

Things to notice:

  • mutableStateOf(T) is a single value holder whose reads and writes are observed by Compose.
  • and, remember will remember the value produced by calculation. calculation will only be evaluated during the composition. Recomposition will always return the value produced by composition.

Source: https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary. and to learn more you can visit: State in Jetpack Compose.

Now, Let’s re-run the code 🧑🏻‍🍳

Simple Calendar View with Jetpack Compose (https://github.com/mzennis/MyCalendar)

This method may not be the best in terms of performance (I need to calculate how fast the recomposition n stuff, and re-read the Thinking in Compose article). But, we can always improve things with time.

That’s all for today, hope it’s useful for you. Happy Coding! 👩🏻‍💻

--

--