Animation / Translate / ViewPager2

Parallax Scrolling

Parallax scrolling can be a really interesting technique to use to give parts of your app a bit more life and character. It is a technique built on the mathematical principle if we are moving then objects closer to us appear to move faster than those further away. A great example of this is if we’re driving at night, objects close by such as trees or buildings move through our field of vision quite rapidly, but the moon stays static and appears to follow us. In this article we’ll discover how easy it is to implement parallax scrolling in our app.

Parallax scrolling has been around for a while. Parallax scrolling became popular in video games in the 1980’s and these days can be seen on numerous websites, and it is also becoming popular in apps – specifically in explanatory on-boarding flows. In such cases it can make what can sometimes be quite dry, but necessary content rather more visually exciting. The technique that we’re going to cover is the layer method of parallax scrolling where an image is divided in to separate layers – each representing objects at a different distance from the viewpoint. By scrolling these layers at different rates, we can achieve the parallax effect.

The parallax scrolling is going to be driven by scrolling a ViewPager2 . I recently wrote about ViewPager2 and the implementation in this codebase is based on the code from that article. If you’re not familiar with ViewPager2 it may be worth reading that article before continuing, as it will make what follows much easier to understand.

Let’s begin by looking at the images that represent the different layers. I obtained all of the components for these images from https://publicdomainvectors.org which provides royalty-free vector images, which are open for modification. I extracted and combined separate images in to the layers that I needed.

A couple of things worth pointing out here. Firstly I’ve elected to go with three layers here. Although we could get away with using two, I find a three layer parallax more pleasing. It’s certainly possible to to use more layers, but each additional layer will require additional memory, rendering time, and overdraw. This may not scale well to lower-spec devices, so be careful when considering more layers.

Secondly, I have elected to use VectorDrawables here because they scale much better. However, these are some quite complex vectors which contain complex paths, and create large bitmaps when rendered. Once again this may not scale well to lower-spec devices, but my dev device is a Pixel 2XL which is more than capable of handling these. In a real-world project it may be better to use a bitmap format instead of vectors.

The first layer is the layer that will represent the background and contain the objects that are furthest away from the viewer:

This contains the sky and a cityscape from a distance. The bottom section of the image looks a little odd, but this will actually be overlaid with other layers, so won’t be seen. The second layer represents the middleground objects which are closer than the background objects, but still some distance away from the viewer:

This contains a road, some grass, and some bushes. It is this that will overlay the bottom of the background image. The final layer is the foreground object which appear much closer to the viewer:

This contains a tree, some bushes and a fountain.

One important thing about the middleground and background images is that they are both wider than we actually need. When they are displayed in the layout we’ll only see the middle section of each image. However this will be useful because we can actually change precisely which part of the image is visible by applying a translationX to the ImageViews containing them. This is important for a layered parallax scrolling implementation.

To overlay these we create a layout with three ImageViews overlaid one on top of the other, with the background at the rear, then the middleground, and finally the foreground at the front:



    

    

    


The Fragment which inflates this is about as simple as it gets:

class ParallaxFragment: Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? = inflater.inflate(R.layout.fragment_main, container, false)

    companion object {

        fun newInstance(): Fragment = ParallaxFragment()
    }
}

If we run this we can see the three layers overlaid:

To implement this we need to make a small change to the SectionsPagerAdapter from the article on ViewPager2 to use this Fragment instead:

class SectionsPagerAdapter(
    private val activity: FragmentActivity
) : FragmentStateAdapter(activity) {

    override fun createFragment(position: Int): Fragment =
        ParallaxFragment.newInstance()

    fun getPageTitle(position: Int): CharSequence =
        activity.resources.getString(TAB_TITLES[position])

    override fun getItemCount(): Int = TAB_TITLES.size

    companion object {
        private val TAB_TITLES = arrayOf(
            R.string.tab_text_1,
            R.string.tab_text_2
        )
    }
}

Scrolling between pages provides a standard ViewPager behaviour with the images all moving in unison – not the parallax effect that we want:

To create the parallax effect we need to move the different layers at different rates. To achieve this we need to know about the scroll position of the ViewPager2, and we can get this by attaching a ViewPager2.PageTransformer instance to the ViewPager2:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val sectionsPagerAdapter = SectionsPagerAdapter(this)
        val viewPager = findViewById(R.id.view_pager).apply {
            adapter = sectionsPagerAdapter
            orientation = ViewPager2.ORIENTATION_HORIZONTAL
            setPageTransformer(ParallaxPageTransformer())
        }
        val tabs: TabLayout = findViewById(R.id.tabs)
        TabLayoutMediator(tabs, viewPager, true) { tab, position ->
            tab.text = sectionsPagerAdapter.getPageTitle(position)
        }.attach()
    }
}

