DEV Community

Cover image for How to build animated lists
with MotionLayout and
ViewPager2
TomislavNovacicBF for Bornfight

Posted on • Originally published at bornfight.com

How to build animated lists with MotionLayout and ViewPager2

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.

Alt Text

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"  />
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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)
Enter fullscreen mode Exit fullscreen mode

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, or you can hop over to our projects page to check out the full Filmdom case study.

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 (0)