Hello! This is my first post on this platform, I hope you’ll like it. Please leave a comment or react to the post to show me support. P.S. Many more posts are on their way!
We are all seeing beautiful design concepts made by creative designers every day on various websites and blogs, but most of us never had a chance to try to build them. Have you ever wondered what it’s like to build such layouts in practice? Is it super easy with modern frameworks or is it too much hassle? Well the purpose of this blog is to solve that mystery and find out how building one of those complex designs looks like first hand.
Implementation
Animating widgets like ImageViews, AppBars and DrawerLayouts is easy thanks to MotionLayout (which became stable with the release of ConstrainLayout 2.0), but what about list items? There are a bunch of blogs about animating widgets, but very few are about animation list items. For my sample project, I chose our Filmdom app and tried to implement its landing screen. Below, you will find out how it went and what are the pros and cons of building such a layout.
Final version of the layout is shown in the video below.
Note: It is considered that you know the basics of MotionLayout and ViewPager2, as we will not go into details of how each of these components work.
The purpose of this blog is list animation, so we will exclude everything else for the point of brevity.
Top picks for you
To showcase recommended movies, I used ViewPager2 which extends RecyclerView. The only attributes from the XML layout worth mentioning are clipToPadding and clipChildren, we need to set both of those attributes to false. Attribute clipChildren determines whether each child view is allowed to draw outside its own bounds within the parent, and clipToPadding attribute determines whether child view is allowed to draw outside of the parent itself. Without these attributes set to false, our ViewPager would clip two upcoming pages and we would only see the currently selected page which is not what we want. In this case, attribute paddingEnd narrows our selected page, so we have space to show two more pages on the screen.
<androidx.viewpager2.widget.ViewPager2
android:layout_width="match_parent"
android:layout_height=”wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingEnd="120dp" />
```
Margins are not supported by **ViewPager.LayoutParams**, so I had to wrap my fragment’s layout into an additional **FrameLayout** for our margin to be applied. I couldn’t use padding on my layout because it has a background and if I had applied margin on the **ViewPager** widget directly, then we wouldn’t see the selected page leaving the screen. Applying padding on the **ViewPager** widget is also not an option because we have set our **clipToPadding** and **clipChildren** attributes, and the result would be that the previous page is visible – which is not what we want.
To achieve a faded effect on the unselected cards like in the design, I had to set the background of the inner **FrameLayout** to black. It is important that the background follows the shape of the loaded image so we don’t get excess black area. The reason I didn’t use some different approach to achieve this is that I needed to create a fade-in/fade-out effect, and the only way I found to do that is by changing the opacity of the view. We will go in further detail about this in the next section. Another thing I had to do to get three pages on the screen at the same time was to use the **setOffscreenPageLimit** method and set the limit value to “3” on our **ViewPager**.
```xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:background="@drawable/bg_rounded_circle_drawable">
<ImageView
…
/>
</FrameLayout>
</FrameLayout>
```
For the purpose of scaling and fade-in/fade-out animations, I implemented **PageTransformer** on our **ViewPager**. In the overridden **transformPage** method, we first have to set the elevation on each card depending on its position. Line **ViewCompat.setElevation(page, -abs(position))** sets the highest elevation on the first card in our stack of three and a proportionally smaller elevation on each consecutive card. This gives us the desired visual effect of cards coming towards us. Next thing we have to do is calculate our scale factor, so we can apply the correct values on our movie cards.
Our **when** function is scaling our cards if they are visible on the screen, otherwise they have default values. The transition on the X axis is proportional to the scale factor, so we can have smooth animations. The conditions afterwards determine the visibility of movie posters inside our card. Selected card in the center position has visibility at 100% and its visibility drops as it moves away from the center of the **ViewPager**. We can change the card’s movie poster visibility through **setAlpha** method. This **PageTransformer** setup requires a little bit of trial and error to achieve the desired result.
```kotlin
class SliderTransformer(private val offscreenPageLimit: Int) : ViewPager2.PageTransformer {
companion object {
private const val DEFAULT_TRANSLATION_X = .0f
private const val DEFAULT_TRANSLATION_FACTOR = 1.46f
private const val SCALE_FACTOR = .14f
private const val DEFAULT_SCALE = 1f
}
override fun transformPage(page: View, position: Float) {
page.apply {
ViewCompat.setElevation(page, -abs(position))
val scaleFactor = -SCALE_FACTOR * position + DEFAULT_SCALE
when (position) {
in 0f..offscreenPageLimit - 1f -> {
scaleX = scaleFactor
scaleY = scaleFactor
translationX = -(width / DEFAULT_TRANSLATION_FACTOR) * position
}
else -> {
translationX = DEFAULT_TRANSLATION_X
scaleX = DEFAULT_SCALE
scaleY = DEFAULT_SCALE
}
}
val recommendedMovieIV: ImageView = findViewById(R.id.recommendedMovieIV)
if (position <= -1.0f || position >= 1.0f) {
recommendedMovieIV.alpha = 0.5f
} else if (position == 0.5f) {
recommendedMovieIV.alpha = 1.0f
} else if (position < 0.5f) {
recommendedMovieIV.alpha = 1.0f - abs(position)
}
}
}
}
```
We can conclude that MotionLayout is not designed to work with a single ViewPager2 page. For animating pages, we have to use **PageTransformer** and if we really want to use MotionLayout on a single page we can do so by putting its implementation logic inside the transformPage method. The issue with that approach is that we then have to find a way to track MotionLayout’s progress from the value of the position argument and that’s not an easy job to do. There is a possibility to animate all items in the **ViewPager2/RecyclerView** pretty easily, but that is not what we are looking for here.
###Coming soon
For the upcoming movie list, I also used **ViewPager2**. Our page’s layout is wrapped inside MotionLayout as we use it here to achieve the tilting animation on swipe gestures. Each swipe animates the whole ViewPager and all of its pages.
```xml
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="240dp"
app:layoutDescription="@xml/tilt_scene">
<LinearLayout
…
/>
</androidx.constraintlayout.motion.widget.MotionLayout>
```
MotionScene below is self-explanatory, so I won’t be getting into details about it. Pages are tilted to the right or to the left depending on the gesture direction.
```xml
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
android:id="@+id/rightToLeft"
app:constraintSetEnd="@id/end"
app:constraintSetStart="@id/start"
app:duration="1000">
<OnSwipe
app:dragDirection="dragLeft"
app:touchAnchorId="@id/motionContainer" />
<KeyFrameSet>
<KeyAttribute
android:rotationY="0"
app:framePosition="0"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="-15"
app:framePosition="25"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="-30"
app:framePosition="50"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="-15"
app:framePosition="75"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="0"
app:framePosition="100"
app:motionTarget="@id/motionContainer" />
</KeyFrameSet>
</Transition>
<Transition
android:id="@+id/leftToRight"
app:constraintSetEnd="@id/end"
app:constraintSetStart="@id/start"
app:duration="1000">
<OnSwipe
app:dragDirection="dragRight"
app:touchAnchorId="@+id/motionContainer" />
<KeyFrameSet>
<KeyAttribute
android:rotationY="0"
app:framePosition="0"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="15"
app:framePosition="25"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="30"
app:framePosition="50"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="15"
app:framePosition="75"
app:motionTarget="@id/motionContainer" />
<KeyAttribute
android:rotationY="0"
app:framePosition="100"
app:motionTarget="@id/motionContainer" />
</KeyFrameSet>
</Transition>
<ConstraintSet android:id="@+id/start" />
<ConstraintSet android:id="@+id/end" />
</MotionScene>
```
To show 5 pages on the screen at the same time, I again had to use the **setOffscreenPageLimit** method on our **ViewPager** to set the limit to “3”. I applied a custom **OnPageChangeCallback** on our **ViewPager**. This callback is determining the direction of the gesture by comparing the current offset to the previous one. With this information, we can calculate the **realCurrentPosition**, **nextPosition** and **realOffset**.
With the **realCurrentPosition** and **nextPosition** properties, we are fetching the first two visible pages from ViewPager. On swipe left, we are scaling down the currently selected page and scaling up the following page. For the swipe to the right, it’s vice-versa. This callback is also responsible for determining the strength of the gesture, so that we can tilt the pages accordingly.
```kotlin
class UpcomingMovieChangedCallback(private val binding: ActivityMainBinding, private val upcomingMoviesAdapter: GenericMoviesAdapter) : ViewPager2.OnPageChangeCallback() {
var goingLeft: Boolean by Delegates.notNull()
private var lastOffset = 0f
var progress: Float by Delegates.notNull()
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
val realCurrentPosition: Int
val nextPosition: Int
val realOffset: Float
goingLeft = lastOffset > positionOffset
if (goingLeft) {
realCurrentPosition = position + 1
nextPosition = position
realOffset = 1 - positionOffset
} else {
nextPosition = position + 1
realCurrentPosition = position
realOffset = positionOffset
}
val currentCard = (binding.upcomingMoviesVP[0] as RecyclerView).layoutManager?.findViewByPosition(realCurrentPosition)
currentCard?.scaleX = (1 + 0.4 * (1 - realOffset)).toFloat()
currentCard?.scaleY = (1 + 0.4 * (1 - realOffset)).toFloat()
currentCard?.pivotY = 0f
val nextCard = (binding.upcomingMoviesVP[0] as RecyclerView).layoutManager?.findViewByPosition(nextPosition)
nextCard?.scaleX = (1 + 0.4 * realOffset).toFloat()
nextCard?.scaleY = (1 + 0.4 * realOffset).toFloat()
nextCard?.pivotY = 0f
lastOffset = positionOffset
progress = when (position) {
position -> positionOffset
position + 1 -> 1 - positionOffset
position - 1 -> 1 - positionOffset
else -> 0f
}
}
}
```
**PageTransformer** for upcoming movies is applying translation on the X axis that is equivalent to the page position in the list and negative value of summed page margins. We are using values calculated in our custom **OnPageChangeCallback** to determine correct transition animation and progress of the transition on our **ViewPager**. There are also a bunch of page decorations that are not mentioned here, but you can check them out in a repository that is linked at the bottom.
```kotlin
val nextItemVisiblePx = resources.getDimension(R.dimen.viewpager_next_item_visible) //50dp
val currentItemHorizontalMarginPx = resources.getDimension(R.dimen.viewpager_current_item_horizontal_margin_right) //230dp
val pageTranslationX = nextItemVisiblePx + currentItemHorizontalMarginPx
val pageTransformer = PageTransformer { page: View, position: Float ->
page.translationX = -pageTranslationX * position
if (upcomingMovieChangedCallback.goingLeft) {
((page as ViewGroup).getChildAt(0) as MotionLayout).setTransition(R.id.leftToRight)
} else {
((page as ViewGroup).getChildAt(0) as MotionLayout).setTransition(R.id.rightToLeft)
}
(page.getChildAt(0) as MotionLayout).progress = upcomingMovieChangedCallback.progress
}
binding.upcomingMoviesVP.setPageTransformer(pageTransformer)
```
###Conclusion
Building complex layouts that include animations is still not an easy job to do – even with **MotionLayout** and **PageTransformer** classes. Despite the fact that a bunch of popular apps from years ago have designs with multiple pages on the screen with only one page highlighted, there still isn’t any standardised way of dealing with this issue. This is an easy job to do if we have three pages on the screen, but if we have five or more pages visible on the screen, you will get a massive headache before getting things to behave as you desire (no matter what widget you use for displaying a list of items).
**MotionLayout** and **ViewPager2** are both great tools, but they are not meant to resolve the above mentioned issue. I think we should have a library dedicated to this kind of list presentation – there is no sense in writing a bunch of complex calculations just to get a commonly wanted behaviour.
###Looking to learn more?
You can find this entire project on [GitLab](https://gitlab.com/bornfight-mobile/android-public/cinema), or you can hop over to our projects page to check out the full [Filmdom case study](https://www.bornfight.com/work/filmdom/).
If you know an easier way of solving this issue, or have some other complex layouts in mind that you would like to see come to life, feel free to leave a comment.
Top comments (1)
i have download and build your project. Then the project is gives the error (Caused by: org.gradle.api.GradleException: File google-services.json is missing. The Google Services Plugin cannot function without it.).The google-services.json is missing so please update the file.