Cdacians

Cdacians
Cdacians

Thursday 7 September 2017

Fragments translate animation

Fragments translate animation

During one of my recent assignments I had to implement a Fragment which slides up from the bottom when you open it and slides back down when you close it. Something like this:
My gif
Here list view is a separate ListFragment and when I press "list" action item - I add this fragment to the main activity.
Lets find out how to do that and what problems you might be facing here.

setCustomAnimations

The very first thing I found is FragmentTransaction#setCustomAnimations() method which lets you specify custom enter/exit animations for certain fragment transaction.
Neat! Looks like exactly what I need. The only thing I need to do - is to create custom Animator via XML and pass it into my fragment transaction:
slide_up.xml
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="translationY"
        android:valueType="floatType"
        android:valueFrom="1280"
        android:valueTo="0"
        android:duration="@android:integer/config_mediumAnimTime"/>
slide_down.xml
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="translationY"
        android:valueType="floatType"
        android:valueFrom="0"
        android:valueTo="1280"
        android:duration="@android:integer/config_mediumAnimTime"/>
fragment toggle routine:
private void toggleList() {
    Fragment f = getFragmentManager()
                       .findFragmentByTag(LIST_FRAGMENT_TAG);
    if (f != null) {
        getFragmentManager().popBackStack();
    } else {
        getFragmentManager().beginTransaction()
                .setCustomAnimations(R.animator.slide_up,
                        R.animator.slide_down,
                        R.animator.slide_up,
                        R.animator.slide_down)
                .add(R.id.list_fragment_container, Fragment
                                .instantiate(this, SlidingListFragment.class.getName()),
                        LIST_FRAGMENT_TAG
                ).addToBackStack(null).commit();
    }
}

Problem #1: Order matters

Please note that order in which you call methods of your fragment transaction matters! In my case I call setCustomAnimations before add. If you swap these calls - animation will not be specified.

Problem #2: Hardcoded values are bad

Unfortunately, it is not possible to specify relative translation in ObjectAnimator (like 100%, -50%, etc.), so we had to hardcode target translation. Don't want to even explain why this is bad.

setYFraction()

Definitely hardcoded translation values is not acceptable approach for me, so let's see what we can do here.
Even if ObjectAnimator doesn't have relative translation attribute, it doesn't mean we cannot create our own one. Essentially we can animate any built-in object property (accessible via setter method) or even create our own one.
Having said that, let's create custom RelativeLayout and add yFraction attribute which we will be animating via ObjectAnimator:
SlidingRelativeLayout.java:
package com.trickyandroid.fragmenttranslate.app.view;

import android.content.Context;
import android.util.AttributeSet;
import android.widget.RelativeLayout;

public class SlidingRelativeLayout extends RelativeLayout {

    public SlidingRelativeLayout(Context context) {
        super(context);
    }

    public SlidingRelativeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public SlidingRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public void setYFraction(final float fraction) {
        float translationY = getHeight() * fraction;
        setTranslationY(translationY);
    }

    public float getYFraction() {
        if (getHeight() == 0) {
            return 0;
        }
        return getTranslationY() / getHeight();
    }
}
Here I created custom attribute yFraction which represents current Y translation relative to view's height (fraction 0.5 = 50% of height). I.e. now if we animate our fraction value from 0 to 1 - we will animate translationY from 0% to 100%
Now let's set this custom layout as a root layout for my fragment:
sliding_fragment_layout.xml
<?xml version="1.0" encoding="utf-8"?>

<com.trickyandroid.fragmenttranslate.app.view.SlidingRelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#7c7c7c">

    <ListView
            android:id="@android:id/list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

</com.trickyandroid.fragmenttranslate.app.view.SlidingRelativeLayout>
And let's update my animation resources:
slide_up.xml:
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="yFraction"
        android:valueType="floatType"
        android:valueFrom="1.0"
        android:valueTo="0"
        android:duration="@android:integer/config_mediumAnimTime"/>
slide_down.xml
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="yFraction"
        android:valueType="floatType"
        android:valueFrom="0"
        android:valueTo="1.0"
        android:duration="@android:integer/config_mediumAnimTime"/>
Let's see what we've got:
yFraction
WTF? It seems like it slides correctly, but the very first frame in slide_up animation is not translated :(
The problem is that custom fragment animation starts before our layout is measured. It means that our getHeight() is '0' thus translationY is '0' as well when it should be 100%. To confirm this let's add onPreDrawListener() to our custom layout and dump current yFractiontranslationY and getHeight() values:
logcat
Every setYFraction call represents a call from our animator. Here you can see that first 2 calls were made when layout height was '0', but yFraction is 1 which means it should be translated by entire height of the view.
And when the first frame is about to render - translation is set incorrectly.

setYFraction(). Revised

The problem is clear - we need to remember yFraction value and update our translationY once view is measured but just before it is rendered. onPreDraw() suits the best here:
SlidingRelativeLayout.java
package com.trickyandroid.fragmenttranslate.app.view;

import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewTreeObserver;
import android.widget.RelativeLayout;

public class SlidingRelativeLayout extends RelativeLayout {

    private float yFraction = 0;

    public SlidingRelativeLayout(Context context) {
        super(context);
    }

    public SlidingRelativeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public SlidingRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    private ViewTreeObserver.OnPreDrawListener preDrawListener = null;

    public void setYFraction(float fraction) {

        this.yFraction = fraction;

        if (getHeight() == 0) {
            if (preDrawListener == null) {
                preDrawListener = new ViewTreeObserver.OnPreDrawListener() {
                    @Override
                    public boolean onPreDraw() {
                        getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
                        setYFraction(yFraction);
                        return true;
                    }
                };
                getViewTreeObserver().addOnPreDrawListener(preDrawListener);
            }
            return;
        }

        float translationY = getHeight() * fraction;
        setTranslationY(translationY);
    }

    public float getYFraction() {
        return this.yFraction;
    }
}
Let's see what we got:
fixed animation
Gif puts a lot of distortion, but on a device this animation looks really smooth and silky.

Bonus

One neat trick would also help with our "missing" first frame. Along with translate animation you can also animate alpha (from 0 to 1). This will cause first frame to be fully transparent, so you don't see it :) No need to mention that it is not a solution, but rather a workaround..:
slide_up.xml:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <objectAnimator
            android:interpolator="@android:anim/accelerate_decelerate_interpolator"
            android:propertyName="yFraction"
            android:valueType="floatType"
            android:valueFrom="1.0"
            android:valueTo="0"
            android:duration="@android:integer/config_mediumAnimTime"/>
    <objectAnimator
            android:interpolator="@android:anim/accelerate_decelerate_interpolator"
            android:propertyName="alpha"
            android:valueType="floatType"
            android:valueFrom="0"
            android:valueTo="1.0"
            android:duration="@android:integer/config_mediumAnimTime"/>
</set>
That's kinda it. With these not very complex manipulations we got pretty nice looking fragment animation.

No comments:

Post a Comment