Categories
Android Apps Architecture Jetpack Compose Kotlin

Create a login screen with Jetpack Compose

In a previous post I described how to create a Github Repository browser app. The UI in that app was written using XML layouts(imperative UI). In the recent years developers have been finding it easier to define and update the views using the declarative UI paradigm. This leads to Google releasing Jetpack Compose 1.0 in July 2021.

In this post I will describe how to create a login view with Jetpack compose, while using the view models from an existing MVVM project.

Create a new Compose project

To get the existing view models, I clone the existing project and push it as a new project to Github.

Setup Compose dependencies

Compose requires considerable amount of dependencies and configuration.

android {
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion rootProject.composeVersion
    }
}

dependencies {
    ...
    // Compose
    implementation "androidx.compose.runtime:runtime:$compose_version"
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.foundation:foundation:$compose_version"
    implementation "androidx.compose.foundation:foundation-layout:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    implementation "com.google.android.material:compose-theme-adapter:$compose_version"
    implementation 'androidx.activity:activity-compose:1.3.1'}

Remove Activity/Fragment layout files

Compose does not use XML layouts. Thus, the res/layout folder can be removed. All of the current Activity code can also be removed. Only onCreate() function will be retained to load the @Composable view.

Load a @Composable view

In the Activity’s onCreate, there only needs to be a single call:

setContent {
    LoginView()
}

That sets the UI to the LoginView’s @Composable:

@Composable
fun LoginView() = AppTheme {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .background(Color.LightGray)
            .fillMaxSize()
    ) {
        Text("Hello from compose")
    }
}
Compose “Hello World”

There are more changes required to migrate from XML layouts to @Compose, like removing XML themes and colours. See the full list of changes in this commit.

Create the login fields

For the Login, 3 components are required:

E-mail and password text fields can be represented with TextField or OutlinedTextField.

OutlinedTextField(
    value = email,
    placeholder = { Text(text = "user@email.com") },
    label = { Text(text = "email") },
    onValueChange = onEmailChange,
)

OutlinedTextField(
    value = password,
    placeholder = { Text(text = "password") },
    label = { Text(text = "password") },
    onValueChange = onPasswordChange,
)

The login button can be represented with Button

Button(onClick = {
    if (email.isBlank() == false && password.isBlank() == false) {
        onLoginClick(email)
        focusManager.clearFocus()
    } else {
        Toast.makeText(
            context,
            "Please enter email and password",
            Toast.LENGTH_SHORT
        ).show()
    }
}) {
    Text("Login")
}

Keep the child @Composable stateless

Login Fields will be added as a child to the main LoginView. The child @Composable state changes will be hoisted to the parent view. This way the child components can be reused tested separately.

@Composable
fun LoginView(viewModel: LoginViewModel) = AppTheme {
    var email by rememberSaveable { mutableStateOf("") }
    var password by rememberSaveable { mutableStateOf("") }

    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .background(Color.LightGray)
            .fillMaxSize()
    ) {
        LoginFields(
						// The fields are kept stateless and the state is hoisted to/from
						// the parent LoginView @Composable.
            email,
            password,
            onLoginClick = { viewModel.login(email, password) },
            onEmailChange = { email = it },
            onPasswordChange = { password = it }
        )
    }
}

Hide the keyboard on background press

The keyboard is expected to be hidden on a background press. For this, a .clickable{ } modifier can be added that clears the text field’s focus.

val focusManager = LocalFocusManager.current

Column(
    modifier = Modifier
        .fillMaxSize()
        .clickable { focusManager.clearFocus() }
) {

See the full login fields composable function here.

Animate the text fields up after focus

After selecting one of the text fields, the user expects the UI to slide up. To achieve this, there are different options. When using AppCompat, it can be enabled for a wider range of Android devices.

WindowCompat.setDecorFitsSystemWindows(window, false)
val rootView = findViewById<View>(android.R.id.content).rootView
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
    val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
    rootView.setPadding(0, 0, 0, imeHeight)
    insets
}

Connect the view model

The view model from the XML layout project can be used without changes to its code. It can be injected via Koin’s(koin-android:3.0.2+) getViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        LoginView(getViewModel())
    }
}

The developer needs to use observeAsState() on view model’s LiveData objects to make them update composed views.

val user by viewModel.user.observeAsState()
when (user?.status) {
    Status.LOADING -> {
			// draw compose views

Show progress indicator when loading

Use a CircularProgressIndicator to show progress while the web request is loading.

when (user?.status) {
	Status.LOADING -> {
	    CircularProgressIndicator(modifier = Modifier.size(width = 100.dp, height = 100.dp))
	}
	Status.SUCCESS -> {
		// other views
	}
	...
}

Show Snackbar with error when the request failed

User should be notified about the request error descriptions. Snackbar can be used for this functionality. It needs to be launched in a coroutineScope, because showSnackbar is a suspend function

Status.ERROR -> {
	coroutineScope.launch {
		val result = snackbarHostState.showSnackbar(
			"Login error: ${user?.errorMessage}")

Since the request error state is definite and doesn’t change, we need to manually set the ViewModel to its default state after dismiss button click

when (result) {
	SnackbarResult.ActionPerformed -> viewModel.goToInitialState()
}

See the full code here.

Success handling

In Jetpack Compose there only needs to be a single Activity. If the program receives the LiveData update with a logged in user, it can draw the @Composable with the main app view.

if (user?.data == null) {
    LoginFields(
        email,
        password,
        onLoginClick = { viewModel.login("ttiganik@gmail.com", password) },
        onEmailChange = { email = it },
        onPasswordChange = { password = it }
    )
} else {
		// We have a logged in user, show the main app view
    Text("Login successful")
    Text("User ${user?.data?.login}")
} 

Final result

Login view

Conclusion

Declarative UI is a great evolution in the software development field. In a few years both of the major mobile platforms and cross-platform counterparts like Flutter have embraced it completely. It makes code easier to write, read and test. I recommend the reader to try and write a test app or refactor parts of a current app with Compose. Even if some concepts are hard to understand, it will be worthwhile in the end.

Source code