DevAndAndroid

Build for humans, not just for screens.


5 steps to create a Contacts app using Jetpack Compose

I recently had a chance to learn more about Jetpack Compose. As part of that, I created my first app using Compose: a Contacts app. It has a single screen, which displays a list of contacts from your phone in alphabetical order, with a sticky header.

I couldn’t find anything similar to what I was looking for so I thought I’d share my implementation details. Let’s get started!


For those of you that just want the link to the app, here it is:

GitHub – anitaa1990/ContactsApp: Sample app to demonstrate Jetpack compose with MVVM
Sample app to demonstrate Jetpack compose with MVVM – anitaa1990/ContactsAppgithub.com

Step 1: Add dependencies

I have used Hilt and KSP (Kotlin Symbol Processing — similar to KAPT) for injecting our data layer with our UI layer. The other libraries used here are Coil for displaying contact URI image.

I have also used Gradle’s version catalogs to declare dependencies in the app.

You can checkout my other articles on version catalogs and Hilt to get a deeper understanding of what is required here. For now, I’ll just share the relevant files used to add these dependencies.

[versions]
agp = "8.4.1"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.8.1"
activityCompose = "1.9.0"
composeBom = "2024.05.00"
ksp = "1.9.0-1.0.13"
hilt = "2.49"
lifecycleRuntimeComposeAndroid = "2.8.1"
coil = "2.6.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
## Hilt
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
## lifecycle-compose
androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" }
## Coil
coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
## Added KSP & Hilt
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
libs.versions.toml
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
// Added KSP & hilt
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}
build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
// Added KSP & Hilt plugins
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.an.contactsapp"
compileSdk = 34
defaultConfig {
applicationId = "com.an.contactsapp"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
// lifecycle-compose
implementation(libs.androidx.lifecycle.runtime.compose.android)
// Coil
implementation(libs.coil)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
testImplementation(libs.junit)
kspAndroidTest(libs.hilt.android.compiler)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
app/build.gradle.kts

We also need to create a custom Application class and annotate it with @HiltAndroidApp and add it to our Manifest file.

@HiltAndroidApp
class ContactsApplication: Application()
ContactsApplication.kt

Step 2: Add data layer

Now that we have our dependencies, we need to create a model class that holds our contact data.

data class ContactModel(
val id: String,
val displayName: String,
val phoneNumber: String,
val photoThumbnailUri: String?
)
view raw ContactModel.kt hosted with ❤ by GitHub
ContactModel.kt

We also create a Repository class that simply fetches the list of contacts dtails using ContactsContract class.

class ContactsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
// defined a couroutine scope to get list of contacts from the background
suspend fun getContacts(): List<ContactModel> = coroutineScope {
async(Dispatchers.IO) { getContactList() }.await()
}
// get list of contact details such as id, name, phone number & photo URI
// from the `Phone.CONTENT)URI`
private fun getContactList(): List<ContactModel> {
val contactsList = mutableListOf<ContactModel>()
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
null,
null,
null,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
)?.use { contactsCursor ->
val idIndex = contactsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
val nameIndex = contactsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = contactsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val photoUriIndex = contactsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_URI)
val photoThumbNailIndex = contactsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_THUMBNAIL_URI)
while (contactsCursor.moveToNext()) {
val id = contactsCursor.getString(idIndex)
val name = contactsCursor.getString(nameIndex)
val number = contactsCursor.getString(numberIndex)
val photoUri = contactsCursor.getString(photoUriIndex)
val photoThumbnailUri = contactsCursor.getString(photoThumbNailIndex)
contactsList.add(
ContactModel(
id = id, displayName = name, phoneNumber = number, photoThumbnailUri = photoThumbnailUri, photoUri = photoUri
)
)
}
}
return contactsList
}
}
ContactsRepository.kt

Step 3: Add ViewModel

We’re introducing a new data class called ContactUiState which includes a loading Boolean value and the list of contacts to display. This is so that we can display a loading screen when the contacts are first getting fetched.

data class ContactUiState(
val loading: Boolean = false,
val contacts: GroupedContacts = Collections.emptyMap()
)
/**
* Type aliases provide alternative names for existing types.
* If the type name is too long you can introduce a different shorter name
* and use the new one instead. In this example, we've created a typealias
* for `ContactModel` to convert it into a Map which includes the alphabets
* as the key and the list of `ContactModel` as the values of the Map.
*/
typealias GroupedContacts = Map<String, List<ContactModel>>
ContactUiState.kt

