How to implement "Loading images" pattern (Opacity, Exposure and Saturation) from Google's new Material design guidelines

Thanks to @mttmllns! Previous Answer.

Since the previous answer shows an example written in C# and I was curious, I ported it to java. Complete GitHub Example

It outlines a 3-steps process where a combination of opacity, contrast/luminosity and saturation is used in concert to help salvage our poor users eyesight.

For a detailed explanation read this article.

EDIT:

See, the excellent answer provided by @DavidCrawford.

BTW: I fixed the linked GitHubProject to support pre-Lollipop devices. (Since API Level 11)

The Code

AlphaSatColorMatrixEvaluator.java

import android.animation.TypeEvaluator;
import android.graphics.ColorMatrix;

public class AlphaSatColorMatrixEvaluator implements TypeEvaluator {
    private ColorMatrix colorMatrix;
    float[] elements = new float[20];

    public AlphaSatColorMatrixEvaluator() {
        colorMatrix = new ColorMatrix ();
    }

    public ColorMatrix getColorMatrix() {
        return colorMatrix;
    }

    @Override
    public Object evaluate(float fraction, Object startValue, Object endValue) {
        // There are 3 phases so we multiply fraction by that amount
        float phase = fraction * 3;

        // Compute the alpha change over period [0, 2]
        float alpha = Math.min(phase, 2f) / 2f;
        // elements [19] = (float)Math.round(alpha * 255);
        elements [18] = alpha;

        // We substract to make the picture look darker, it will automatically clamp
        // This is spread over period [0, 2.5]
        final int MaxBlacker = 100;
        float blackening = (float)Math.round((1 - Math.min(phase, 2.5f) / 2.5f) * MaxBlacker);
        elements [4] = elements [9] = elements [14] = -blackening;

        // Finally we desaturate over [0, 3], taken from ColorMatrix.SetSaturation
        float invSat = 1 - Math.max(0.2f, fraction);
        float R = 0.213f * invSat;
        float G = 0.715f * invSat;
        float B = 0.072f * invSat;

        elements[0] = R + fraction; elements[1] = G;            elements[2] = B;
        elements[5] = R;            elements[6] = G + fraction; elements[7] = B;
        elements[10] = R;           elements[11] = G;           elements[12] = B + fraction;

        colorMatrix.set(elements);
        return colorMatrix;
    }
}

Here is how you can set it up:

ImageView imageView = (ImageView)findViewById(R.id.imageView);
final BitmapDrawable drawable = (BitmapDrawable) getResources().getDrawable(R.drawable.image);
imageView.setImageDrawable(drawable);
AlphaSatColorMatrixEvaluator evaluator = new AlphaSatColorMatrixEvaluator ();
final ColorMatrixColorFilter filter = new ColorMatrixColorFilter(evaluator.getColorMatrix());
drawable.setColorFilter(filter);

ObjectAnimator animator = ObjectAnimator.ofObject(filter, "colorMatrix", evaluator, evaluator.getColorMatrix());

animator.addUpdateListener( new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        drawable.setColorFilter (filter);
    }
});
animator.setDuration(1500);
animator.start();

And here is the result:

enter image description here


Please note that this answer, as it stands, works for Lollipop only. The reason for this is because the colorMatrix property is not available to animate on the ColorMatrixColorFilter class (it doesn't provide getColorMatrix and setColorMatrix methods). To see this in action, try the code, in logcat output you should see a warning message like this:

Method setColorMatrix() with type class android.graphics.ColorMatrix not found on target class class android.graphics.ColorMatrixColorFilter

That being said, I was able to get this to work on older android versions (pre-Lollipop) by creating the following class (not the best name, I know)

private class AnimateColorMatrixColorFilter {
    private ColorMatrixColorFilter mFilter;
    private ColorMatrix mMatrix;

    public AnimateColorMatrixColorFilter(ColorMatrix matrix) {
        setColorMatrix(matrix);
    }

    public ColorMatrixColorFilter getColorFilter() {
        return mFilter;
    }

    public void setColorMatrix(ColorMatrix matrix) {
        mMatrix = matrix;
        mFilter = new ColorMatrixColorFilter(matrix);
    }

    public ColorMatrix getColorMatrix() {
        return mMatrix;
    }
}

Then, the setup code would look something like the following. Note that I have this "setup" in a derived class from ImageView and so I'm doing this in the overriden method setImageBitmap.

@Override
public void setImageBitmap(Bitmap bm) {
    final Drawable drawable = new BitmapDrawable(getContext().getResources(), bm);
    setImageDrawable(drawable);

    AlphaSatColorMatrixEvaluator evaluator = new AlphaSatColorMatrixEvaluator();
    final AnimateColorMatrixColorFilter filter = new AnimateColorMatrixColorFilter(evaluator.getColorMatrix());
    drawable.setColorFilter(filter.getColorFilter());

    ObjectAnimator animator = ObjectAnimator.ofObject(filter, "colorMatrix", evaluator, evaluator.getColorMatrix());

    animator.addUpdateListener( new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            drawable.setColorFilter(filter.getColorFilter());
        }
    });
    animator.setDuration(1500);
    animator.start();
}

Following up on rnrneverdies's excellent answer, I'd like to offer a small fix to this animation logic.

My problem with this implementation is when it comes to png images with transparency (for example, circular images, or custom shapes). For these images, the colour filter will draw the transparency of the image as black, rather than just leaving them transparent.

The problem is with this line:

elements [19] = (float)Math.round(alpha * 255);

What's happening here is that the colour matrix is telling the bitmap that the alpha value of each pixels is equal to the current phase of the alpha. This is obviously not perfect, since pixels which were already transparent will lose their transparency and appear as black.

To fix this, instead of applying the alpha of the "additive" alpha field in the colour matrix, apply it on the "multiplicative" field:

Rm | 0  | 0  | 0  | Ra
0  | Gm | 0  | 0  | Ga
0  | 0  | Bm | 0  | Ba
0  | 0  | 0  | Am | Aa

Xm = multiplicative field
Xa = additive field

So instead of applying the alpha value on the "Aa" field (elements[19]), apply it on the "Am" field (elements[18]), and use the 0-1 value rather than the 0-255 value:

//elements [19] = (float)Math.round(alpha * 255);
elements [18] = alpha;

Now the transition will multiply the original alpha value of the bitmap with the alpha phase of the animation and not force an alpha value when there shouldn't be one.

Hope this helps