Android — Simple Calendar with Jetpack Compose
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.
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:
- Composable annotation (
@Composable
) can be applied to a function or lambda, and it’s the fundamental building blocks of an application built with Compose. Text
composable is the most basic way to display text with aString
as an argument (https://developer.android.com/jetpack/compose/text)
Next, we want to add Previous and Next buttons:
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 usingRow
to place items horizontally on the screen.
- and
Modifier
which allow us to decorate or augment a composable. WithModifier
we can change the size, layout, behavior and appearance of a Composable. (https://developer.android.com/jetpack/compose/modifiers). You might want to check full List of Compose modifiers
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.
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.
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:
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 🧑🏻🍳
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! 👩🏻💻