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 that you can use on Android. That UI toolkit is Jetpack Compose. This won’t be an extensive discussion on Jetpack Compose, but it will teach you the basics. Open the starter project from this chapter because it has some starter code.
UI frameworks
KMP doesn’t provide a framework for developing a UI, so you’ll need to use a different framework for each platform. In this chapter, you’ll learn about writing the UI for Android with Jetpack Compose, which also works on desktop. In the next chapter, you’ll learn about building the UI for iOS using SwiftUI, which also works on macOS.
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 version of Android.
One of the main tenants 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 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
.
setContent
To start converting your app to use Compose, open MainActivity. Delete the line containing setContentView
and add the following:
setContent {
Text("Test")
}
You’ll need to import:
import androidx.activity.compose.setContent
import androidx.compose.material.Text
Run the app and you’ll see a small “Test” in the top left corner.
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. See the Where to go from here section at the end of this chapter to learn more about how Compose works.
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. Two different time zones are below that: New York and London. Your user is trying to find a meeting time in all three 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.raywenderlich.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 Material 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. Start by creating a new package in the androidApp module on the same level as MainActivity and name it theme.
Next, create a new file in that package named Colors.kt. Add the following:
import androidx.compose.ui.graphics.Color
val primaryColor = Color(0xFF1e88e5)
val primaryLightColor = Color(0xFF6ab7ff)
val primaryDarkColor = Color(0xFF005cb2)
val secondaryColor = Color(0xFF26a69a)
val secondaryLightColor = Color(0xFF64d8cb)
val secondaryDarkColor = Color(0xFF00766c)
val primaryTextColor = Color(0xFF000000)
val secondaryTextColor = Color(0xFF000000)
val lightGrey = Color(0xFFA2B4B5)
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, right-click on the theme directory and create a new Kotlin file named Typography.kt. Add the following:
import androidx.compose.material.Typography
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// 1
val typography = Typography(
// 2
h1 = TextStyle(
// 3
fontFamily = FontFamily.SansSerif,
// 4
fontSize = 24.sp,
// 5
fontWeight = FontWeight.Bold,
// 6
color = Color.White
),
h2 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 20.sp,
color = Color.White
),
h3 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 12.sp,
color = Color.White
),
h4 = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 10.sp,
color = Color.White
)
)
In the code above, you:
- Create a variable named
typography
that’s an instance of the ComposeTypography
class. - Override the predefined
h1
type. - Define the font family to use. You’ll use the SansSerif family.
- Set the font size.
- Set the font weight.
- Set the font color.
You can also set the letter spacing and many other values defined in TextStyle
. Here, you define h1-h4 styles. There are other styles like body, buttons, captions and subtitles.
Next, create a new file in that package named AppTheme.kt. Create the dark and light palettes by adding the following code:
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = primaryDarkColor,
primaryVariant = primaryLightColor,
secondary = secondaryDarkColor,
secondaryVariant = secondaryLightColor,
onPrimary = Color.White,
background = lightGrey,
onSurface = lightGrey
)
private val LightColorPalette = lightColors(
primary = primaryColor,
primaryVariant = primaryLightColor,
secondary = secondaryColor,
secondaryVariant = secondaryLightColor,
onPrimary = Color.Black,
background = Color.White
)
Create a new function named AppTheme
:
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
// TODO: Add Colors
}
This function will take an optional parameter to set the dark theme. If nothing is passed in, it will check what the system setting is. The last parameter is the composable function to show. Next, get the palette. Replace the // TODO: Add Colors
with the following:
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
// TODO: Add Theme
This sets the colors variable with either light or dark colors. The two functions DarkColorPalette
and LightColorPalette
return a specific Colors
class, which you can make a copy of and change a few colors. Investigate the Colors
class to see what colors you can change. Next, replace // TODO: Add Theme
with the following:
MaterialTheme(
colors = colors,
typography = typography,
content = content
)
This applies the MaterialTheme
with your colors and typography and passes in the given content.
Types
Before you get to the main screen, you’ll need a few types that will be used throughout the app. In the ui folder, create a new 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() {
}
- 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
In the androidApp module in the ui folder, create a new Kotlin file named MainView.kt. 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.padding
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
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.runtime.Composable
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.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.raywenderlich.findtime.android.theme.AppTheme
Notice that you’re importing the material icons you’ll use and a few other compose classes.
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. Next, define a class to handle the bottom navigation item:
data class BottomNavigationItem(
val route: String,
val icon: ImageVector,
val iconContentDescription: String
)
This defines a route, icon for that route and a content description. Next, create a variable with two items:
val bottomNavigationItems = listOf(
BottomNavigationItem(
Screen.TimeZonesScreen.title,
Icons.Filled.Language,
"Timezones"
),
BottomNavigationItem(
Screen.FindTimeScreen.title,
Icons.Filled.Place,
"Find Time"
)
)
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 { mutableStateOf(0)}
// 6
AppTheme {
// TODO: Add Scaffold
}
}
- 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.
- Hold the state containing a list of current time zone strings.
- Use the compose
remember
andmutableStateOf
functions to remember the state of the currently selected index. - Use the theme defined earlier.
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 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
}
) {
// 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.value)
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.value == 0) {
// 1
FloatingActionButton(
// 2
modifier = Modifier
.padding(16.dp),
// 3
onClick = {
showAddDialog.value = true
}
) {
// 4
Icon(
imageVector = Icons.Default.Add,
contentDescription = null
)
}
}
- 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
BottomNavigation(
backgroundColor = MaterialTheme.colors.primary
) {
// 2
bottomNavigationItems.forEachIndexed { i, bottomNavigationItem ->
// 3
BottomNavigationItem(
selectedContentColor = Color.White,
unselectedContentColor = Color.Black,
label = {
Text(bottomNavigationitem.route, style = MaterialTheme.typography.h4)
},
// 4
icon = {
Icon(
bottomNavigationItem.icon,
contentDescription = bottomNavigationItem.iconContentDescription
)
},
// 5
selected = selectedIndex.value == i,
// 6
onClick = {
selectedIndex.value = i
}
)
}
}
- Create a
BottomNavigation
composable. - Use
forEachIndexed
to go through each item in your list of navigation items. - Create a new
BottomNavigationItem
. - 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.material.TopAppBar
import androidx.compose.ui.res.stringResource
import com.raywenderlich.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(title = {
// 4
when (it) {
0 -> Text(text = stringResource(R.string.world_clocks))
else -> Text(text = stringResource(R.string.findmeeting))
}
})
}
}
- 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.
- 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, floating action button and a bottom navigation bar. Try switching between the two icons. What happens?
Local time card
The first thing you want to show 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. Add the following code:
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.material.Card
import androidx.compose.material.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.raywenderlich.findtime.android.theme.primaryColor
import com.raywenderlich.findtime.android.theme.primaryDarkColor
import com.raywenderlich.findtime.android.theme.typography
@Composable
// 1
fun LocalTimeCard(city: String, time: String, date: String) {
// 2
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.background(Color.White)
.padding(8.dp)
) {
// 3
Card(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, Color.Black),
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add body
}
}
}
- Create a function named
LocalTimeCard
that takes acity
,time
anddate
string. - Use a
Box
function that fills the current width and has a height of 140 dp and white background.Box
is a container that draws elements on top of one another. - Use a
Card
with rounded corners and a border. It also fills the width.
For the body, replace // TODO: Add body
with:
// 1
Box(
modifier = Modifier
.background(
brush = Brush.horizontalGradient(
colors = listOf(
primaryColor,
primaryDarkColor,
)
)
)
.padding(8.dp)
) {
// 2
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 3
Column(
horizontalAlignment = Alignment.Start
) {
// 4
Spacer(modifier = Modifier.weight(1.0f))
Text(
"Your Location", style = typography.h4
)
Spacer(Modifier.height(8.dp))
// 5
Text(
city, style = typography.h2
)
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 = typography.h1
)
Spacer(Modifier.height(8.dp))
// 9
Text(
date, style = typography.h3
)
Spacer(Modifier.height(8.dp))
}
}
}
- 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.
- Create the right column.
- Show the time.
- Show the date.
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.items
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.raywenderlich.findtime.TimeZoneHelper
import com.raywenderlich.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. It’s a SnapshotStateList
so that this class can change the values, and other functions will be notified of the changes.
- 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 and height.
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
- Remember the current time.
- 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.value) {
0 -> TimeZoneScreen(currentTimezoneStrings)
// 1 -> FindMeetingScreen(currentTimezoneStrings)
}
If the index is 0, show the Time Zone screen, otherwise 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.
Time card
The time card will look like this:
In the ui folder, create a new Kotlin file named TimeCard.kt. Add:
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.material.Card
import androidx.compose.material.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),
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
)
{
// TODO: Add Content
}
}
}
- This function takes a time zone, hours, time and date.
- Use a box to take up the full width and give it a white background.
- Create a nice-looking card.
Now that you have the card, add a few rows and columns. 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
)
)
}
}
}
- 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 by creating a dialog that will allow the user to pick many time zones to add.
You can add code to use the new time card. The following code will go through the list of current time zone strings, 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,
// 3
key = { timezone ->
timezone
}) { timezoneString ->
// 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)
}
}
)
}
}
- Use Compose’s
LazyColumn
function, which is like Android’s RecyclerView or iOS’s UITableView. - Use
LazyColumn
’sitems
method to go through the list of time zones. - Use the
key
field to set the unique key for each row. This is important if you need to delete items. - Use the included
AnimatedSwipeDismiss
class to handle swiping away a row. - Set the background that will show when swiping.
- Set the content that will show over the background.
- When the row is swiped away, remove the time zone string from your list.
Return to MainView. Now you want to show the Add Timezone Dialog when the showAddDialog
Boolean is true. When that value is true, pass in lambdas for adding and dismissing the dialog. 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
},
)
}
- 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, 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:
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.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.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),
border = BorderStroke(1.dp, Color.White),
elevation = 4.dp,
) {
// 3
Row(
modifier = Modifier
.padding(16.dp)
) {
// 4
Text(
modifier = Modifier
.align(Alignment.CenterVertically),
text = label,
style = MaterialTheme.typography.body1
)
Spacer(modifier = Modifier.size(16.dp))
// 5
NumberPicker(hour = hour, range = IntRange(0, 23),
onStateChanged = {
hour.value = it
})
}
}
}
- Create a composable that will take a label and an hour.
- Wrap it in a card.
- Use a row to lay out the items horizontally.
- Center the label.
- Use
NumberPicker
to show the hour with 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:
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.material.Checkbox
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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 com.raywenderlich.findtime.TimeZoneHelper
import com.raywenderlich.findtime.TimeZoneHelperImpl
// 1
@Composable
fun FindMeetingScreen(
timezoneStrings: List<String>
) {
val listState = rememberLazyListState()
// 2
// 8am
val startTime = remember {
mutableStateOf(8)
}
// 5pm
val endTime = remember {
mutableStateOf(17)
}
// 3
val selectedTimeZones = remember {
val selected = SnapshotStateMap<Int, Boolean>()
for (i in 0..timezoneStrings.size-1) 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
- 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 some variables.
- If the boolean for this is true, show the
MeetingDialog
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. 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.h6
)
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.h6
)
}
Spacer(modifier = Modifier.size(16.dp))
// TODO: Add LazyColumn
}
- Create a column that takes up the full width.
- Add a Time Range header.
- Add a row that is centered horizontally.
- Add two
NumberTimeCard
s with their labels and hours. - Add a row that takes up the full width and has a “Time Zones” header.
This creates a column with a text field, 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(onClick = {
meetingHours.clear()
meetingHours.addAll(
timezoneHelper.search(
startTime.value,
endTime.value,
getSelectedTimeZones(timezoneStrings, selectedTimeZones)
)
)
showMeetingDialog.value = true
}) {
Text("Search")
}
}
Spacer(Modifier.size(16.dp))
- 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.
- 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, check out these resources:
-
The book: https://www.raywenderlich.com/books/jetpack-compose-by-tutorials
-
Official site: https://developer.android.com/jetpack/compose
-
Video course: https://www.raywenderlich.com/21959310-jetpack-compose/
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.