Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

First Edition · Android 12, iOS 15, Desktop · Kotlin 1.6.10 · Android Studio Bumblebee

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.

Fig. 3.1 - Initial screen in Jetpack Compose
Fig. 3.1 - Initial screen in Jetpack Compose

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:

Fig. 3.2 - List of selected time zones
Fig. 3.2 - List of selected time zones

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:

Fig. 3.3 - Dialog to search for time zones
Fig. 3.3 - Dialog to search for time zones

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.

Fig. 3.4 - Select meeting start and end time ranges
Fig. 3.4 - Select meeting start and end time ranges

Tapping the search button brings up the result dialog:

Fig. 3.5 - List of possible times for the meeting
Fig. 3.5 - List of possible times for the meeting

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.

Fig. 3.6 - Create a new package in the androidApp module
Fig. 3.6 - Create a new package in the androidApp module

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:

  1. Create a variable named typography that’s an instance of the Compose Typography class.
  2. Override the predefined h1 type.
  3. Define the font family to use. You’ll use the SansSerif family.
  4. Set the font size.
  5. Set the font weight.
  6. 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() {
}

  1. Define an alias named OnAddType that takes a list of strings and doesn’t return anything.
  2. Define an alias used when dismissing a dialog.
  3. Define a composable function.
  4. Define a function that takes an integer.
  5. 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
  }
}

  1. Define this function as a composable.
  2. This function takes a function that can provide a top bar (toolbar on Android) and defaults to an empty composable.
  3. Hold the state for showing the add dialog.
  4. Hold the state containing a list of current time zone strings.
  5. Use the compose remember and mutableStateOf functions to remember the state of the currently selected index.
  6. 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:

  1. remember: Remembers the variable and retains its value between redraws.
  2. mutableStateOf: Creates a MutableState instance whose state is observed by Compose.
  3. SnapshotStateList: Creates a MutableList whose state is observed by Compose.
  4. collectAsState: Collects values from a Kotlin coroutine StateFlow 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
    )
   }
}
  1. For the first page, create a FloatingActionButton.
  2. Use Compose’s Modifier function to add padding.
  3. Set a click listener. Set the variable to show the add dialog screen. Changing this value will cause a redraw of the screen.
  4. 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
      }
    )
  }
}
  1. Create a BottomNavigation composable.
  2. Use forEachIndexed to go through each item in your list of navigation items.
  3. Create a new BottomNavigationItem.
  4. Set the icon field to the icon in your list.
  5. Is this screen selected? Only if the selectedIndex value is the current index.
  6. 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))
            }
        })
    }
}
  1. Initialize the Napier logging library. (Be sure to include needed imports.)
  2. Set your main content to the MainView composable.
  3. For Android, you want a top app bar.
  4. 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:

Fig. 3.7 - Basic structure of the World Clocks screen
Fig. 3.7 - Basic structure of the World Clocks screen

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:

Fig. 3.8 - Card to display the local time zone
Fig. 3.8 - Card to display the local time zone

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
        }
    }
}
  1. Create a function named LocalTimeCard that takes a city, time and date string.
  2. 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.
  3. 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))
        }
    }
}
  1. Use a box to display the gradient background.
  2. Create a row that fills the entire width.
  3. Create a column for the left side of the card.
  4. Use a spacer with a weight modifier to push the text to the bottom.
  5. Display the city text with the given typography.
  6. Push the right column over.
  7. Create the right column.
  8. Show the time.
  9. 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.

  1. Create an instance of your TimeZoneHelper class.
  2. Remember the state of the list that will be defined later.
  3. 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
  1. Remember the current time.
  2. Use Compose’s LaunchedEffect. It will be launched once but continue to run. The method will get the updated time every minute. You pass Unit as a parameter to LaunchedEffect so that it is not canceled and re-launched when LaunchedEffect is recomposed.
  3. Use the LocalTimeCard function you created earlier. Use TimeZoneHelper’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:

Fig. 3.9 - World Clocks screen with local time zone
Fig. 3.9 - World Clocks screen with local time zone

Nicely done! Your app is really starting to take shape.

Time card

The time card will look like this:

Fig. 3.10 - Card to display a time zone
Fig. 3.10 - Card to display a time zone

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
        }
    }
}
  1. This function takes a time zone, hours, time and date.
  2. Use a box to take up the full width and give it a white background.
  3. 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
                )
            )
        }
    }
}
  1. Use a box to set the background to white.
  2. Create a row that fills the width.
  3. Create a column on the left side.
  4. Show the time zone.
  5. Create a row underneath the previous one.
  6. Show the hours in bold.
  7. Show the text “hours from local.”
  8. Create a column on the right side.
  9. Show the time.
  10. 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)
                }
            }
        )
    }
}
  1. Use Compose’s LazyColumn function, which is like Android’s RecyclerView or iOS’s UITableView.
  2. Use LazyColumn’s items method to go through the list of time zones.
  3. Use the key field to set the unique key for each row. This is important if you need to delete items.
  4. Use the included AnimatedSwipeDismiss class to handle swiping away a row.
  5. Set the background that will show when swiping.
  6. Set the content that will show over the background.
  7. 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
    },
  )
}
  1. If your variable to show the dialog is true, call the AddTimeZoneDialog composable.
  2. Your onAdd lambda will receive a list of new time zones.
  3. If your current list doesn’t already contain the time zone, add it to your list.
  4. Set the show variable back to false.

Build and run the app again. Click the FAB. You’ll see the dialog as follows:

Fig. 3.11 - Time zone search is functional
Fig. 3.11 - Time zone search is functional

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:

Fig. 3.12 - List of selected time zones
Fig. 3.12 - List of selected time zones

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:

Fig. 3.13 - Time chooser UI component
Fig. 3.13 - Time chooser UI component

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
                })
        }
    }
}
  1. Create a composable that will take a label and an hour.
  2. Wrap it in a card.
  3. Use a row to lay out the items horizontally.
  4. Center the label.
  5. 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
  1. Create a composable that takes a list of time zone strings.
  2. Create some variables to hold the start and end hours. Default to 8 a.m. and 5 p.m.
  3. Remember the selected time zones.
  4. Create your time zone helper and remember some variables.
  5. 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
}
  1. Create a column that takes up the full width.
  2. Add a Time Range header.
  3. Add a row that is centered horizontally.
  4. Add two NumberTimeCards with their labels and hours.
  5. 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))
  1. Add a LazyColumn for the list of selected time zones. Give it a weight and padding.
  2. For each selected time zone, create a surface and row.
  3. Create a checkbox that sets the selected map when clicked.
  4. 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:

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.