Dimitry Ivanov

EmotionLayout

by DIMITRY IVANOV

I've stumbled upon an interesting UI effect by Chris Banes implemented with MotionLayout. Unfortunately... well. Unfortunately it's MotionLayout, so motions are defined in XML scene files sprinkled with constrains and references to views. If you haven't seen one — go ahead and see the one that defines UI effect in question. Now, there must be a simpler way...

First, let's define our layout structure:

<ru.noties.scrollable.ScrollableLayout>

  <HeaderView...>

  <FrameLayout...>
  
</ru.noties.scrollable.ScrollableLayout>

ScrollableLayout comes from the library that I had created when I was dealing with scrollable tabs (it was a UI hit some time ago). It gives a way to add a header to any scrolling content and receive scroll events which we will use here.

Our poster view will go to the content view. We will give it negative top margin and remove children clipping from both ScrollableLayout and content FrameLayout:

<ru.noties.scrollable.ScrollableLayout
    android:clipChildren="false">

    <HeaderView.../>

    <FrameLayout
        android:clipChildren="false">

        <ScrollView...>

        <ImageView
            android:layout_height="96dip"
            android:layout_marginTop="-48dip" />

    </FrameLayout>

</ru.noties.scrollable.ScrollableLayout>

This should give us the result:

result

Now, that XML part is done... 😄, let's add some code:

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val scrollable = findViewById<ScrollableLayout>(R.id.scrollable_layout)
        val scrollView = findViewById<ScrollView>(R.id.scroll_view)
        val image = findViewById<View>(R.id.image)

        // _hook-up_ ScrollableLayout with our scrolling content
        scrollable.setCanScrollVerticallyDelegate {
            scrollView.canScrollVertically(it)
        }
        
        scrollable.addOnScrollChangedListener { y, _, maxY ->

            // when reached half of possible scroll we _flip_ view order and animate the other way
            val half = maxY / 2F

            // change drawing order of poster view
            image.translationZ = if (y <= half) {
                1F
            } else {
                -1F
            }

            val height = image.height

            image.translationY = if (y <= half) {
                // negative translationY (moving to the top of the screen) multiplied by distance ratio
                -(height / 2F) * (y / half)
            } else {
                // initial starting position is -(height / 2F) - which first half is finishing at
                // then we multiply by view height (not half of it for faster movement) by distance ratio
                -(height / 2F) + (height * ((y - half) / half))
            }
        }
    }
}

Pre-Lollipop(21) era

You might've noticed we are using translationZ property to define drawing order of views which is not supported on devices lower than Lollipop (21). For such devices we can rely on natural drawing order of views inside a ViewGroupgetChildAt(0) is drawn first, getChildAt(count - 1) is drawn last. Let's create a simple utility class:

Please note that version based on MotionLayout has no such workaround. In fact — its minimum SDK is 23 (Marshmallow)

interface DrawingOrder {

    fun init()

    fun bringToFront()

    fun sendToBack()

    companion object {
        fun create(view: View): DrawingOrder {
            return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) {
                DrawingOrderPre21(view)
            } else {
                DrawingOrder21(view)
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    private class DrawingOrder21(private val view: View) : DrawingOrder {/*...*/}

    private class DrawingOrderPre21(private val view: View) : DrawingOrder {

        private val parent = view.parent as ViewGroup

        override fun init() {
            if (!isAtFront()) {
                view.bringToFront()
            }
        }

        override fun bringToFront() {
            if (!isAtFront()) {
                view.bringToFront()
            }
        }

        override fun sendToBack() {
            if (!isAtBack()) {
                // small optimization if there are only 2 views in the parent
                if (parent.childCount == 2) {
                    parent.getChildAt(0).bringToFront()
                } else {
                    parent.removeView(view)
                    parent.addView(view, 0)
                }
            }
        }

        private fun isAtFront() = view == parent.getChildAt(parent.childCount - 1)

        private fun isAtBack() = view == parent.getChildAt(0)
    }
}

And update our scroll-changed listener:

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        /*...*/

        val drawingOrder = DrawingOrder.create(image).also { it.init() }

        scrollable.addOnScrollChangedListener { y, _, maxY ->

            val half = maxY / 2F

            if (y <= half) {
                drawingOrder.bringToFront()
            } else {
                drawingOrder.sendToBack()
            }

            /*...*/
        }
    }
}

Interpolation

We also can interpolate poster view movement:

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        /*...*/

        val interpolator = AccelerateDecelerateInterpolator()

        scrollable.addOnScrollChangedListener { y, _, maxY ->

            val half = maxY / 2F

            /*...*/

            val height = image.height

            image.translationY = if (y <= half) {
                val ratio = interpolator.getInterpolation(y / half)
                -(height / 2F) * ratio
            } else {
                val ratio = interpolator.getInterpolation((y - half) / half)
                -(height / 2F) + (height * ratio)
            }
        }
    }
}

MotionLayout is a good piece of software and it has its own usages. Unfortunately it also has downsides:

  • animations created with it are not portable to/from other platforms
  • motion definition is filled with XML constraints and references ids that are not present in definition itself thus creating confusing and error-prone environment
  • understanding (reading) and updating (modifying) motions can be a compelling task for a new-comer
  • and it's certainly not fast in terms of development — having a visual result can take a lot of time thus making MotionLayout a not-so-perfect candidate for quick UI experiments

Source code can be found here


< Previous public static void main for Android
Next > How to toggle boolean in SQLite