Implementation of KenBurns effect on Android with dynamic bitmaps setting

android developer picture android developer · May 15, 2014 · Viewed 7.6k times · Source

Background

I am working on an implementation of the "KenBurns effect" (demo here) on the action bar , as shown on this library's sample (except for the icon that moves, which I've done so myself).

In fact, I even asked about it a long time ago (here), which at this point I didn't even know its name. I was sure I've found a solution, but it has some problems.

Also, since I sometimes show the images from the device, some of them even need to be rotated, so I use a rotatableDrawable (as shown here).

The problem

The current implementation cannot handle multiple bitmaps that are given dynamically (from the Internet, for example), and doesn't even look at the input images' size.

Instead, it just does the zooming and translation in a random way, so many times it can zoom too much/little, and empty spaces can be shown.

The code

Here's the code that is related to the problems:

private float pickScale() {
    return MIN_SCALE_FACTOR + this.random.nextFloat() * (MAX_SCALE_FACTOR - MIN_SCALE_FACTOR);
}

private float pickTranslation(final int value, final float ratio) {
    return value * (ratio - 1.0f) * (this.random.nextFloat() - 0.5f);
}

public void animate(final ImageView view) {
    final float fromScale = pickScale();
    final float toScale = pickScale();
    final float fromTranslationX = pickTranslation(view.getWidth(), fromScale);
    final float fromTranslationY = pickTranslation(view.getHeight(), fromScale);
    final float toTranslationX = pickTranslation(view.getWidth(), toScale);
    final float toTranslationY = pickTranslation(view.getHeight(), toScale);
    start(view, KenBurnsView.DELAY_BETWEEN_IMAGE_SWAPPING_IN_MS, fromScale, toScale, fromTranslationX,
            fromTranslationY, toTranslationX, toTranslationY);
}

And here's the part of the animation itself, which animates the current ImageView:

private void start(View view, long duration, float fromScale, float toScale, float fromTranslationX, float fromTranslationY, float toTranslationX, float toTranslationY) {
    view.setScaleX(fromScale);
    view.setScaleY(fromScale);
    view.setTranslationX(fromTranslationX);
    view.setTranslationY(fromTranslationY);
    ViewPropertyAnimator propertyAnimator = view.animate().translationX(toTranslationX).translationY(toTranslationY).scaleX(toScale).scaleY(toScale).setDuration(duration);
    propertyAnimator.start();
}

As you can see, this doesn't look at the view/bitmap sizes, and just randomly selects how to zoom and pan.

What I've tried

I've made it work with dynamic bitmaps, but I don't understand what to change on it so that it will handle the sizes correctly.

I've also noticed there is another library (here) that does this work, but it also has the same problems, and it's even harder to understand how to fix them there. Plus it randomly crashes . Here's a post I've reported about it.

The question

What should be done in order to implement Ken-Burns effect correctly, so that it could handle dynamically created bitmaps?

I'm thinking that maybe the best solution is to customize the way the ImageView draws its content, so that at any given time, it will show a part of the bitmap that is given to it, and the real animation would be between two rectangles of the bitmap . Sadly, I'm not sure how to do this.

Again, the question isn't about getting bitmaps or decoding. It's about how to make them work well with this effect without crashes or weird zoom in/out which show empty spaces.

Answer

Xaver Kapeller picture Xaver Kapeller · May 22, 2014

I have look at the source code of the KenBurnsView and it isn't actually that hard to implement the features you want, but there are a few things I have to clarify first:


1. Loading images dynamically

The current implementation cannot handle multiple bitmaps that are given dynamically (from the Internet, for example),...

It isn't difficult to download images dynamically from the internet if you know what you are doing, but there are many ways to do it. Many people don't actually come up with their own solution but use a networking library like Volley to download the image or they go straight for Picasso or something similar. Personally I mostly use my own set of helper classes but you have to decide how exactly you want to download the images. Using a library like Picasso is most likely the best solution for you. My code samples in this answer will use the Picasso library, here is a quick example of how to use Picasso:

Picasso.with(context).load("http://foo.com/bar.png").into(imageView);

2. Image Size

...and doesn't even look at the input images' size.