Here is our ViewModel class:

@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) : ViewModel() {
// We are defining a MutableStateFlow for the `ContactUiState` with an
// initial value of loading = true. So when the app is first launched,
// a loading screen will be displayed while we fetch the contacts list.
private val _uiState = MutableStateFlow(ContactUiState(loading = true))
val uiState = _uiState.asStateFlow()
init {
// When the ViewModel is first initialized, we update the `ContactUiState`
// loading state as true while we fetch the contact list from the device.
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
getContacts()
}
}
// Once the contact list is fetched, we sort the list
// alphabetically and then update the Ui with the contact list
// and set the loading state as false.
private fun getContacts() = viewModelScope.launch {
val contacts = contactsRepository.getContacts().groupBy { contact ->
contact.displayName.first().toString()
}
_uiState.update { it.copy(
loading = false,
contacts = contacts
) }
}
}
ContactsViewModel.kt

Step 4: Add UI layer

Our MainActivity looks like this:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainApp()
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub
MainActivity.kt

We simply call a compose function called MainApp() . There are two Composables created for our purpose: MainAppContent and ContactsList .

The MainAppContent composable consists of a loading indicator and another composable called ContactsList .

@Composable
fun MainAppContent(
modifier: Modifier = Modifier,
state: ContactUiState
) {
Box(modifier = modifier
.fillMaxSize()
) {
// the loader indicator
AnimatedVisibility(visible = state.loading) {
LinearProgressIndicator(
Modifier.fillMaxWidth()
)
}
// the contact list
ContactsList(
modifier = Modifier.fillMaxSize(),
contacts = state.contacts
)
}
}
view raw MainApp.kt hosted with ❤ by GitHub
MainApp.kt

Our ContactsList composable consists of a LazyColumn with stickHeader that displays the alphabet letters. Each section list the contact names from that letter.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(
modifier: Modifier = Modifier,
contacts: Map<String, List<ContactModel>> = Collections.emptyMap()
){
LazyColumn(modifier) {
contacts.map { entry ->
// Our stick header displays the alphabet letters
stickyHeader {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(start = 12.dp, top = 6.dp, bottom = 6.dp)
) {
Text(
text = entry.key,
style = TextStyle(color = MaterialTheme.colorScheme.primary, fontSize = 20.sp),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Serif,
)
}
}
items(
entry.value.size
) { index ->
// Our Contact list item contains just a Text composable
// that displays the contact name and phone number.
// There are also two ucon buttons to call/send sms
// to that phone number.
ContactListItem(contact = entry.value[index])
}
}
}
}
@Composable
fun ContactListItem(contact: ContactModel) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(end = 10.dp)
.background(MaterialTheme.colorScheme.background)
) {
Image(
painter = rememberAsyncImagePainter(
model = contact.photoThumbnailUri,
error = painterResource(R.drawable.ic_profile_icon)
),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(10.dp)
.size(60.dp)
.clip(CircleShape)
)
Column(modifier = Modifier.weight(1f, true)) {
Text(
text = contact.displayName,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = contact.phoneNumber,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.outline
)
}
IconButton(
onClick = {
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + contact.phoneNumber))
context.startActivity(intent)
},
) {
Image(
imageVector = Icons.Filled.Call,
contentDescription ="",
modifier = Modifier.padding(9.dp),
colorFilter = ColorFilter.tint(
color = MaterialTheme.colorScheme.primary
)
)
}
IconButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("sms:" + contact.phoneNumber))
context.startActivity(intent)
}
) {
Image(
painter = painterResource(id = R.drawable.ic_message),
contentDescription ="",
modifier = Modifier.padding(9.dp),
colorFilter = ColorFilter.tint(
color = MaterialTheme.colorScheme.primary
)
)
}
}
}
view raw ContactsList.kt hosted with ❤ by GitHub
ContactsList.kt

Step 5: Add permissions UI

Finally, in order to be able to fetch contacts from our device, we need to ask for permissions to readcontacts. In Compose, we currently do not have a native way to ask for permissions. The recommended way is to use the Google’s accompanist library to request runtime permissions.

So as a first step, let’s import the library to our app.

