Trang Onboarding
Hướng dẫn xây dựng màn hình onboarding với pager và animations trong Compose Multiplatform.
Data Model
data class OnboardingPage(
val title: String,
val description: String,
val imageRes: DrawableResource? = null,
val backgroundColor: Color = Color.White
)
val onboardingPages = listOf(
OnboardingPage(
title = "Welcome",
description = "Discover amazing features that will help you in your daily life.",
backgroundColor = Color(0xFFE3F2FD)
),
OnboardingPage(
title = "Stay Connected",
description = "Connect with friends and family anytime, anywhere.",
backgroundColor = Color(0xFFFCE4EC)
),
OnboardingPage(
title = "Get Started",
description = "Join millions of users and start your journey today!",
backgroundColor = Color(0xFFE8F5E9)
)
)Onboarding Screen
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnboardingScreen(
onComplete: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
val scope = rememberCoroutineScope()
Box(modifier = Modifier.fillMaxSize()) {
// Pager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
OnboardingPageContent(
page = onboardingPages[page],
pageIndex = page
)
}
// Bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Page indicators
PageIndicator(
pageCount = onboardingPages.size,
currentPage = pagerState.currentPage
)
Spacer(Modifier.height(32.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Skip button
if (pagerState.currentPage < onboardingPages.lastIndex) {
TextButton(onClick = onComplete) {
Text("Skip")
}
} else {
Spacer(Modifier.width(64.dp))
}
// Next/Get Started button
Button(
onClick = {
if (pagerState.currentPage < onboardingPages.lastIndex) {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onComplete()
}
},
shape = RoundedCornerShape(12.dp)
) {
Text(
if (pagerState.currentPage == onboardingPages.lastIndex) {
"Get Started"
} else {
"Next"
}
)
if (pagerState.currentPage < onboardingPages.lastIndex) {
Spacer(Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, contentDescription = null)
}
}
}
}
}
}Page Content
@Composable
fun OnboardingPageContent(
page: OnboardingPage,
pageIndex: Int
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(page.backgroundColor)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Illustration
Box(
modifier = Modifier
.size(250.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
CircleShape
),
contentAlignment = Alignment.Center
) {
// Placeholder icon - replace với actual image
Icon(
imageVector = when (pageIndex) {
0 -> Icons.Default.Star
1 -> Icons.Default.People
else -> Icons.Default.Rocket
},
contentDescription = null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(48.dp))
// Title
Text(
text = page.title,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(16.dp))
// Description
Text(
text = page.description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}Page Indicator
@Composable
fun PageIndicator(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier,
activeColor: Color = MaterialTheme.colorScheme.primary,
inactiveColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(pageCount) { index ->
val isSelected = index == currentPage
Box(
modifier = Modifier
.height(8.dp)
.width(if (isSelected) 24.dp else 8.dp)
.background(
color = if (isSelected) activeColor else inactiveColor,
shape = RoundedCornerShape(4.dp)
)
.animateContentSize()
)
}
}
}Animated Page Indicator
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimatedPageIndicator(
pagerState: PagerState,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(pagerState.pageCount) { index ->
val width by animateDpAsState(
targetValue = if (pagerState.currentPage == index) 24.dp else 8.dp,
animationSpec = tween(300)
)
val color by animateColorAsState(
targetValue = if (pagerState.currentPage == index) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
},
animationSpec = tween(300)
)
Box(
modifier = Modifier
.height(8.dp)
.width(width)
.background(color, RoundedCornerShape(4.dp))
)
}
}
}Animated Content
@Composable
fun AnimatedOnboardingPage(
page: OnboardingPage,
isVisible: Boolean
) {
val animatedAlpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(500)
)
val animatedOffset by animateDpAsState(
targetValue = if (isVisible) 0.dp else 50.dp,
animationSpec = tween(500)
)
Column(
modifier = Modifier
.fillMaxSize()
.alpha(animatedAlpha)
.offset(y = animatedOffset),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Content...
}
}Full Example với Lottie Animation
// Nếu dùng Lottie cho animations
@Composable
fun OnboardingWithLottie(
onComplete: () -> Unit
) {
// State
val pagerState = rememberPagerState(pageCount = { 3 })
val scope = rememberCoroutineScope()
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Pager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
// Animation placeholder
Box(
modifier = Modifier
.size(300.dp)
.background(Color.LightGray, RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center
) {
Text("Animation $page")
}
Spacer(Modifier.height(48.dp))
Text(
text = onboardingPages[page].title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(16.dp))
Text(
text = onboardingPages[page].description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = Color.Gray
)
Spacer(Modifier.weight(1f))
}
}
// Bottom section
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(24.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedPageIndicator(pagerState)
Spacer(Modifier.height(32.dp))
Button(
onClick = {
if (pagerState.currentPage < 2) {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onComplete()
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp)
) {
Text(
if (pagerState.currentPage == 2) "Get Started" else "Continue",
style = MaterialTheme.typography.titleMedium
)
}
if (pagerState.currentPage < 2) {
TextButton(onClick = onComplete) {
Text("Skip")
}
}
}
}
}
}📝 Tóm tắt
| Component | Mục đích |
|---|---|
HorizontalPager | Swipeable pages |
rememberPagerState | Pager state |
PageIndicator | Dots indicator |
animateScrollToPage | Programmatic navigation |
animateDpAsState | Smooth animations |
Best Practices
- Max 3-5 pages - Đừng quá dài
- Skip button - Cho user bỏ qua
- Smooth animations - UX tốt hơn
- Save completion - Để không show lại
Tiếp theo
Học về Trang Profile.
Last updated on