3.
Developing UI: Android Jetpack Compose
Written by Kevin D Moore
In the last chapter, you learned about the KMP build system. In this chapter, you’ll learn about a new UI toolkit named Jetpack Compose that you can use on Android. This won’t be an extensive discussion on Jetpack Compose, but it will teach you the basics. Open the starter project from this chapter. It contains the starter code that you’ll use while going through this chapter.
UI Frameworks
At the time of writing this book, KMP doesn’t provide a stable framework for developing a UI on cross-platform devices, so it is recommended to use each platform’s native framework until it is stable. In this chapter, you’ll learn about writing the UI for Android with Jetpack Compose. In the next chapter, you’ll learn about building the UI for iOS using SwiftUI, which also works on macOS.
Note: You can use Compose to build UI for iOS as well but since its support is in alpha at the moment of writing this book. Therefore, this book will focus on using SwiftUI for building iOS UI.
Current UI System
On Android, you typically use an XML layout system for building your UIs. While Android Studio does provide a UI layout editor, it still uses XML underneath. This means that Android will have to parse XML files to build its view classes to then build the UI. What if you could just build your UI in code?
Jetpack Compose
That’s the idea behind Jetpack Compose (JC). JC is a declarative UI system that uses functions to create all or part of your UI. The developers at Google realized the Android View system was getting older and had many flaws. So, they decided to come up with a whole new framework that would use a library instead of the built-in framework — allowing app developers to continue to provide the most up-to-date version of the framework regardless of the Android version.
One of the main tenets of Compose is that it takes less code to do the same things as the old View system. For example, to create a modified button, you don’t have to subclass Button
— instead, just add modifiers to an existing Compose component.
Compose components are also easily reusable. You can use Compose with new projects, and you can use it with existing projects that just use Compose in new screens. Compose can preview your UI in Android Studio, so you don’t have to run the app to see what your components will look like. In a declarative UI, the UI will be drawn with the current state. If that state changes, the areas of the screen that have changed will be rerendered. This makes your code much simpler because you only have to draw what’s in your current state and don’t have to listen for changes.
Getting to Know Jetpack Compose
The one Android component that’s still needed in Jetpack Compose (JC) is the Activity
class. There has to be a starting point, and there’s usually one Activity
that’s the main entry point. One of the nice features of JC is that you don’t need more than one Activity
(you can have more if you want to). Also — and more importantly — you don’t need to use fragments anymore. If you’re familiar with activities, you know that the starting method is onCreate
. You no longer need to call setContentView
because you won’t be using XML files. Instead, you use setContent
.
MainActivity
Open MainActivity in androidApp/src/main/java/com/kodeco/findtime/android/. Your code should look something like:
// 1
setContent {
// 2
MyApplicationTheme {
// 3
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// 4
Text("Hello, Android!")
}
}
}
Here’s what the code does:
- Use the
setContent
function to set the UI for the activity. - Set the theme (this was created for you when you created the project). This provides the colors, typography and shapes.
- Fill the screen with the background color.
- Use a text to say “Hello, Android!”.
If you look at the source of setContent
, you’ll see that it’s an extension method on ComponentActivity
. The last parameter in this method is your UI. This method is of type @Composable
, which is a special annotation that you’ll need to use on all of your Compose functions. A Compose function will look something like this:
@Composable
fun showName(text: String) {
Text(text)
}
The most important part is the @Composable
annotation. This tells JC this is a function that can be drawn on the screen. No Composable
function returns a value. Importantly, you want most of your functions to be stateless. This means that you pass in the data you want to show, and the function doesn’t store that data. This makes the function very fast to draw.
Time Finder
You’re going to develop a multiplatform app that will allow the user to select multiple time zones and find the best meeting times that work for all people in those time zones. Here’s what the first screen looks like:
Here, you see the local time zone, time and date. Several different time zones are below that. Your user is trying to find a meeting time in all these locations.
Note: This is just the raw time zone string code. If you’re interested, you can challenge yourself to replace the string codes with more readable strings.
When the user wants to add a time zone, they will tap the Floating Action Button (FAB) and a dialog will appear to allow them to select all the time zones they want:
Next up is the search screen, which allows the user to select the start and end times for their day and includes a search button to show the hours available.
Tapping the search button brings up the result dialog:
Note: While this chapter goes into some detail about Jetpack Compose, it’s not intended to be a thorough examination of how to use it. For a deeper understanding of Jetpack Compose, check out the books at https://www.kodeco.com/android/books.
Themes
One of the first Compose functions you need to learn about is the theme. This is the color scheme you’ll use for your app. In Android, you would normally have a style.xml or theme.xml file with specifications for colors, fonts and other areas of UI styling. In Compose, you use a theme function. Since you have included the Material3 Compose library, you can use the MaterialTheme
class as a starting point for setting colors, fonts and shapes. Compose can also tell you if the system is using the dark theme. Luckily, Android Studio creates a theme for you. In the starter project, the theme has been changed to use Material3 instead of the older Material library.
Open up MyApplicationTheme.kt. This is a Composable function that defines the light and dark colors of the app:
@Composable
fun MyApplicationTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
darkColorScheme(
primary = Color(0xFF005cb2),
onPrimary = Color.White,
secondary = Color(0xFF00766c)
)
} else {
lightColorScheme(
primary = Color(0xFF1e88e5),
onPrimary = Color.Black,
secondary = Color(0xFF26a69a)
)
}
This defines some primary and secondary colors. You can see the colors in the left margin. Change them if you want a different color scheme. Next are the definitions for typography and shapes. If you want to define other text types, define them here.
val typography = Typography(
bodySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
color = Color.White
),
headlineSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = Color.White
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 20.sp,
color = Color.White
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 11.sp,
color = Color.White
),
)
val shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
You can also set the letter spacing and many other values defined in TextStyle
.
The shapes define how you would like your corners on all kinds of controls. From buttons to text field borders to Floating Action Buttons.
Next, the passed-in content is wrapped in a MaterialTheme that uses the defined colors, typography and shapes.
MaterialTheme(
colorScheme = colors,
typography = typography,
shapes = shapes,
content = content
)
Above the MyApplicationTheme
definition, you’ll find two colors defined as follows:
val startGradientColor = Color(0xFF1e88e5)
val endGradientColor = Color(0xFF005cb2)
You’ll use the above colors later in the chapter.
Types
Before you get to the main screen, you’ll need a few custom types that will be used throughout the app. In the ui folder, create a new Kotlin file named Types.kt. Add the following:
import androidx.compose.runtime.Composable
// 1
typealias OnAddType = (List<String>) -> Unit
// 2
typealias onDismissType = () -> Unit
// 3
typealias composeFun = @Composable () -> Unit
// 4
typealias topBarFun = @Composable (Int) -> Unit
// 5
@Composable
fun EmptyComposable() {
}
Here’s what the above code does:
- Define an alias named
OnAddType
that takes a list of strings and doesn’t return anything. - Define an alias used when dismissing a dialog.
- Define a composable function.
- Define a function that takes an integer.
- Define an empty composable function (as a default variable for the Top Bar).
Now that you have your colors and text styles set up, it’s time to create your first screen.
Main Screen
Inside the androidApp module, create a new Kotlin file named MainView.kt in the ui folder. You’ll start by creating some helper classes and variables. First, add the imports you’ll need (this saves some time importing):
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.kodeco.findtime.android.MyApplicationTheme
Notice that you’re importing the material icons you’ll use and a few other compose classes for building your UI.
To keep track of your two screens, create a new sealed class named Screen:
sealed class Screen(val title: String) {
object TimeZonesScreen : Screen("Timezones")
object FindTimeScreen : Screen("Find Time")
}
This just defines two screens: TimeZonesScreen
and FindTimeScreen
, along with their titles. Here, this sealed class is similar to an enum class that allows you to switch between screens and titles. You can learn more about sealed classes and other advanced classes available in Kotlin in the reference link shared at the end of the chapter.
Next, define a class to handle the bottom navigation item. Write the following code just below the Screen
class you created above:
data class BottomItem(
val route: String,
val icon: ImageVector,
val iconContentDescription: String
)
This defines a route, an icon for that route and a content description. Next, create a variable with two items. Add it just below the BottomItem
class:
val bottomNavigationItems = listOf(
BottomItem(
Screen.TimeZonesScreen.title,
Icons.Filled.Language,
"Timezones"
),
BottomItem(
Screen.FindTimeScreen.title,
Icons.Filled.Place,
"Find Time"
)
)
This list will help you create the bottom navigation UI that you will be using to switch the screens. This uses the material icons and the titles from the screen class. Now, create the MainView
composable:
// 1
@Composable
// 2
fun MainView(actionBarFun: topBarFun = { EmptyComposable() }) {
// 3
val showAddDialog = remember { mutableStateOf(false) }
// 4
val currentTimezoneStrings = remember { SnapshotStateList<String>() }
// 5
val selectedIndex = remember { mutableIntStateOf(0)}
// 6
MyApplicationTheme {
// TODO: Add Scaffold
}
}
Here’s what the above code does:
-
Define this function as a composable.
-
This function takes a function that can provide a top bar (toolbar on Android) and defaults to an empty composable.
-
Hold the state for showing the add dialog. If the state object is true then the app will show a dialog, otherwise it will hide the add dialog.
-
Hold the state containing a list of current time zone strings.
-
Hold the state containing the currently selected index.
-
Use the current theme composable.
Note that in the above code, you have used compose remember
and mutableStateOf
functions to remember the state of the UI.
State
State is any value that can change over time. Compose uses a few functions for handling state. The most important one is remember
. This stores the variable so that it’s remembered between redraws of the screen. When the user selects between the two bottom buttons, you want to save this selection to update which screen is showing. A MutableState
is a value holder that tells the Compose engine to redraw whenever the state changes.
Here are some key functions:
-
remember
: Remembers the variable and retains its value between redraws. -
mutableStateOf
: Creates aMutableState
instance whose state is observed by Compose. -
SnapshotStateList
: Creates aMutableList
whose state is observed by Compose. -
collectAsState
: Collects values from a Kotlin coroutineStateFlow
and is observed by Compose.
Scaffold
Compose uses a function named Scaffold
that uses the Material Design layout structure with an app bar (toolbar) and an optional floating action button. By using this function, your screen will be laid out properly.
Start by replacing // TODO: Add Scaffold
with:
Scaffold(
topBar = {
// TODO: Add Toolbar
},
floatingActionButton = {
// TODO: Add Floating action button
},
bottomBar = {
// TODO: Add bottom bar
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
// TODO: Replace with Dialog
// TODO: Replace with screens
}
}
As you can see, there are places to add composable functions inside the topBar, floatingActionButton and bottomBar parameters.
TopAppBar
The TopAppBar
is Compose’s function for a toolbar. Since every platform handles a toolbar differently — macOS displays menu items in the system toolbar, whereas Windows uses a separate toolbar — this section is optional. If the platform passes in a function that creates one, it will use that. Replace // TODO: Add Toolbar
with:
actionBarFun(selectedIndex.intValue)
This calls the passed-in function with the currently selected bottom bar index, whose value is stored in the selectedIndex
state variable. Since actionBarFun
gets set to an empty function by default, nothing will happen unless a function is passed in. You’ll do this later for the Android app. Now add the code to show a floating action button if you’re on the first screen but not on the second screen. Replace // TODO: Add Floating action button
with:
if (selectedIndex.intValue == 0) {
// 1
FloatingActionButton(
// 2
modifier = Modifier
.padding(16.dp),
shape = FloatingActionButtonDefaults.largeShape,
containerColor = MaterialTheme.colorScheme.secondary,
// 3
onClick = {
showAddDialog.value = true
}
) {
// 4
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add Timezone"
)
}
}
Here’s the explanation for the code:
- For the first page, create a
FloatingActionButton
. - Use Compose’s
Modifier
function to add padding. - Set a click listener. Set the variable to show the add dialog screen. Changing this value will cause a redraw of the screen.
- Use the
Add
icon for the FAB.
Bottom Navigation
Compose has a BottomNavigation
function that creates a bottom bar with icons. Underneath, it’s a Compose Row
class that you fill with your content.
Replace // TODO: Add bottom bar
with:
// 1
NavigationBar(
containerColor = MaterialTheme.colorScheme.primary
) {
// 2
bottomNavigationItems.forEachIndexed { i, bottomNavigationItem ->
// 3
NavigationBarItem(
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color.White,
selectedTextColor = Color.White,
unselectedIconColor = Color.Black,
unselectedTextColor = Color.Black,
indicatorColor = MaterialTheme.colorScheme.primary,
),
label = {
Text(bottomNavigationItem.route, style = MaterialTheme.typography.bodyMedium)
},
// 4
icon = {
Icon(
bottomNavigationItem.icon,
contentDescription = bottomNavigationItem.iconContentDescription
)
},
// 5
selected = selectedIndex.intValue == i,
// 6
onClick = {
selectedIndex.intValue = i
}
)
}
}
Here’s what the code does:
- Create a
NavigationBar
composable. - Use
forEachIndexed
to go through each item in your list of navigation items. - Create a new
NavigationBarItem
. - Set the icon field to the icon in your list.
- Is this screen selected? Only if the
selectedIndex
value is the current index. - Set the click listener. Change the
selectedIndex
value and the screen will redraw.
Next, return to MainActivity.kt and add the following imports:
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.res.stringResource
import com.kodeco.findtime.android.ui.MainView
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
Then, replace setContent
with:
// 1
Napier.base(DebugAntilog())
setContent {
// 2
MainView {
// 3
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary),
title = {
// 4
when (it) {
0 -> Text(text = stringResource(R.string.world_clocks))
else -> Text(text = stringResource(R.string.findmeeting))
}
})
}
}
Here’s what you did:
- Initialize the Napier logging library. (Be sure to include needed imports.)
- Set your main content to the
MainView
composable. - For Android, you want a top app bar. So you have added a
TopAppBar
composable function. - You check the currently selected index for the screen. When the first screen is showing, have the title be World Clocks. Otherwise, show Find Meeting.
Build and run the app on a device or emulator. Here’s what you’ll see:
Now you have a working app that displays a title bar, a floating action button and a bottom navigation bar. Try switching between the two icons. What happens?
Local Time Card
Moving ahead, the first thing you want to show on the screen is the user’s local time zone, time and date. This will be in a card with a blue gradient.
It will look like this:
In the ui folder, create a new Kotlin file named LocalTimeCard.kt. First, add the following imports that you will need for creating the card:
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.kodeco.findtime.android.endGradientColor
import com.kodeco.findtime.android.startGradientColor
Then, add the following code below:
@Composable
// 1
fun LocalTimeCard(city: String, time: String, date: String) {
// 2
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.background(MaterialTheme.colorScheme.background)
.padding(8.dp)
) {
// 3
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Black),
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add body
}
}
}
Here’s the explanation of the code above:
- Create a function named
LocalTimeCard
that takes acity
,time
, anddate
as a string. - Use a
Box
function that fills the current width and has a height of 140 dp and a white background.Box
is a container that draws elements inside it on top of one another. - Use a
Card
with rounded corners and a black border. It also fills the width.
For the body, replace // TODO: Add body
with:
// 1
Box(
modifier = Modifier
.background(
brush = Brush.horizontalGradient(
colors = listOf(
startGradientColor,
endGradientColor,
)
)
)
.padding(8.dp)
) {
// 2
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 3
Column(
horizontalAlignment = Alignment.Start
) {
// 4
Spacer(modifier = Modifier.weight(1.0f))
Text(
"Your Location", style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
// 5
Text(
city, style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
}
// 6
Spacer(modifier = Modifier.weight(1.0f))
// 7
Column(
horizontalAlignment = Alignment.End
) {
Spacer(modifier = Modifier.weight(1.0f))
// 8
Text(
time, style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
// 9
Text(
date, style = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(8.dp))
}
}
}
Here’s an explanation of the code above:
- Use a box to display the gradient background.
- Create a row that fills the entire width.
- Create a column for the left side of the card.
- Use a spacer with a weight modifier to push the text to the bottom.
- Display the city text with the given typography.
- Push the right column over by using a spacer with a weight modifier.
- Create the right column.
- Show the time with the given typography.
- Show the date with the given typography.
Time Zone Screen
Now that you have your cards ready, it’s time to put them all together in one screen. In the ui directory, create a new file named TimeZoneScreen.kt. Add the imports and a constant:
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.kodeco.findtime.TimeZoneHelper
import com.kodeco.findtime.TimeZoneHelperImpl
import kotlinx.coroutines.delay
const val timeMillis = 1000 * 60L // 1 second
Next, create the composable:
@Composable
fun TimeZoneScreen(
currentTimezoneStrings: SnapshotStateList<String>
) {
// 1
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
// 2
val listState = rememberLazyListState()
// 3
Column(
modifier = Modifier
.fillMaxSize()
) {
// TODO: Add Content
}
}
This function takes a list of current time zones. This list is a SnapshotStateList
so that this class can change the values, and other functions will be notified of the changes.
Finally, here’s the explanation of the remaining code:
- Create an instance of your TimeZoneHelper class.
- Remember the state of the list that will be defined later.
- Create a vertical column that takes up the full width.
Moving ahead. Replace // TODO: Add Content
with:
// 1
var time by remember { mutableStateOf(timezoneHelper.currentTime()) }
// 2
LaunchedEffect(Unit) {
while (true) {
time = timezoneHelper.currentTime()
delay(timeMillis) // Every minute
}
}
// 3
LocalTimeCard(
city = timezoneHelper.currentTimeZone(),
time = time, date = timezoneHelper.getDate(timezoneHelper.currentTimeZone())
)
Spacer(modifier = Modifier.size(16.dp))
// TODO: Add Timezone items
Here’s what the above code does:
- Remember the current time. Note that here you are using
by
instead of=
. In this case, thetime
variable is the current time in a string format. - Use Compose’s
LaunchedEffect
. It will be launched once but continue to run. The method will get the updated time every minute. You passUnit
as a parameter toLaunchedEffect
so that it is not canceled and re-launched whenLaunchedEffect
is recomposed. - Use the
LocalTimeCard
function you created earlier. UseTimeZoneHelper
’s methods to get the current time zone and current date.
Return to MainView. Replace // TODO: Replace with screens
with the following:
when (selectedIndex.intValue ) {
0 -> TimeZoneScreen(currentTimezoneStrings)
// 1 -> FindMeetingScreen(currentTimezoneStrings)
}
If the index is 0, the app shows the Time Zone screen, otherwise, it will show the Find Meeting screen. The Find Meeting screen is commented out until you write it.
Build and run the app. It will look like this:
Nicely done! Your app is really starting to take shape now.
Time Card
Next up, we will create the time card that will display the time, date, and time difference from local and other timezones. At the end, the time card will look like this:
In the ui folder, create a new Kotlin file named TimeCard.kt. Add the following:
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
// 1
fun TimeCard(timezone: String, hours: Double, time: String, date: String) {
// 2
Box(
modifier = Modifier
.fillMaxSize()
.height(120.dp)
.background(Color.White)
.padding(8.dp)
) {
// 3
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Gray),
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add Content
}
}
}
Here’s what’s happening in this code:
- This function takes a time zone, hours, time and date that will be used in the time card.
- Use a
Box
to take up the full width and give it a white background. - Create a nice-looking card using the
Card
Composable with rounded corners and a grey border.
Now that you have the card, add some content by adding a few rows and columns inside the card. Replace // TODO: Add Content
with:
// 1
Box(
modifier = Modifier
.background(
color = Color.White
)
.padding(16.dp)
) {
// 2
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 3
Column(
horizontalAlignment = Alignment.Start
) {
// 4
Text(
timezone, style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
)
Spacer(modifier = Modifier.weight(1.0f))
// 5
Row {
// 6
Text(
hours.toString(), style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
)
// 7
Text(
" hours from local", style = TextStyle(
color = Color.Black,
fontSize = 14.sp
)
)
}
}
Spacer(modifier = Modifier.weight(1.0f))
// 8
Column(
horizontalAlignment = Alignment.End
) {
// 9
Text(
time, style = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
Spacer(modifier = Modifier.weight(1.0f))
// 10
Text(
date, style = TextStyle(
color = Color.Black,
fontSize = 12.sp
)
)
}
}
}
Here’s the explanation of the code you have added:
- Use a box to set the background to white.
- Create a row that fills the width.
- Create a column on the left side.
- Show the time zone.
- Create a row underneath the previous one.
- Show the hours in bold.
- Show the text “hours from local.”
- Create a column on the right side.
- Show the time.
- Show the date.
Notice how you’re building up the screen section by section. You can’t quite use these cards yet, as you need a way to add a new time zone. You’ll do this later by creating a dialog that will allow the user to pick many time zones to add.
Next, you will be writing code to build a user-selected list of timezone item cards that we just created above. The following code will go through the list of current time zone strings and wrap the item in an AnimatedSwipeDismiss
to allow the user to swipe and delete the card and then use the new time card. Return to TimezoneScreen and replace // TODO: Add Timezone items
with:
// 1
LazyColumn(
state = listState,
) {
// 2
items(currentTimezoneStrings.size,
// 3
key = { timezone ->
timezone
}) { index ->
val timezoneString = currentTimezoneStrings[index]
// 4
AnimatedSwipeDismiss(
item = timezoneString,
// 5
background = { _ ->
Box(
modifier = Modifier
.fillMaxSize()
.height(50.dp)
.background(Color.Red)
.padding(
start = 20.dp,
end = 20.dp
)
) {
val alpha = 1f
Icon(
Icons.Filled.Delete,
contentDescription = "Delete",
modifier = Modifier
.align(Alignment.CenterEnd),
tint = Color.White.copy(alpha = alpha)
)
}
},
content = {
// 6
TimeCard(
timezone = timezoneString,
hours = timezoneHelper.hoursFromTimeZone(timezoneString),
time = timezoneHelper.getTime(timezoneString),
date = timezoneHelper.getDate(timezoneString)
)
},
// 7
onDismiss = { zone ->
if (currentTimezoneStrings.contains(zone)) {
currentTimezoneStrings.remove(zone)
}
}
)
}
}
Here’s the explanation of the above code:
- Use Compose’s
LazyColumn
function, which is like Android’s RecyclerView or iOS’s UITableView for building the vertical list items of time zone cards. - Use
LazyColumn
’sitems
method to go through the list of time zones. - Use the
key
field to set the unique key for each time zone card. This is important if you need to delete items. - Use the included
AnimatedSwipeDismiss
composable to handle swiping away a time zone card. - Set the background to red which will be shown when swiping. You have also added a delete icon on the background to let the user know what swiping the card will do.
- Set the content as a time zone card that will be shown over the background.
- Define the function
onDismiss
to remove the time zone string from your list when the time zone card is swiped away and remove that time zone card from the view.
Finally, return to MainView. Now you want to show the Add Timezone Dialog when the showAddDialog
Boolean is true
. Replace // TODO: Replace with Dialog
with:
// 1
if (showAddDialog.value) {
AddTimeZoneDialog(
// 2
onAdd = { newTimezones ->
showAddDialog.value = false
for (zone in newTimezones) {
// 3
if (!currentTimezoneStrings.contains(zone)) {
currentTimezoneStrings.add(zone)
}
}
},
onDismiss = {
// 4
showAddDialog.value = false
},
)
}
Here’s what you just did:
- If your variable to show the dialog is true, call the
AddTimeZoneDialog
composable. - Your
onAdd
lambda will receive a list of new time zones. - If your current list doesn’t already contain the time zone, then add it to your list.
- Set the show variable back to false.
Build and run the app again. Click the FAB. You’ll see the dialog as follows:
Search for a time zone and select it. Hit the clear button, search for another time zone, and select it. Finally, press the add button. If you selected Los Angeles and New York, you would see something like:
Find Meeting Time Screen
Now that you have the Time Zone screen finished, it’s time to write the Find Meeting Time screen. This screen will allow the user to choose the hour range they want to meet, select the time zones to search against and perform a search that will bring up a dialog with the list of hours found.
Since a composable is made up of many parts, you’ll use the included number picker composable that will look like this:
This has a text field on the left, an up arrow, a number, and a down arrow. You’ll use this for both the start and end hours.
Number Time Card
In the ui folder, create a new file named NumberTimeCard.kt. This will display a card with the label and number picker. Add the following code:
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// 1
@Composable
fun NumberTimeCard(label: String, hour: MutableState<Int>) {
// 2
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, Color.Black),
) {
// 3
Row(
modifier = Modifier
.padding(16.dp)
) {
// 4
Text(
modifier = Modifier
.align(Alignment.CenterVertically),
text = label,
style = MaterialTheme.typography.bodySmall.copy(color = Color.Black)
)
Spacer(modifier = Modifier.size(16.dp))
// 5
NumberPicker(hour = hour, range = IntRange(0, 23),
onStateChanged = {
hour.value = it
})
}
}
}
Here’s an explanation of the numbered comments:
- Create a composable that will take a
label
and anhour
as arguments. Notice thathour
is of the typeMutableSate<Int>
. - Create a rounded card having black border and white color.
- Use a row to lay out the items horizontally.
- Create and center the label using
Alignment.CenterVertically
. - Use
NumberPicker
to show the hour with up/down arrows. It also contains the onStateChanged callback to change the hour values based on clicking the up/down arrows.
This creates a card with a text field on the left and a number picker on the right.
Creating the Find Meeting Time Screen
Now you can put together the Find Meeting Time screen. In the ui folder, create a new file named FindMeetingScreen.kt. Add the following code:
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import com.kodeco.findtime.TimeZoneHelper
import com.kodeco.findtime.TimeZoneHelperImpl
// 1
@Composable
fun FindMeetingScreen(
timezoneStrings: List<String>
) {
val listState = rememberLazyListState()
// 2
// 8am
val startTime = remember {
mutableIntStateOf(8)
}
// 5pm
val endTime = remember {
mutableIntStateOf(17)
}
// 3
val selectedTimeZones = remember {
val selected = SnapshotStateMap<Int, Boolean>()
for (i in timezoneStrings.indices) selected[i] = true
selected
}
// 4
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
val showMeetingDialog = remember { mutableStateOf(false) }
val meetingHours = remember { SnapshotStateList<Int>() }
// 5
if (showMeetingDialog.value) {
MeetingDialog(
hours = meetingHours,
onDismiss = {
showMeetingDialog.value = false
}
)
}
// TODO: Add Content
}
// TODO: Add getSelectedTimeZones
Here’s an explanation of the code you just added:
- Create a composable that takes a list of time zone strings.
- Create some variables to hold the start and end hours. Default to 8 a.m. and 5 p.m.
- Remember the selected time zones.
- Create your time zone helper and remember a few more states that you will need like
showMeetingDialog
andmeetingHours
. - If the boolean for
showMeetingDialog.value
is true, then show theMeetingDialog
results.
Here, you’ve set up all of your variables and put in a small bit of code to show the Add Meeting Dialog when the variable is true. Now, replace // TODO: Add getSelectedTimeZones
with:
fun getSelectedTimeZones(
timezoneStrings: List<String>,
selectedStates: Map<Int, Boolean>
): List<String> {
val selectedTimezones = mutableListOf<String>()
selectedStates.keys.map {
val timezone = timezoneStrings[it]
if (isSelected(selectedStates, it) && !selectedTimezones.contains(timezone)) {
selectedTimezones.add(timezone)
}
}
return selectedTimezones
}
This is a helper function that will return a list of selected time zones based on the selected state map. Now, add the contents in the FindMeetingScreen
composable. Replace // TODO: Add Content
with:
// 1
Column(
modifier = Modifier
.fillMaxSize()
) {
Spacer(modifier = Modifier.size(16.dp))
// 2
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
text = "Time Range",
style = MaterialTheme.typography.headlineSmall.copy(color = MaterialTheme.colorScheme.onBackground)
)
Spacer(modifier = Modifier.size(16.dp))
// 3
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp)
.wrapContentWidth(Alignment.CenterHorizontally),
) {
// 4
Spacer(modifier = Modifier.size(16.dp))
NumberTimeCard("Start", startTime)
Spacer(modifier = Modifier.size(32.dp))
NumberTimeCard("End", endTime)
}
Spacer(modifier = Modifier.size(16.dp))
// 5
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
text = "Time Zones",
style = MaterialTheme.typography.headlineSmall.copy(color = MaterialTheme.colorScheme.onBackground)
)
}
Spacer(modifier = Modifier.size(16.dp))
// TODO: Add LazyColumn
}
Here’s what this code does:
- Create a column that takes up the full width.
- Add a Time Range header in the center of the column.
- Add a row that is centered horizontally.
- Add two
NumberTimeCard
s with their labels and hours with some white space between them by using theSpacer
composable. - Add a row that takes up the full width and has a “Time Zones” header.
This creates a column with a text field, a start & end hour picker, and another text field. Next replace // TODO: Add LazyColumn
with:
// 1
LazyColumn(
modifier = Modifier
.weight(0.6F)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
state = listState,
) {
// 2
itemsIndexed(timezoneStrings) { i, timezone ->
Surface(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth(),
) {
// 3
Checkbox(checked = isSelected(selectedTimeZones, i),
onCheckedChange = {
selectedTimeZones[i] = it
})
Text(timezone, modifier = Modifier.align(Alignment.CenterVertically))
}
}
}
}
Spacer(Modifier.weight(0.1f))
Row(
modifier = Modifier
.fillMaxWidth()
.weight(0.2F)
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(start = 4.dp, end = 4.dp)
) {
// 4
OutlinedButton(
colors = ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary),
onClick = {
meetingHours.clear()
meetingHours.addAll(
timezoneHelper.search(
startTime.intValue,
endTime.intValue,
getSelectedTimeZones(timezoneStrings, selectedTimeZones)
)
)
showMeetingDialog.value = true
}) {
Text("Search")
}
}
Spacer(Modifier.size(16.dp))
Here’s what you just did:
- Add a
LazyColumn
for the list of selected time zones. Give it a weight and padding. - For each selected time zone, create a surface and row for adding a checkbox.
- Create a checkbox that sets the selected map when clicked.
- Create a button to start the search process and show the meeting dialog.
Remember that LazyColumn
is used for lists. You use the items
or itemsIndexed
functions to show an item in a list. Each row will have a checkbox and text with the time zone name. At the bottom will be a button that will start the search process, get all the meeting hours and then show the meeting dialog.
Return to MainView and uncomment the FindMeetingScreen
call. Build and run the app.
Switch between the World Clocks and the Find Meeting Time views. Add a few time zones and press the search button. If no hours appear, try increasing the end time.
Wow, that was a lot of work, but you now have a working Meeting Finder app in Android using Jetpack Compose!
Key Points
-
In Android, you can create your UI in both traditional XML layouts or in the new Jetpack Compose framework.
-
Jetpack Compose is made up of composable functions.
-
Break up your UI into smaller composables.
-
You can create a theme for your app that includes colors and typography.
-
Jetpack Compose uses concepts like Scaffold, TopAppBar and BottomNavigation to simplify creating screens.
Where to Go From Here?
To learn more about Jetpack Compose and other references that are mentioned in the chapter, check out these resources:
-
The book: https://www.kodeco.com/books/jetpack-compose-by-tutorials
-
Official site: https://developer.android.com/jetpack/compose
-
Video course: https://www.kodeco.com/21959310-jetpack-compose/
-
Advanced Classes in Kotlin: https://www.kodeco.com/books/kotlin-apprentice/v3.0/chapters/15-advanced-classes
Congratulations! You’ve written a Jetpack Compose app that uses a shared library for the business logic. The next chapter will show you how to create the iOS app.