Body Mass Index (BMI) is a widely used measure of body fat based on height and weight. With the rise of health consciousness, BMI calculators have become popular tools to monitor and manage one’s weight and health. In this article, we will explore how to develop a BMI Calculator app for Android using Jetpack Compose and Material 3.
- Jetpack Compose is a modern toolkit for building Android UIs declaratively
- Material 3 is the latest version of Google’s design system for Android apps.
We will walk through the process of building a BMI Calculator app that allows users to input their height and weight in either metric or imperial units and get their BMI and corresponding health status instantly. We will cover topics such as Composable UIs, State management, and Material 3 components. A sample video is given below to get an idea about what we are going to do in this article.
Set up your development environment
Before you begin building your app, make sure you have the latest version of Android Studio installed, and create a new jetpack compose material3 project. Add the following dependencies in the project’s app/build.gradle file.
dependencies { implementation platform("androidx.compose:compose-bom:2023.03.00") implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-tooling-preview" implementation "androidx.compose.material3:material3:1.1.0-beta01" implementation "androidx.compose.foundation:foundation" implementation "androidx.activity:activity-compose:1.7.0" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" implementation "com.google.accompanist:accompanist-systemuicontroller:0.38.0" implementation "androidx.core:core-ktx:1.9.0" implementation "androidx.annotation:annotation:1.6.0" // Other dependencies }
Complete build.gradle file is given below.
plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } android { namespace 'srimani7.apps.bmi.calculator' compileSdk 33 defaultConfig { applicationId "srimani7.apps.bmi.calculator" minSdk 21 targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary true } } buildTypes { release { minifyEnabled 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 project.compose_compiler_version } packagingOptions { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } } dependencies { implementation platform("androidx.compose:compose-bom:$project.compose_bom_version") implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-tooling-preview" implementation 'androidx.compose.material3:material3:1.1.0-beta01' implementation "androidx.compose.foundation:foundation" implementation "androidx.activity:activity-compose:$project.activity_version" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$project.lifecycle_version" implementation "androidx.navigation:navigation-compose:$project.navigation_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$project.accompanist_version" implementation "androidx.core:core-ktx:$project.androidx_core_version" implementation "androidx.annotation:annotation:$project.androidx_annotation_version" testImplementation "junit:junit:$junit_version" androidTestImplementation "androidx.test.ext:junit:$androidx_junit_version" androidTestImplementation "androidx.test.espresso:espresso-core:$androidx_expresso_version" debugImplementation "androidx.compose.ui:ui-tooling" debugImplementation "androidx.compose.ui:ui-test-manifest" }
Business Logic – BmiViewModel
Before going to design the UI we first define state and business logic. The BmiViewModel class is an important part of the BMI Calculator app, as it handles the business logic and state management of the app, and is crucial to providing a responsive and intuitive user experience. follow the below diagram for a better understanding.
BmiViewModel.kt
class BmiViewModel : ViewModel() {
var bmi by mutableStateOf( 0.0 )
private set
var message by mutableStateOf( "" )
private set
var selectedMode by mutableStateOf(Mode.Metric)
private set
var heightState by mutableStateOf(ValueState( "Height" , "m" ))
private set
var weightState by mutableStateOf(ValueState( "Weight" , "kg" ))
private set
fun updateHeight(it: String) {
heightState = heightState.copy(value = it, error = null )
}
fun updateWeight(it: String) {
weightState = weightState.copy(value = it, error = null )
}
fun calculate() {
val height = heightState.toNumber()
val weight = weightState.toNumber()
if (height == null )
heightState = heightState.copy(error = "Invalid number" )
else if (weight == null )
weightState = weightState.copy(error = "Invalid number" )
else calculateBMI(height, weight, selectedMode == Mode.Metric)
}
private fun calculateBMI(height: Double, weight: Double, isMetric: Boolean = true ) {
bmi = if (isMetric)
weight / (height * height)
else ( 703 * weight) / (height * height)
message = when {
bmi < 18.5 -> "Underweight"
bmi in 18.5 .. 24.9 -> "Normal"
bmi in 25.0 .. 29.9 -> "Overweight"
bmi >= 30.0 -> "Obsess"
else -> error( "Invalid params" )
}
}
fun updateMode(it: Mode) {
selectedMode = it
when (selectedMode) {
Mode.Imperial -> {
heightState = heightState.copy(prefix = "inch" )
weightState = weightState.copy(prefix = "pound" )
}
Mode.Metric -> {
heightState = heightState.copy(prefix = "m" )
weightState = weightState.copy(prefix = "kg" )
}
}
}
fun clear() {
heightState = heightState.copy(value = "" , error = null )
weightState = weightState.copy(value = "" , error = null )
bmi = 0.0
message = ""
}
enum class Mode { Imperial, Metric }
} |
ValueState.kt
ValueState data class is used to handle the user’s input for both height and weight.
data class ValueState(
val label: String, // lable
val suffix: String, // units
val value: String = "" , // A string representing the user's input value.
val error: String? = null // error meaage if any
) { fun toNumber() = value.toDoubleOrNull()
} |
Designing UI
Theme.kt
The BmiTheme composable is responsible for setting up the theme for the BMI calculator app. It also takes in a dynamicColor flag to enable a dynamic color scheme on devices running Android 12 or later. The composable uses rememberSystemUiController to set the system bar color to transparent and adjust the icon colors based on the useDarkTheme flag. Finally, it applies the MaterialTheme to wrap around the app content.
@Composable fun BmiTheme( useDarkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true ,
content: @Composable () () -> Unit
) { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColors
else -> LightColors
}
val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = !useDarkTheme
)
onDispose {}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
} |
UI Components
The following composable functions were used to build the user interface of the BMI Calculator app:
- ModeSelector(selectedMode: BmiViewModel.Mode, updateMode: (BmiViewModel.Mode) -> Unit): A row of elevated filter chips that allows the user to switch between metric and imperial units.
- RowScope.ActionButton(text: String, onClick: () -> Unit): A button that triggers an action when clicked.
- CustomTextField(state: ValueState, imeAction: ImeAction, onValueChange: (String) -> Unit): A custom text field that accepts a ValueState object, which contains the current value, label, and error message of the text field. It also allows the specification of the ImeAction (such as “done” or “next”) for the keyboard, and a callback function to be triggered whenever the text in the field changes.
@Composable private fun ModeSelector(selectedMode: BmiViewModel.Mode, updateMode: (BmiViewModel.Mode) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy( 10 .dp)) {
BmiViewModel.Mode.values().forEach {
ElevatedFilterChip(selectedMode == it, onClick = { updateMode(it) },
label = {
Text(it.name)
}
)
}
}
} @Composable fun RowScope.ActionButton(text: String, onClick: () -> Unit) { val focusManager = LocalFocusManager.current
Button(
onClick = { focusManager.clearFocus(); onClick() },
shape = RoundedCornerShape( 8 .dp),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues( 14 .dp)
) {
Text(text, fontSize = 15 .sp)
}
} @Composable fun CustomTextField(state: ValueState, imeAction: ImeAction, onValueChange: (String) -> Unit, ) { val focusManager = LocalFocusManager.current
OutlinedTextField(
value = state.value,
isError = state.error != null ,
supportingText = { state.error?.let { Text(it) } },
label = { Text(state.label) },
suffix = { Text(state.prefix) },
onValueChange = onValueChange,
singleLine = true ,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = imeAction),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
})
)
} |
The main app composable
The App composable is the main UI component of the BMI Calculator app. It is composed of a Scaffold that provides the basic structure for the app, including the TopAppBar, which displays the app title. The Column component is used to lay out the different UI elements in a vertical arrangement. The UI structure consists of a top app bar with the title “BMI Calculator”. Below the app bar, there is a column that displays the calculated BMI value and its corresponding message, both of which are dynamically updated based on user input. The BMI value and message are separated by a horizontal divider.
Below the BMI value and message column, there is another column that contains the user input fields, which include two custom text fields for the user to enter their height and weight and a mode selector that lets the user choose between metric and imperial units. The mode selector is implemented using elevated filter chips. At the bottom of the UI, there is a row of two buttons: “Clear” and “Calculate”. These buttons are aligned horizontally and have equal widths.
Copy code @Composable fun App(viewModel: BmiViewModel = viewModel()) { Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text( "BMI Calculator" )
})
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy( 8 .dp)
) {
Text(
text = "%.2f" .format(viewModel.bmi),
style = MaterialTheme.typography.headlineSmall,
)
Divider(modifier = Modifier.fillMaxWidth(.7f), thickness = 2.5 .dp)
Text(text = viewModel.message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.secondary)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ModeSelector(viewModel.selectedMode, updateMode = viewModel::updateMode)
CustomTextField(viewModel.heightState,ImeAction.Next, viewModel::updateHeight)
CustomTextField(viewModel.weightState,ImeAction.Done, viewModel::updateWeight)
}
Spacer(modifier = Modifier)
Row(
horizontalArrangement = Arrangement.spacedBy( 8 .dp),
modifier = Modifier
.padding( 16 .dp, 12 .dp)
.fillMaxWidth(),
) {
ActionButton(text = "Clear" , viewModel::clear)
ActionButton(text = "Calculate" , viewModel::calculate)
}
}
}
} |
MainActivity.kt
The entry point to the application. In the onCreate method, the app’s content view is set to the App composable function wrapped in a BmiTheme ( extended material 3 themes).
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super .onCreate(savedInstanceState)
setContent {
BmiTheme {
App()
}
}
WindowCompat.setDecorFitsSystemWindows(window, false )
}
} |
Output
Conclusion
In conclusion, building a BMI Calculator app using Compose and Material 3 is a straightforward process that allows you to create a modern and responsive user interface with minimal boilerplate code. Compose’s declarative approach to building UIs and Material 3’s ready-to-use components makes it easy to develop and maintain high-quality Android apps that meet the latest design standards. We hope this article has provided you with a useful introduction to building Android apps using Compose and Material 3.