Skip to Content

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

ComponentMục đích
HorizontalPagerSwipeable pages
rememberPagerStatePager state
PageIndicatorDots indicator
animateScrollToPageProgrammatic navigation
animateDpAsStateSmooth animations

Best Practices

  1. Max 3-5 pages - Đừng quá dài
  2. Skip button - Cho user bỏ qua
  3. Smooth animations - UX tốt hơn
  4. Save completion - Để không show lại

Tiếp theo

Học về Trang Profile.

Last updated on