Jetpack Compose Login UI
In this tutorial, we will implement a login screen’s UI using Jetpack Compose.
Open a new Empty Compose Project, name it Compose Login, and add the Compose navigation dependency to your module’s build.gradle file:
build.gradle (Module)
// Navigation
implementation "androidx.navigation:navigation-compose:2.5.0"
MainActivity.kt
package com.popularpenguin.composelogin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.popularpenguin.composelogin.ui.HomeScreen
import com.popularpenguin.composelogin.ui.LoginScreen
import com.popularpenguin.composelogin.ui.theme.ComposeLoginTheme
import com.popularpenguin.composelogin.viewmodel.AuthViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeLoginTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
MainActivityScreen()
}
}
}
}
}
@Composable
private fun MainActivityScreen() {
val navController = rememberNavController()
val viewModel: AuthViewModel = viewModel()
NavHost(navController = navController, startDestination = "login") {
composable("login") {
LoginScreen(
navController = navController,
authState = viewModel.authState,
onLogin = viewModel::authenticateUser
)
}
composable("home") {
HomeScreen(
navController = navController,
authState = viewModel.authState,
onLogOut = viewModel::logout
)
}
}
}
MainActivity is the entry point for our application and contains our navigation system between the login screen and our home page.
The NavHost object holds your routes to your composables. We have two current routes, ‘login’ and ‘home’. These respectively call our LoginScreen and HomeScreen composables, which require the navController and viewModel to be passed to each. Both require the authorization state from the viewModel (whether the user is logged in or out, also denotes if the login attempt succeeded or failed). The LoginScreen will require you to pass the authenticateUser function from the viewModel and the HomeScreen requires the viewModel’s logout function.
User.kt
package com.popularpenguin.composelogin.model
data class User(
val email: String, // primary key, must be unique
val name: String = "",
val password: String = "",
val authToken: String? = null
)
Create a new package called model. In User.kt, create a data class called User, which will be used to represent the user logging in. Note that the user’s email address must be unique; all users in our system will need their unique user name (which is the user’s email address) and their password.
The authToken variable isn’t used currently in this login example as it is generated by the server. It is just included here for completeness.
AuthViewModel.kt
package com.popularpenguin.composelogin.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.popularpenguin.composelogin.model.User
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() {
var authState: AuthState by mutableStateOf(AuthState.LoggedOut())
private set
private val testUsers = mutableSetOf(
User(
name = "Test User",
email = "m",
password = "m"
),
User(
name ="Brian",
email = "brian@hotmail.com",
password = "hunter2"
),
User(
name = "Abraham Lincoln",
email = "abraham.lincoln@gmail.com",
password = "guywithahat"
),
User(
name = "George Washington",
email = "george.washington@microsoft.com",
password = "OGpresident"
)
)
fun authenticateUser(email: String, password: String) {
if (email.isBlank() or password.isBlank()) return
viewModelScope.launch {
authState = AuthState.LoggingIn()
delay(500L) // simulate network delay
testUsers.firstOrNull { it.email == email }?.let { user ->
if (user.password == password) {
authState = AuthState.Success(user, "ACCESS_TOKEN")
return@launch
}
}
authState = AuthState.Failure()
}
}
fun logout() {
authState = AuthState.LoggedOut()
}
sealed class AuthState {
data class LoggedOut(val message: String = "") : AuthState()
data class LoggingIn(val message: String = "") : AuthState()
data class Success(val user: User, val authToken: String) : AuthState()
data class Failure(val error: String = "") : AuthState()
}
}
Our ViewModel’s purpose is to check if a user is valid and update the app’s authorization state. This state keeps track of whether a user’s login attempt is successful or failed and also if the user is logged in or out. Authorization state will be persisted through configuration changes due to being part of the view model.
In our ViewModel, we first create a variable for the authorization state (logging in, logged out, login success, login failure) and assign it an initial state of logged out. We then create four test users for our system. The first test user is just set as a single letter for testing purposes as it’s slow to type out complete email addresses. In a normal app, the fields would be validated and notify the user if they enter a non-email user name.
The authenticateUser function checks the test users that we created and compares emails to correct passwords. We check if either the email or password is empty and return if one or both is; both fields should be populated. Next, we run a coroutine asynchronously to simulate the login process. We first set the authState as ‘logging in’ (AuthState.LoggingIn) and set a simulated network delay of half a second (500 ms). We then check the email and password passed into the authenticateUser function against our test list and set the authState as a success (and giving a simulated authToken as well) if the password matches the email. If no emails + passwords match the list, we set the authState as a failure.
The logout() function simply sets the authState to AuthState.Logout(), denoting that the user is logged out.
Finally we have our AuthState sealed class. It has four data subclasses that either represent the login attempt or the current state of the user. LoggingIn is the state where a user has submitted the login request but the server has yet to respond to the user. Success means the server has approved the user’s login request. Failure means the login request was denied. LoggedOut is the default state.
Login.kt
package com.popularpenguin.composelogin.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.popularpenguin.composelogin.R
import com.popularpenguin.composelogin.viewmodel.AuthViewModel
@Composable
fun LoginScreen(
navController: NavController,
authState: AuthViewModel.AuthState,
onLogin: (String, String) -> Unit
) {
// On successful login, switch to 'Home' page
if (authState is AuthViewModel.AuthState.Success) {
navController.navigate(route = "home") {
launchSingleTop = true
}
} else {
LoginContent(authState, onLogin)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginContent(
authState: AuthViewModel.AuthState,
onLogin: (String, String) -> Unit
) {
val (email, setEmail) = remember { mutableStateOf("") }
val (password, setPassword) = remember { mutableStateOf("") }
Column(
Modifier
.verticalScroll(state = rememberScrollState(), enabled = true)
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header image
Image(
modifier = Modifier.padding(top = 64.dp, bottom = 64.dp),
painter = painterResource(id = R.drawable.login),
contentDescription = null
)
LoginEmailTextField(
text = email,
onTextChange = setEmail
)
// Password
LoginPasswordTextField(
modifier = Modifier.padding(top = 32.dp),
text = password,
onTextChange = setPassword
)
// Button - Log in
Button(
modifier = Modifier.padding(32.dp),
onClick = { onLogin(email, password) }
) {
Text("Log in")
}
LoginMessageText(
modifier = Modifier.padding(32.dp),
authState = authState
)
}
}
If the authState stored in the ViewModel is a success, automatically take us to the home screen (user is currently logged in). The NavController’s navigate function takes our “home” route and launches our activity as singletop, which avoids putting multiple copies of our activity on the back stack. If the authState is in any state other than AuthState.Success, bring up the login screen content (LoginContent composable).
The LoginContent composable represents our login screen UI. It has two variables, email and password, as well as two functions, setEmail and setPassword. The UI is composed of one column with several elements. This column fills all available space on the screen and has all of its elements centered. The user’s scroll location is stored in the parameter passed to the Modifier’s verticalScroll() function.The header image is the login logo (R.drawable.login), which is used by the painter parameter. We set the top and bottom padding to 64 dp (density-independent pixels, which adjust to the user’s screen size), giving us a decent amount of space between the top edge of the app screen and the login input fields.
Our custom composable, LoginEmailTextField, takes our email String and setEmail function as parameters (more on our custom composables in the LoginComponents section). This field is where we type in our email address (user name).
LoginPasswordTextField is similar, taking our password String and setPassword function as parameters. This field is where we type in our password. It also sets the top padding to 32 dp, as we need some spacing between our composables.
Our login button takes our onLogin function as a parameter in onClick; we pass our inputted email and password. The button’s body is just a simple text composable with the String “Log In”.
Finally, we have a simple composable, LoginMessageText, which just displays our authState to the user, passing in our padding modifier and the authState itself.
LoginComponents.kt
package com.popularpenguin.composelogin.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.popularpenguin.composelogin.viewmodel.AuthViewModel
@ExperimentalComposeUiApi
@Composable
fun LoginEmailTextField(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
) {
LoginTextField(
modifier = modifier,
text = text,
onTextChange = onTextChange,
label = "User Email",
keyboardType = KeyboardType.Email
)
}
@ExperimentalComposeUiApi
@Composable
fun LoginPasswordTextField(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
) {
LoginTextField(
modifier = modifier,
text = text,
onTextChange = onTextChange,
label = "Password",
keyboardType = KeyboardType.Password,
visualTransformation = PasswordVisualTransformation()
)
}
@ExperimentalComposeUiApi
@Composable
private fun LoginTextField(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
label: String,
keyboardType: KeyboardType,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
modifier = modifier,
value = text,
onValueChange = onTextChange,
label = { Text(text = label) },
shape = RoundedCornerShape(16.dp),
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
maxLines = 1,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done, keyboardType = keyboardType),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
visualTransformation = visualTransformation
)
}
@Composable
fun LoginMessageText(modifier: Modifier = Modifier, authState: AuthViewModel.AuthState) {
val text: String
val color: Color
when (authState) {
is AuthViewModel.AuthState.LoggedOut -> {
text = "Logged out"
color = Color.Black
}
is AuthViewModel.AuthState.Success -> {
return
}
is AuthViewModel.AuthState.Failure -> {
text = "Invalid user name or password"
color = Color.Red
}
is AuthViewModel.AuthState.LoggingIn -> {
text = "Logging in..."
color = Color.Blue
}
}
Text(
modifier = modifier,
text = text,
color = color
)
}
Our LoginComponents file consists of the email and password composables for our login screen. The LoginEmailTextField and LoginPasswordTextField composables are similar in that they have the same parameters and both call the same composable, but they both apply different parameters to the LoginTextField composable.
LoginTextField takes our supplied text, set text function, label (email or password), keyboard type (which displays a keyboard appropriate for the field you are filling out [email or password]), and visual transformation (used for the password to hide the characters when the user types it out).
The LoginMessageText composable just shows the user the current authorization state (Logged Out, Logging In, Success, Failure).
AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ComposeLogin"
android:windowSoftInputMode="adjustResize">
One final touch for our keyboard is to adjust our UI to ensure the keyboard doesn’t cover essential input fields. We can add android:windowSoftInputMode=“adjustResize” to
AndroidManifest.xml under <activity> to ensure this behavior.
Home.kt
package com.popularpenguin.composelogin.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.popularpenguin.composelogin.model.User
import com.popularpenguin.composelogin.ui.theme.ComposeLoginTheme
import com.popularpenguin.composelogin.viewmodel.AuthViewModel
@Composable
fun HomeScreen(
navController: NavController,
authState: AuthViewModel.AuthState,
onLogOut: () -> Unit
) {
// Launch login screen if user isn't logged in
if (authState !is AuthViewModel.AuthState.Success) {
navController.navigateUp()
} else {
HomeContent(
user = authState.user,
onLogOut = onLogOut
)
}
}
@Composable
fun HomeContent(
user: User,
onLogOut: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Text(
text = "Welcome ${user.name}",
style = TextStyle(fontSize = 24.sp)
)
Button(onClick = onLogOut) {
Text("Log out")
}
}
}
The HomeScreen composable contains the navigation for this screen. If the authState isn’t AuthState.Success, we just navigate to the login screen (navigating up takes us back a screen, which would be the login screen). If it is, we show the home screen’s content by calling the HomeContent composable with the user and onLogOut functions as parameters.
The HomeContent composable displays a column with the user’s name on top and the logout button below. The onLogOut function is passed from HomeContent’s signature to its button. This function sets the authState to AuthState.LoggedOut. Setting the state here will recompose your UI and your navController will navigate up to the login screen.
Comments
Post a Comment