I really don't understand what you mean by that. Internally the KenBurnsView uses ImageViews to display the images. They take care of properly scaling and displaying the image and they most certainly take the size of the images into account. I think your confusion might be caused by the scaleType which is set for the ImageViews. If you look at the layout file R.layout.view_kenburns which contains the layout of the KenBurnsView you see this:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/image0"
        android:scaleType="centerCrop"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/image1"
        android:scaleType="centerCrop"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>

Notice that there are two ImageViews instead of just one to create the crossfade effect. The important part is this tag which is found on both ImageViews:

android:scaleType="centerCrop"

What this does is tell the ImageView to:

  1. Center the image inside the ImageView
  2. Scale the image so its width fits inside the ImageView
  3. If the image is taller than the ImageView it will be cropped to the size of the ImageView

So in its current state the images inside the KenBurnsView may be cropped at all times. If you want the image to scale to fit completely inside the ImageView so nothing has to be cropped or removed you need to change the scaleType to one of those two:

  • android:scaleType="fitCenter"
  • android:scaleType="centerInside"

I don't remember the exact difference between those two, but they should both have the desired effect of scaling the image so it fits both on the X and Y axis inside the ImageView while at the same time centering it inside the ImageView.

IMPORTANT: Changing the scaleType potentially messes up the KenBurnsView!
If you really just use the KenBurnsView to display two images then changing the scaleType won't matter aside from how the images are displayed, but if you resize the KenBurnsView - for example in an Animation - and the ImageViews have the scaleType set to something other than centerCrop you will loose the parallax effect! Using centerCrop as scaleType of an ImageView is a quick and easy way to create parallax-like effects. The drawback of this trick is probably what you noticed: The image in the ImageView will most likely be cropped and not completely visible!

If you look at the layout you can see that all Views in there have match_parent as layout_height and layout_width. This could also be a problem for certain images as the match_parent constraint and certain scaleTypes sometimes produce strange results when the images are considerably smaller or larger than the ImageView.


The translate animation also takes the size of the image into account - or at least the size of the ImageView. If you look at the source code of animate(...) and pickTranslation(...) you will see this:

// The value which is passed to pickTranslation() is the size of the View!
private float pickTranslation(final int value, final float ratio) {
    return value * (ratio - 1.0f) * (this.random.nextFloat() - 0.5f);
}

public void animate(final ImageView view) {
    final float fromScale = pickScale();
    final float toScale = pickScale();

    // Depending on the axis either the width or the height is passed to pickTranslation()
    final float fromTranslationX = pickTranslation(view.getWidth(), fromScale);
    final float fromTranslationY = pickTranslation(view.getHeight(), fromScale);
    final float toTranslationX = pickTranslation(view.getWidth(), toScale);
    final float toTranslationY = pickTranslation(view.getHeight(), toScale);

    start(view, KenBurnsView.DELAY_BETWEEN_IMAGE_SWAPPING_IN_MS, fromScale, toScale, fromTranslationX, fromTranslationY, toTranslationX, toTranslationY);
}

So the view already accounts for the images size and how much the image is scaled when calculating the translation. So the concept of how this works is okay, the only problem I see is that both the start and end values are randomised without any dependencies between those two values. What this means is one simple thing: The start and endpoint of the animation might be the exact same position or may be very close to each other. As a result of that the animation may sometimes be very significant and other times barely noticeable at all.

I can think of three main ways to fix that:

  1. Instead of randomising both start and end values you just randomise the start values and pick the end values based on the start values.
  2. You keep the current strategy of randomising all values, but you impose range restrictions on each value. For example the fromScale should be a random value between 1.2f and 1.4f and toScale should be a random value between 1.6f and 1.8f.
  3. Implement a fixed translation and scale animation (In other words the boring way).

Whether you choose approach #1 or #2 you are going to need this method:

// Returns a random value between min and max
private float randomRange(float min, float max) {
    return random.nextFloat() * (max - min) + max;
}

Here I have modified the animate() method to force a certain distance between start and end points of the animation:

public void animate(View view) {
    final float fromScale = randomRange(1.2f, 1.4f);
    final float fromTranslationX = pickTranslation(view.getWidth(), fromScale);
    final float fromTranslationY = pickTranslation(view.getHeight(), fromScale);


    final float toScale =  randomRange(1.6f, 1.8f);
    final float toTranslationX = pickTranslation(view.getWidth(), toScale);
    final float toTranslationY = pickTranslation(view.getHeight(), toScale);

    start(view, this.mSwapMs, fromScale, toScale, fromTranslationX, fromTranslationY, toTranslationX, toTranslationY);
}

As you can see I only need to modify how fromScale and toScale are calculated because the translations values are calculated from the scale values. This is not a 100% fix, but it is a big improvement.


3. Solution #1: Fixing KenBurnsView

(Use solution #2 if possible)

To fix the KenBurnsView you can implement the suggestions I mentioned above. Additionally we need to implement a way for the images to be added dynamically. The implementation of how the KenBurnsView handles images is a little weird. We are going to need to modify that a bit. Since we are using Picasso this is actually going to be pretty simple:

Essentially you just need to modify the swapImage() method, I tested it like this and it is working:

private void swapImage() {
    if (this.urlList.size() > 0) {
        if(mActiveImageIndex == -1) {
            mActiveImageIndex = 1;
            animate(mImageViews[mActiveImageIndex]);
            return;
        }

        final int inactiveIndex = mActiveImageIndex;
        mActiveImageIndex = (1 + mActiveImageIndex) % mImageViews.length;
        Log.d(TAG, "new active=" + mActiveImageIndex);

        String url = this.urlList.get(this.urlIndex++);
        this.urlIndex = this.urlIndex % this.urlList.size();

        final ImageView activeImageView = mImageViews[mActiveImageIndex];
        activeImageView.setAlpha(0.0f);

        Picasso.with(this.context).load(url).into(activeImageView, new Callback() {

            @Override
            public void onSuccess() {
                ImageView inactiveImageView = mImageViews[inactiveIndex];

                animate(activeImageView);

                AnimatorSet animatorSet = new AnimatorSet();
                animatorSet.setDuration(mFadeInOutMs);
                animatorSet.playTogether(
                        ObjectAnimator.ofFloat(inactiveImageView, "alpha", 1.0f, 0.0f),
                        ObjectAnimator.ofFloat(activeImageView, "alpha", 0.0f, 1.0f)
                );
                animatorSet.start();
            }

            @Override
            public void onError() {
                Log.i(LOG_TAG, "Could not download next image");
            }
        });        
    }
}

I have omitted a few trivial parts, urlList is just a List<String> which contains all the urls to the images we want to display, urlIndex is used to cycle through the urlList. I moved the animation into the Callback. That way the image will be downloaded in the background and as soon as the image has been downloaded successfully the animations will play and the ImageViews will crossfade. A lot of the old code from the KenBurnsView can now be deleted, for example the methods setResourceIds() or fillImageViews() are now unnecessary.


4. Solution #2: Better KenBurnsView + Picasso

The second library you link to, this one, actually contains a MUCH better KenBurnsView. The KenBurnsView I talk about above is a subclass of FrameLayout and there are a few problems with the approach this View takes. The KenBurnsView from the second library is a subclass of ImageView, this is already a huge improvement. Because of it we can use image loader libraries like Picasso directly on the KenBurnsView and we don't have to take care of anything ourselves. You say that you experience random crashes with the second library? I have been testing it rather extensively the last few hours and didn't encounter a single crash.

With the KenBurnsView from the second library and Picasso this all becomes very easy and very few lines of code, you just have to create a KenBurnsView for example in xml:

<com.flaviofaria.kenburnsview.KenBurnsView
    android:id="@+id/kbvExample"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:src="@drawable/image" />

And then in your Fragment you first have to find the view in the layout and then in onViewCreated() we load the image with Picasso:

private KenBurnsView kbvExample;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.fragment_kenburns_test, container, false);

    this.kbvExample = (KenBurnsView) view.findViewById(R.id.kbvExample);

    return view;
}

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    Picasso.with(getActivity()).load(IMAGE_URL).into(this.kbvExample);
}

5. Testing

I tested everything on my Nexus 5 running Android 4.4.2. Since ViewPropertyAnimators are used this should all be compatible somewhere down to API Level 16, maybe even 12.

I have a omitted a few lines of code here and there so if you have any questions feel free to ask!