All of the work is done in the ParallaxPageTransformer instance, but it’s surprisingly small:

class ParallaxPageTransformer : ViewPager2.PageTransformer {

    companion object {
        private const val FOREGROUND_FACTOR = 0.5f
    }

    private val cache = WeakHashMap()

    override fun transformPage(page: View, position: Float) {
        val offset = page.width * position
        page.getMappings().also { mappings ->
            mappings[R.id.image_background]?.translationX = -offset
            mappings[R.id.image_foreground]?.translationX = offset * FOREGROUND_FACTOR
        }
    }

    private fun View.getMappings(): ViewMappings =
        cache[this] ?: ViewMappings().also { mappings ->
            mappings.put(R.id.image_background, findViewById(R.id.image_background))
            mappings.put(R.id.image_middleground, findViewById(R.id.image_middleground))
            mappings.put(R.id.image_foreground, findViewById(R.id.image_foreground))
            cache[this] = mappings
        }

    private class ViewMappings : SparseArray()
}

The only method that we need to override is transformPage() which takes two arguments – the first is the View that the scrolling applies to, and the second is the scrolling position for that View. Typically during a scroll, this will be called twice for each positional update, one for each of the Views representing the two visible pages in the ViewPager2. These Views will actually be the layouts for the individual Fragments representing the pages in the ViewPager2 . The position will be a float value between -1 and 1 with -1 representing the View being completely off the left hand side of the screen; 0 representing the View being centred and fully visible ion the screen; and 1 representing the View being completely off the right hand side of the screen. Fractional values represent states in between these.

We first calculate an offset value in pixels which we obtain from the width of the View multiplied by the position value (this implementation assumes horizontal scrolling).

Next we look up a set of View mapping. I am using a cache here to avoid having to make multiple findViewById() calls within what is effectively an animation as this could cause jank if we do not render the frame within 16ms). I use a WeakHashMap to store these mappings which uses a key of the View representing the page being updated. A WeakHashMap is useful here because it will only hold a weak reference to the key, and therefore that key can be garbage collected. If it is GC’d then the value for that key / value pair in the WeakHashMap will be removed. Therefore this protects us from leaking the Fragment layouts by holding string references to them. The value of each entry in the WeakHashMap is a SparseArray which is a mapping between the resource IDs and the individual Views fro those IDs within the layout. Essentially this caches the ImageViews representing the background, middleground, and foreground images meaning that we don’t have to do a findViewById() for each one in every frame of the animation – instead we do a much cheaper WeakHashMap, then SparseArray lookup.

Once we have these mappings, we can apply a translationX to each to tweak it’s position. For the background image, we apply the negated offset value which we calculated earlier. This will essentially lock the background in to position, and prevent it from moving during the scroll. We are not actually applying the translationX to the middleground component, so this will move at the same speed as the ViewPager2 scrolling – i.e. it will track the user’s finger / or the fling. For the foreground image we apply the offset mutliplied by a scaling factor. I played with various values here, but rather liked the effect with a scale factor of 0.5 so that’s what we’ve gone with. That causes the foreground elements to move out of the frame faster than the swipe of fling.

If we run this we get the following:

The background remains completely static, while the middleground precisely tracks the swipe or fling, The foreground moves slightly faster, and we get quite a subtle, but overall quite pleasant parallax effect.

This no longer appears to be two separate pages in a ViewPager2, and I have left the TabLayout in place to make it clear that it is still a ViewPager2 underneath. In a real-world scenario it would be much nicer if we didn’t draw attention to this, and allow the illusion of a single scene.

This illusion is possible because I have designed the component images to permit this. Also, in the real world it would be preferable to have distinct scenes on each page, but that is easily achievable by using different layer images on each Fragment. I have just gone with a repetitive scene to keep the example code leaner.

So it is actually the PageTransformer instance which makes this really easy, and we’ve covered this in some depth to properly explain why things have been implemented in the way that they have, but much of the code is in the caching optimisation, and the actual code to perform the parallax scrolling is really quite simple – it’s just applying different translationX values to the ImageViews based on the current scrolling position for each of the Fragment layout hierarchies. We get a really nice visual effect without too much effort!

The source code for this article is available here.

© 2019, Mark Allison. All rights reserved.

Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

1 Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.