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
Phần tiêu đề “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
Phần tiêu đề “Onboarding Screen”@OptIn(ExperimentalFoundationApi::class)@Composablefun 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
Phần tiêu đề “Page Content”@Composablefun 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
Phần tiêu đề “Page Indicator”@Composablefun 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
Phần tiêu đề “Animated Page Indicator”@OptIn(ExperimentalFoundationApi::class)@Composablefun 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
Phần tiêu đề “Animated Content”@Composablefun 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
Phần tiêu đề “Full Example với Lottie Animation”// Nếu dùng Lottie cho animations@Composablefun 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
Phần tiêu đề “📝 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
Phần tiêu đề “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
Phần tiêu đề “Tiếp theo”Học về Trang Profile.