Android Animation in Kotlin: Onboarding screen with Lottie Animation, Navigation Component and ViewPager2
Loveth Nwokike
Posted on November 7, 2020
Animation in android is a way to make your app lively while passing information to the user. There are different APIs available in android you can use to create animations read more about it here. For this post we will be using a library called Lottie which makes it easier to add animations to your application. What you will learn
- Creating an onboarding screen
- Using Navigation Component
- Animations with Lottie
Onboarding screen is mainly used to explain the features of an app using images, text or animations. To begin add the following dependencies to your app module build.gradle file
//navigation component
Implementation "androidx.navigation:navigation-fragment-ktx:2.3.1"
implementation "androidx.navigation:navigation-ui-ktx:2.3.1"
//viewpager2
implementation "androidx.viewpager2:viewpager2:1.0.0"
//indicator
implementation 'me.relex:circleindicator:2.1.4'
//lottie
implementation "com.airbnb.android:lottie:3.4.1"
//datastore
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha02"
We need to enable safe args to ensure type-safety by adding the plugin to gradle. Add the following to the top level build.gradle file in the dependencies block
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.1"
then apply the plugin in the app module build.gradle file
apply plugin: 'androidx.navigation.safeargs.kotlin'
We will be navigating into the onboarding screen from the splash screen.
The flow of navigation is Splash screen -> Onboarding Screen -> Main Page. Following the single activity principle the project will have only one activity(Main Activity) and three(3) fragments.
- Create a new project with an empty template
- Right click on res folder and select New -> Android Resource File
- Enter a name for your navigation graph file, select Navigation as Resource type and click ok
nav_graph.xml
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/splashFragment">
<fragment
android:id="@+id/splashFragment"
android:name="com.wellnesscity.health.ui.intro.SplashFragment"
android:label="fragment_splash"
tools:layout="@layout/fragment_splash" >
<action
android:id="@+id/action_splashFragment_to_welcomeFragment"
app:destination="@id/welcomeFragment"
app:popUpTo="@id/splashFragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_splashFragment_to_onboardingFragment"
app:destination="@id/onboardingFragment"
app:popUpTo="@id/splashFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/onboardingFragment"
android:name="com.wellnesscity.health.ui.intro.OnboardingFragment"
android:label="fragment_onboarding"
tools:layout="@layout/fragment_onboarding" >
<action
android:id="@+id/action_onboardingFragment_to_welcomeFragment"
app:destination="@id/welcomeFragment"
app:popUpTo="@id/onboardingFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/welcomeFragment"
android:name="com.wellnesscity.health.ui.welcome.WelcomeFragment"
android:label="fragment_welcome"
tools:layout="@layout/fragment_welcome" >
</fragment>
</navigation>
The nav_graph contains splash fragment which is the start destination, onboarding fragment and finally the welcome fragment. The first fragment added is set as the start destination which you can change later if need be by changing the id in the app:startDestination
which is an attribute in the <navigation>
tag to a different fragment id that you choose.
- To add the fragments; in the design view at the top left corner click on the add button to see a list of fragments to choose from.
- To add an action that connects the SplashFragment to the OnboardingFragment; hover on the SplashFragment and drag the circular connection point that shows up to the OnboardingFragment
- Repeat same for OnboardingFragment to WelcomeFragment
- The action tag represents possible path to a destination
- The
app:destination
contains the id to the destination fragment - The
app:popUpTo
contains the id to the fragment you want directly in your back stack . - The
app:popUpToInclusive="true"
indicates that the popUpTo fragment should also be removed from the back stack
Connecting the nav_graph to the MainActivity
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:navGraph="@navigation/nav_graph"
app:defaultNavHost="true" />
</androidx.constraintlayout.widget.ConstraintLayout>
- The
android:name
attribute indicates the type of navigation been used - The
app:navGraph
attribute connects the layout to a navigation graph - The
app:defaultNavHost="true"
intercepts the system back button
The nav-graph is all setup, attached to the MainActivity and ready for use
fragment_splash.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.intro.SplashFragment">
<!-- TODO: Update blank fragment layout -->
<ImageView
android:id="@+id/splash_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:srcCompat="@drawable/splash_screen" />
</FrameLayout>
N:B
Dagger Hilt is used as a dependency injection library in the full project and you will be seeing some of its annotations.
SplashFragment.kt
@AndroidEntryPoint
class SplashFragment : Fragment() {
private var binding: FragmentSplashBinding? = null
@Inject lateinit var prefs:DataStore<Preferences>
private val handler = Handler()
private val runnable = Runnable {
//saves the onboarding screen to datastore the first its viewed and ensures it only shows up ones after installation
lifecycleScope.launch {
prefs.data.collectLatest {
if (it[preferencesKey<Boolean>("onBoard")] == true)
requireView().findNavController()
.navigate(SplashFragmentDirections.actionSplashFragmentToWelcomeFragment())
else
requireView().findNavController()
.navigate(SplashFragmentDirections.actionSplashFragmentToOnboardingFragment())
}
}}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
binding = FragmentSplashBinding.inflate(layoutInflater)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
override fun onResume() {
super.onResume()
handler.postDelayed(runnable,3000)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(runnable)
}
}
In the SplashFragment we check if the onboarding has been displayed before, if yes then we navigate to the welcome screen if not we navigate into the OnboardingFragment because we want the onboarding to be displayed only when the app is first installed. To achieve this we used the preference datastore to save a boolean value to true on first installation.
The OnboardingFragment contains three item describing the content of the app, these items will be displayed with ViewPager2 and a RecyclerAdapter. You can either create your own animations with after effect and export as a json file or choose from lottie website
To add the json files to the asset folder;
- Right click on your app folder
- select New->folder->AssetsFolder
- copy and paste them in the asset folder
slide_item_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="15dp">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/imageSlideIcon"
android:layout_width="match_parent"
android:layout_height="300dp"
app:layout_constraintTop_toTopOf="parent"
app:lottie_autoPlay="true"
app:lottie_fileName="diet.json"
app:lottie_loop="true" />
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Title Text"
android:textColor="@color/colorPrimary"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/imageSlideIcon" />
<TextView
android:id="@+id/textDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:gravity="center"
android:text="Description Text"
android:textColor="@color/colorTextSecondary"
android:textSize="15sp"
app:layout_constraintTop_toBottomOf="@id/textTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
Lottie files can be referenced from two folders: raw and asset folder and for this post the json files are in the later. Notice the Lottie view widget in the item layout above;
- The
app:lottie_autoPlay="true"
starts the animation as soon the screen is in view - The
app:lottie_fileName="diet.json"
attribute is used to add file name when the json file is in asset folder - The
app:lottie_loop="true"
attribute keeps the animation active until user exits the screen
IntroSlide
data class IntroSlide (
val title: String,
val description: String,
val icon: String
)
The above model class is used to represent each item present in the onboarding page
IntroSliderAdapter.kt
class IntroSliderAdapter(private val introSlides: List<IntroSlide>)
: RecyclerView.Adapter<IntroSliderAdapter.IntroSlideViewHolder>(){
//for adding text to speech listener in the onboarding fragment
var onTextPassed: ((textView:TextView) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IntroSlideViewHolder {
return IntroSlideViewHolder(
SlideItemContainerBinding.inflate(LayoutInflater.from(parent.context),parent,false)
)
}
override fun getItemCount(): Int {
return introSlides.size
}
override fun onBindViewHolder(holder: IntroSlideViewHolder, position: Int) {
holder.bind(introSlides[position])
}
inner class IntroSlideViewHolder(private val binding: SlideItemContainerBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(introSlide: IntroSlide) {
binding.textTitle.text = introSlide.title
binding.textDescription.text = introSlide.description
binding.imageSlideIcon.imageAssetsFolder = "images";
binding.imageSlideIcon.setAnimation(introSlide.icon)
onTextPassed?.invoke(binding.textTitle)
}
}
}
The above adapter is a normal recyclerView adapter containing a viewHolder with an item layout. In the viewHolder we direct Lottie to where the images are stored by calling .imageAssetsFolder
on the Lottie view and setting it to "images"
and then call setAnimation
on the view as well.
fragment_onboarding.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.intro.OnboardingFragment">
<!-- TODO: Update blank fragment layout -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/indicator"/>
<me.relex.circleindicator.CircleIndicator3
android:id="@+id/indicator"
android:layout_width="wrap_content"
android:layout_height="@dimen/indicator_height"
app:ci_drawable="@drawable/indicator_active"
app:ci_drawable_unselected="@drawable/indicator_inactive"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/buttonNext" />
<com.google.android.material.button.MaterialButton
android:id="@+id/buttonNext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/standard_padding"
android:text="@string/next"
android:textColor="@android:color/white"
android:textStyle="bold"
app:backgroundTint="@color/colorPrimaryDark"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
The onboarding fragment layout contains the relex indicator which is a library that automatically controls indicators with view pagers.
OnboardingFragment.kt
@AndroidEntryPoint
class OnboardingFragment : Fragment() {
private var binding: FragmentOnboardingBinding? = null
@Inject
lateinit var prefs: DataStore<Preferences>
//the items are added to the adapter
private val introSliderAdapter = IntroSliderAdapter(
listOf(
IntroSlide(
"Health Tips / Advice",
"Discover tips and advice to help you to help maintain transform and main your health",
"exercise.json"
),
IntroSlide(
"Diet Tips / Advice",
"Find out basics of health diet and good nutrition, Start eating well and keep a balanced diet",
"diet.json"
),
IntroSlide(
"Covid 19 Symptoms/Prevention tips",
"Get regular Reminders of Covid-19 prevention tips ensuring you stay safe",
"covid19.json"
)
)
)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
binding = FragmentOnboardingBinding.inflate(layoutInflater)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//set the adapter to the viewpager2
binding?.viewPager?.adapter = introSliderAdapter
//sets the viewpager2 to the indicator
binding?.indicator?.setViewPager(binding?.viewPager)
binding?.viewPager?.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
/*
*check if its the last page, change text on the button
*from next to finish and set the click listener to
*to navigate to welcome screen else set the text to next
* and click listener to move to next page
*/
if (position == introSliderAdapter.itemCount - 1) {
//this animation is added to the finish button
val animation = AnimationUtils.loadAnimation(
requireActivity(),
R.anim.app_name_animation
)
binding?.buttonNext?.animation = animation
binding?.buttonNext?.text = "Finish"
binding?.buttonNext?.setOnClickListener {
lifecycleScope.launch {
saveOnboarding()
}
requireView().findNavController()
.navigate(OnboardingFragmentDirections.actionOnboardingFragmentToWelcomeFragment())
}
} else {
binding?.buttonNext?.text = "Next"
binding?.buttonNext?.setOnClickListener {
binding?.viewPager?.currentItem?.let {
binding?.viewPager?.setCurrentItem(it + 1, false)
}
}
}
}
})
}
//suspend function to save the onboarding to datastore
suspend fun saveOnboarding() {
prefs.edit {
val oneTime = true
it[preferencesKey<Boolean>("onBoard")] = oneTime
}
}
}
In the onboardingFragment the IntroSLideAdapter is setup with the list of Items for the Onboarding page, connects the adapter to ViewPager2 and setup the indicator with viewPager2. In the onPageChangeCallback
we check if the currentPage is the last one and change the text on the button to finish and navigate into the WelcomeFragment if not the text remains Next and when clicked moves to the next page, finally we have
This sample code is part of a community project WellnessCity, you can check out just the onboarding branch here
If you have any suggestions or question kindly drop them in the comment section. Thank you
Posted on November 7, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.