Register Page in Jetpack Compose
Lets see how to build the below ui:
@Composable
fun CreateAccountScreen() {Lets create a composable function named CreateAccountScreen
In Jetpack Compose, this line 👇
val scrollState = rememberScrollState()means “create and remember the scroll position for a scrollable UI.”
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var acceptedTerms by remember { mutableStateOf(false) }mutableStateOf(...)
- Creates a state holder
- When the value changes → UI automatically recomposes
- This is how Compose becomes reactive
- 🔹
remember { ... } - Keeps the state across recompositions
- Without
remember, values reset on every UI update
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent
) { innerPadding -> }modifier = Modifier.fillMaxSize()
- Makes the scaffold fill the entire screen
- Standard for full-screen pages
🔹 containerColor = Color.Transparent
- Sets the background color of the screen
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color(0xFFFDE7F3),
Color(0xFFE3F2FD)
)
)
)
.padding(
innerPadding
)
.verticalScroll(scrollState) // ✅ enable scroll
.imePadding()
.padding(
start = 24.dp,
top = 24.dp,
end = 32.dp,
bottom = 24.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { }🔹
background(Brush.verticalGradient)Applies a vertical gradient
Top →
#FDE7F3(pink)Bottom →
#E3F2FD(light blue)⚠️ Background must come before padding to cover full screen.
🔹
padding(innerPadding)Comes from
ScaffoldPrevents content from going under: TopAppBar, BottomBar, System bars
✅ Correct placement
🔹
verticalScroll(scrollState)Makes the Column scrollable
Required when: Screen has many fields
Keyboard may push content off screen
🔹
imePadding()Adjusts layout when keyboard opens
Pushes content up so inputs remain visible
Essential for forms + TextFields
🔹
horizontalAlignment = Alignment.CenterHorizontallyCenters children horizontally
🔹
verticalArrangement = Arrangement.CenterCenters children vertically within available space
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to Color(0xFFFDE7F3), // 0%
0.7f to Color(0xFFFDE7F3), // 70%
1.0f to Color(0xFFE3F2FD) // 100%
)
)This means:
Pink stays dominant until 70% of the height
Blue starts after that
Text(
text = "Hey there,",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
style = MaterialTheme.typography.bodyMediumUses Material 3 typography
bodyMediumis:Normal paragraph / supporting text
Smaller than titles, larger than captions
Automatically adapts to: Font scaling (accessibility)
Text(
text = "Create an Account",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)This is a clean, reusable custom TextField composable for auth screens.
Let’s walk through it piece by piece and then I’ll show improvements + best practices.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
error: String? = null,
trailingIcon: @Composable (() -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(placeholder) },
leadingIcon = { Icon(leadingIcon, null) },
trailingIcon = trailingIcon,
isError = error != null,
shape = RoundedCornerShape(12.dp),
visualTransformation = visualTransformation
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp,
modifier = Modifier.padding(start = 8.dp, top = 4.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
}value: String
- Current text value
- Comes from
mutableStateOf
onValueChange: (String) -> Unit
- Callback when user types
- Updates state in parent
placeholder: String: Hint text shown when empty
Get Pandiyan Mani’s stories in your inbox
Join Medium for free to get updates from this writer.
leadingIcon: ImageVector->Icon shown at the start (email, lock, user, etc.)
error: String? = null
Optional error message:
null→ no errorNon-null → error state + message
trailingIcon: @Composable (() -> Unit)? = null
Optional composable
Used for:
Password visibility toggle 👁
Clear button ❌
visualTransformation: VisualTransformation
Controls how text is displayed
Used for password masking
isError = error != null
Turns TextField border redMaterial handles visuals automatically
shape = RoundedCornerShape(12.dp),AuthTextField(
value = firstName,
onValueChange = { firstName = it },
placeholder = "First Name",
leadingIcon = Icons.Default.Person,
error = validationState.firstNameError
)IconButton(onClick = { passwordVisible = !passwordVisible })
Wraps the icon to make it clickable
Toggles the boolean:
true → false
false → trueThis triggers recomposition, so the icon and text update automatically.
🔹 Icon switch logic
imageVector = if (passwordVisible)
Icons.Default.Visibility
else
Icons.Default.VisibilityOffTerms and Condition:
package com.sample.jetpackcompose
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TermsAndConditions(
accepted: Boolean,
showError: Boolean,
onCheckedChange: (Boolean) -> Unit,
onPrivacyClick: () -> Unit,
onTermsClick: () -> Unit
) {
val annotatedText = buildAnnotatedString {
append("By continuing you accept our ")
pushStringAnnotation("PRIVACY", "privacy")
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
) {
append("Privacy Policy")
}
pop()
append(" and ")
pushStringAnnotation("TERMS", "terms")
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
) {
append("Terms of Use")
}
pop()
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically // ✅ center align
) {
Checkbox(
checked = accepted,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(top = 2.dp) // 🔥 fine-tune alignment
)
Spacer(modifier = Modifier.width(8.dp))
ClickableText(
text = annotatedText,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onBackground,
lineHeight = 18.sp
),
onClick = { offset ->
annotatedText
.getStringAnnotations(offset, offset)
.firstOrNull()
?.let {
when (it.tag) {
"PRIVACY" -> onPrivacyClick()
"TERMS" -> onTermsClick()
}
}
}
)
}
if (showError) {
Text(
text = "Please accept Terms & Conditions",
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp,
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
)
}
}pushStringAnnotation("PRIVACY", "privacy")
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
) {
append("Privacy Policy")
}
pop()You’re creating clickable spans inside a single Text.
🔹
pushStringAnnotationpushStringAnnotation("PRIVACY", "privacy")
Tags a portion of text
Used later to detect which part was clicked
🔹
withStylewithStyle(SpanStyle(...)) { append("Privacy Policy") }
Styles only that text:
Primary color
Medium weight
🔹
pop()Ends the annotation / style scope
This pattern is exactly how Compose expects inline links to be done.
🔹
onClick = { offset -> ... }This is the key part 🔑
What is
offset?Index of the character the user tapped
Example: clicking the “P” in “Privacy” gives its position in the string
🔹
getStringAnnotations(offset, offset)annotatedText.getStringAnnotations(offset, offset)
Checks if the clicked character is inside an annotation
Returns a list of annotations at that position
🔹
firstOrNull()Safely handles clicks on:
Non-clickable text
Empty areas
Prevents crashes 👍
🔹
when (it.tag)when (it.tag) {
"PRIVACY" -> onPrivacyClick()
"TERMS" -> onTermsClick()
}Matches the annotation tag
Triggers the correct navigation/action
Flow in simple words 🧠
User taps text
Compose tells you where they tapped (offset)
You check if that spot has an annotation
If yes → run the corresponding click handler
If no → do nothing
@Composable
fun GradientButton(
text: String,
onClick: () -> Unit
) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(
Color(0xFF64B5F6),
Color(0xFF7986CB)
)
),
shape = RoundedCornerShape(28.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}Full Working Code:
package com.sample.jetpackcompose
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sample.jetpackcompose.ui.theme.JetpackComposeTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
JetpackComposeTheme {
CreateAccountScreen()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateAccountScreen() {
val scrollState = rememberScrollState()
var validationState by remember { mutableStateOf(ValidationState()) }
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var acceptedTerms by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to Color(0xFFFDE7F3), // start
0.7f to Color(0xFFFDE7F3), // 70%
1.0f to Color(0xFFE3F2FD) // 100%
)
)
)
.padding(
innerPadding
)
.verticalScroll(scrollState) // ✅ enable scroll
.imePadding()
.padding(
start = 24.dp,
top = 24.dp,
end = 32.dp,
bottom = 24.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Hey there,",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
Text(
text = "Create an Account",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(32.dp))
AuthTextField(
value = firstName,
onValueChange = { firstName = it },
placeholder = "First Name",
leadingIcon = Icons.Default.Person,
error = validationState.firstNameError
)
AuthTextField(
value = lastName,
onValueChange = { lastName = it },
placeholder = "Last Name",
leadingIcon = Icons.Default.Person,
error = validationState.lastNameError
)
AuthTextField(
value = email,
onValueChange = { email = it },
placeholder = "Email",
leadingIcon = Icons.Default.Email,
error = validationState.emailError
)
AuthTextField(
value = email,
onValueChange = { email = it },
placeholder = "Email",
leadingIcon = Icons.Default.Email,
error = validationState.emailError
)
AuthTextField(
value = password,
onValueChange = { password = it },
placeholder = "Password",
leadingIcon = Icons.Default.Lock,
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible)
Icons.Default.Visibility
else Icons.Default.VisibilityOff,
contentDescription = null
)
}
},
visualTransformation = if (passwordVisible)
VisualTransformation.None
else PasswordVisualTransformation(),
error = validationState.passwordError
)
Spacer(modifier = Modifier.height(12.dp))
TermsAndConditions(
accepted = acceptedTerms,
showError = validationState.termsError != null,
onCheckedChange = { acceptedTerms = it },
onPrivacyClick = { /* open privacy */ },
onTermsClick = { /* open terms */ }
)
Spacer(modifier = Modifier.height(24.dp))
GradientButton(
text = "Register",
onClick = {
validationState = validateForm(
firstName,
lastName,
email,
password,
acceptedTerms
)
if (validationState.isValid) {
// Proceed with API call
}
}
)
Spacer(modifier = Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Divider(modifier = Modifier.weight(1f))
Text(" or ", color = Color.Gray)
Divider(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(24.dp))
Row {
Text("Already have an account? ")
Text(
text = "Login",
color = Color(0xFF5E92F3),
fontWeight = FontWeight.Bold
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
error: String? = null,
trailingIcon: @Composable (() -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(placeholder) },
leadingIcon = { Icon(leadingIcon, null) },
trailingIcon = trailingIcon,
isError = error != null,
shape = RoundedCornerShape(12.dp),
visualTransformation = visualTransformation
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp,
modifier = Modifier.padding(start = 8.dp, top = 4.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
fun GradientButton(
text: String,
onClick: () -> Unit
) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(
Color(0xFF64B5F6),
Color(0xFF7986CB)
)
),
shape = RoundedCornerShape(28.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}package com.sample.jetpackcompose
fun validateForm(
firstName: String,
lastName: String,
email: String,
password: String,
acceptedTerms: Boolean
): ValidationState {
return ValidationState(
firstNameError = if (firstName.isBlank()) "First name required" else null,
lastNameError = if (lastName.isBlank()) "Last name required" else null,
emailError = if (
email.isBlank() ||
!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
) "Invalid email address" else null,
passwordError = if (password.length < 8)
"Password must be at least 8 characters"
else null,
termsError = if (!acceptedTerms) "Accept terms to continue" else null
)
}package com.sample.jetpackcompose
data class ValidationState(
val firstNameError: String? = null,
val lastNameError: String? = null,
val emailError: String? = null,
val passwordError: String? = null,
val termsError: String? = null
) {
val isValid: Boolean
get() = listOf(
firstNameError,
lastNameError,
emailError,
passwordError,
termsError
).all { it == null }
}package com.sample.jetpackcompose
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TermsAndConditions(
accepted: Boolean,
showError: Boolean,
onCheckedChange: (Boolean) -> Unit,
onPrivacyClick: () -> Unit,
onTermsClick: () -> Unit
) {
val annotatedText = buildAnnotatedString {
append("By continuing you accept our ")
pushStringAnnotation("PRIVACY", "privacy")
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
) {
append("Privacy Policy")
}
pop()
append(" and ")
pushStringAnnotation("TERMS", "terms")
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
) {
append("Terms of Use")
}
pop()
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically // ✅ center align
) {
Checkbox(
checked = accepted,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(top = 2.dp) // 🔥 fine-tune alignment
)
Spacer(modifier = Modifier.width(8.dp))
ClickableText(
text = annotatedText,
style = MaterialTheme.typography.bodySmall,
onClick = { offset ->
annotatedText
.getStringAnnotations(offset, offset)
.firstOrNull()
?.let {
when (it.tag) {
"PRIVACY" -> onPrivacyClick()
"TERMS" -> onTermsClick()
}
}
}
)
}
if (showError) {
Text(
text = "Please accept Terms & Conditions",
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp,
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
)
}
}Happy Coding !!!