[versions]
accompanistVersion = "0.28.0"
[libraries]
## Google Permissions lib
permission-lib = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistVersion" }
libs.versions.toml
dependencies {
// Google permission lib
implementation(libs.permission.lib)
}
app/build.gradle.kts

And now, we can update our MainApp composable to check for permissions before fetching contacts.

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainApp() {
// The theme of our app
ContactsAppTheme {
// Defines a default Scaffold with a default TopAppBar called `MainTopAppBar()`
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { MainTopAppBar() }
) { innerPadding ->
/**
* Reacting to state changes is the core behavior of Compose. You will notice a couple new
* keywords that are compose related – remember & mutableStateOf. remember{} is a helper
* composable that calculates the value passed to it only during the first composition. It then
* returns the same value for every subsequent composition. Next, you can think of
* mutableStateOf as an observable value where updates to this variable will redraw all
* the composable functions that access it. We don't need to explicitly subscribe at all. Any
* composable that reads its value will be recomposed any time the value changes. This ensures
* that only the composables that depend on this will be redraw while the rest remain unchanged.
* This ensures efficiency and is a performance optimization.
*/
// If true, then the permission has been granted so
// the main app content will be displayed
val showMainContent = remember { mutableStateOf(false) }
// If true, then the default permission has been denied so we
// we need to display a rationale dialog
val showRationale = remember { mutableStateOf(false) }
val permissionState = rememberPermissionState(Manifest.permission.READ_CONTACTS)
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted -> showMainContent.value = isGranted }
when {
// request to read contacts permission was granted so we no longer need to
// display the rationale dialog
permissionState.status.isGranted -> {
showMainContent.value = true
showRationale.value = false
}
// request to read contacts permission was denied and permission is not granted
// so we need to display the rationale dialog
!permissionState.status.isGranted && permissionState.status.shouldShowRationale -> showRationale.value = true
else -> {
// request read contacts permission for the first time
LaunchedEffect(permissionState) {
requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
}
if (showRationale.value) {
val context = LocalContext.current
// display a rationale dialog by calling the `PermissionDialog` composable
PermissionDialog(
// open the settings page of the app to enable read contact permission
// when the ok button is clicked in the rationale dialog
onPermissionRequest = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
context.startActivity(intent)
showRationale.value = false
},
// rationale dialog dismiss button is clicked
onDismissRequest = { showRationale.value = false }
)
}
if(showMainContent.value) {
// Calls the MainAppContent() composable since the permission
// has been granted to access contacts.
PermissionGrantedScreen(Modifier.padding(innerPadding))
}
}
}
}
@Composable
private fun PermissionGrantedScreen(
modifier: Modifier
) {
val viewModel: ContactsViewModel = hiltViewModel()
val state by viewModel.uiState.collectAsStateWithLifecycle(
lifecycleOwner = LocalLifecycleOwner.current
)
MainAppContent(modifier = modifier, state = state)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainTopAppBar() {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text(text = stringResource(id = R.string.app_name))
},
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
)
}
@Composable
fun MainAppContent(
modifier: Modifier = Modifier,
state: ContactUiState
) {
Box(modifier = modifier
.fillMaxSize()
) {
// the loader indicator
AnimatedVisibility(visible = state.loading) {
LinearProgressIndicator(
Modifier.fillMaxWidth()
)
}
// the contact list
ContactsList(
modifier = Modifier.fillMaxSize(),
contacts = state.contacts
)
}
}
view raw MainApp.kt hosted with ❤ by GitHub
MainApp.kt

Reacting to state changes is the core behaviour of Compose. You will notice a couple new keywords that are compose related in the above example:

  • remember — remember{} is a helper composable that calculates the value passed to it only during the first composition. It then returns the same value for every subsequent composition.
  • mutableStateOf — mutableStateOf is an observable value where updates to this variable will redraw all the composable functions that access it. We don’t need to explicitly subscribe at all. Any composable that reads its value will be recomposed any time the value changes. This ensures that only the composables that depend on this will be redraw while the rest remain unchanged.

This ensures efficiency and is a performance optimisation.


And that’s pretty much it! I hope this was useful! If you’d like to look at other examples on Jetpack Compose, take a look at this sample app:

GitHub – anitaa1990/JetpackComposeSample: Small write ups on everything Flutter – for anyone like…
Small write ups on everything Flutter – for anyone like me, looking to develop apps using Jetpack Compose …github.com

Happy coding!



Leave a comment