Implementing ImageView transition between activities for pre-Lollipop devices.

Danylo Volokh
Android Development by Danylo :)
10 min readMar 22, 2016

In Android 5.0 Google introduced very fancy stuff called “Shared elements transition”. Using it we can do a very cool animation that makes it look like our UI elements are moving from one activity to another. Let’s try do it on an Android below 5.0. All the code from this article is on GitHub.

The animation is based on a known video from Chet Haase : DevBytes: Custom Activity Animations

The difference between what I will do here and the demo from Chet is that I will also animate the ImageView Matrix. This will make animation look more natural. The ImageView from previous screen can have ImageView.ScaleType different from the enlarged ImageView on the “next screen”. Here is a demo:

Shared element transition from one activity to another.
Canceling of shared element transition until it’s finished.

Here is how it looks with a native shared element transition:

Shared element transition between activities on Android Lollipop.

Making activity translucent.

There is two activities :

  • ImagesListActivity.
  • ImageDetailsActivity.

First of all we have to make the content of ImagesListActivity visible while image transition animation is running. It is done by changing two attributes of activity style:

<item name="android:windowBackground">
@android:color/transparent
</item>
<item name="android:windowIsTranslucent">
true
</item>

Now ImageListActivity will be visible when ImageDetailsActivity is on the screen. It has one major drawback:

Eventually we make ImageDetailsActivity fully opaque by setting background color on the layout. The ImageListActivity will be still drawn by WindowManager even if it’s not visible!

So in our case we have two activities. In Android 5 they’ve managed to leave the “second Activity” translucent while animation is running and then make it fully opaque and after that the “first Activity” is not drawn anymore and can be easily garbage collected. So, sadly we don’t have that is versions below Lollipop.

Making ImageDetailsActivity translucent will also change the behaviour of ImagesListActivity. When ImageDetailsActivity is started only the onPause() callback will be called for ImagesListActivity.

Animate Entering ImageDetailsActivity.

When image on ImagesListActivity is clicked ImageDetailsActivity is started:

// ImagesListActivitymImageDetailsImageModel = imageModel;int[] screenLocation = new int[2];
image.getLocationInWindow(screenLocation);

Intent startIntent = ImageDetailsActivity.getStartIntent(this,
imageFile,
screenLocation[0],
screenLocation[1],
image.getWidth(),
image.getHeight(),
image.getScaleType());

startActivity(startIntent);

Some data about clicked image has to be passed to the ImageDetailsActivity:

  • Top-left position of clicked ImageView.
  • Width and height of clicked ImageView.
  • ScaleType of clicked image.
  • Image file, to load the image in ImageDetailsActivity.

Also the imageModel has to be saved in ImagesListActivity for further use.

In ImageDetailsActivity a copy of clicked ImageView is created and added to the root FrameLayout (android.R.id.content — the id of a root FrameLayout that is parent view for layout passed to setContentView() ):

// ImageDetailsActivityFrameLayout androidContent = (FrameLayout)
getWindow()
.getDecorView()
.findViewById(android.R.id.content);
mTransitionImage = new ImageView(this);
androidContent.addView(mTransitionImage);

Bundle bundle = getIntent().getExtras();

int thumbnailTop = bundle.getInt(KEY_THUMBNAIL_INIT_TOP_POSITION)
- getStatusBarHeight();
int thumbnailLeft = bundle.getInt(KEY_THUMBNAIL_INIT_LEFT_POSITION);
int thumbnailWidth = bundle.getInt(KEY_THUMBNAIL_INIT_WIDTH);

int thumbnailHeight = bundle.getInt(KEY_THUMBNAIL_INIT_HEIGHT);

ImageView.ScaleType scaleType = (ImageView.ScaleType)
bundle.getSerializable(KEY_SCALE_TYPE);

FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams)
mTransitionImage.getLayoutParams();
layoutParams.height = thumbnailHeight;
layoutParams.width = thumbnailWidth;
layoutParams.setMargins(thumbnailLeft, thumbnailTop, 0, 0);

File imageFile = (File)
getIntent()
.getSerializableExtra(IMAGE_FILE_KEY);
mTransitionImage.setScaleType(scaleType);

mImageDownloader.load(imageFile).noFade().into(mTransitionImage);

We set initial margins to the view so that it was situated at exact same spot that view from the previous screen was.

You may have noticed mImageDownloader. I use Picasso in this project to load images.

Also the enlarged version of ImageView has to be loaded to the layout. And only after that we can start running entering animation:

// ImageDetailsActivitymImageDownloader.load(imageFile).into(mEnlargedImage, new Callback() {

/**
* Image is loaded when this method is called
*/

@Override
public void onSuccess() {
Log.v(TAG, "onSuccess, mEnlargedImage");

// In this callback we already have image set into
// ImageView and we can use it's Matrix for animation
// But we have to wait for final measurements. We use
// OnPreDrawListener to be sure everything is measured


if (savedInstanceState == null) {

// if savedInstanceState is null activity is started for
// the first time.
// run the animation
runEnteringAnimation(); } else { // activity was retrieved from recent apps. No
// animation needed, just load the image
}
}

@Override
public void onError() {
// CAUTION: on error is not handled. If OutOfMemory
// occured during image loading we have to handle it
// here
Log.v(TAG, "onError, mEnlargedImage");
}
});
}

The important is that if savedInstanceState is null only then we have to run the animation. If it’s not — it was probably retrieved from recent apps or opened after returning from other activity.

Using OnPreDrawListener to start the animation at the correct time.

Here is a code from runEnteringAnimation() method.

This method does very tricky part:
It sets up {@link android.view.ViewTreeObserver.OnPreDrawListener}. When onPreDraw() method is called the layout is already measured. It means that we can use locations of images on the screen at this point. Here is what we have to do in OnPreDrawListener:

  1. When first frame is drawn we start animation.
  2. We just let second frame to be drawn.
  3. Make the ImageView that was clicked on the previous screen invisible and remove onPreDrawListener. We make it invisible to make it look like copied ImageView that is on ImageDetailsActivity look like it was moved from the place where clicked image was. And when it’s moved from there it has to leave an empty space.
// ImageDetailsActivityprivate void runEnteringAnimation() {
Log.v(TAG, "runEnteringAnimation, addOnPreDrawListener");

mEnlargedImage
.getViewTreeObserver()
.addOnPreDrawListener(
new ViewTreeObserver.OnPreDrawListener() {

int mFrames = 0;

@Override
public boolean onPreDraw() {

// When this method is called we already have everything
// laid out and measured so we can start our animation


Log.v(TAG, "onPreDraw, mFrames " + mFrames);

switch (mFrames++) {
case 0:
/**
* 1. start animation on first frame
*/

final int[] finalLocationOnTheScreen = new int[2];
mEnlargedImage.getLocationOnScreen(finalLocationOnTheScreen);

mEnterScreenAnimations.playEnteringAnimation(
finalLocationOnTheScreen[0], // left
finalLocationOnTheScreen[1], // top
mEnlargedImage.getWidth(),
mEnlargedImage.getHeight());

return true;
case 1:
/**
* 2. Do nothing. We just draw this frame
*/


return true;
}
/**
* 3.
* Make view on previous screen invisible on after this
* drawing frame
* Here we ensure that animated view will be visible
* when we make the viw behind invisible
*/

Log.v(TAG, "run, onAnimationStart");
mBus.post(new ChangeImageThumbnailVisibility(false));
mEnlargedImage.getViewTreeObserver()
.removeOnPreDrawListener(this);

Log.v(TAG, "onPreDraw, << mFrames " + mFrames);

return true;
}
});
}

Why do we handle frames in this way:

The Android rendering system is double-buffered and we have to let system draw two frames to be sure that ImageView that is transitioned is already visible. Similar technique is used in the SDK. If we don’t do that the ImageView might blink when animation starts.

See here : android.app.EnterTransitionCoordinator#startSharedElementTransition
You can read more about it at this link: https://source.android.com/devices/graphics/architecture.html

In the code above the is a call from Otto event bus:

mBus.post(new ChangeImageThumbnailVisibility(false));

It sends a message to ImagesListActivity in order to hide clicked image in the images list. Here is how it’s done:

// ImagesListActivity@Subscribe
public void hideImageThumbnail(ChangeImageThumbnailVisibility message){
Log.v(TAG, ">> hideImageThumbnail");

mImageDetailsImageModel.setVisibility(message.isVisible());

updateModel(mImageDetailsImageModel);

Log.v(TAG, "<< hideImageThumbnail");
}
/**
* This method basically changes visibility of concrete item
*/

private void updateModel(Image imageToUpdate) {
Log.v(TAG, "updateModel, imageToUpdate " + imageToUpdate);
for (Image image : mImagesList) {

if(image.equals(imageToUpdate)){
image.setVisibility(imageToUpdate.isVisible());
break;
}
}
int index = mImagesList.indexOf(imageToUpdate);
Log.v(TAG, "updateModel, index " + index);

mAdapter.notifyItemChanged(index);

/**
* For some reason recycler view is not always redrawn when
* adapter is updated.
* onBindViewHolder is called but image doesn't disappear from
* screen. That's why we have to do this invalidation
*/

Rect dirty = new Rect();
View viewAtPosition = mLayoutManager.findViewByPosition(index);
viewAtPosition.getDrawingRect(dirty);
mRecyclerView.invalidate(dirty);
}

You can see that here we use mImageDetailsImageModel that was saved when ImageDetailsActivity was launched.

Creating the actual animation.

Let’s have a closer look to this method : playEnteringAnimation.

public void playEnteringAnimation(
int left,
int top,
int width,
int height) {
Log.v(TAG, ">> playEnteringAnimation");

mToLeft = left;
mToTop = top;
mToWidth = width;
mToHeight = height;

AnimatorSet imageAnimatorSet = createEnteringImageAnimation();

Animator mainContainerFadeAnimator =
createEnteringFadeAnimator();

mEnteringAnimation = new AnimatorSet();
mEnteringAnimation.setDuration(IMAGE_TRANSLATION_DURATION);
mEnteringAnimation.setInterpolator(
new AccelerateInterpolator());
mEnteringAnimation.addListener(new SimpleAnimationListener() {

@Override
public void onAnimationCancel(Animator animation) {
mEnteringAnimation = null;
}

@Override
public void onAnimationEnd(Animator animation) {

if (mEnteringAnimation != null) {
mEnteringAnimation = null;

mImageTo.setVisibility(View.VISIBLE);
mAnimatedImage.setVisibility(View.INVISIBLE);
} else {
// Animation was cancelled. Do nothing
}
}
});

mEnteringAnimation.playTogether(
imageAnimatorSet,
mainContainerFadeAnimator
);

mEnteringAnimation.start();
Log.v(TAG, "<< playEnteringAnimation");
}

This method does few things:

  1. It combines 2 animations: “Image entering animation” and “Main container fade in animation”
  2. If entering animation was not cancelled and animation is finished it sets “animated image” to state “INVISIBLE” and “Enlarged image” to “VISIBLE”.

Method createEnteringFadeAnimator() is very simple. It’s a usual ObjectAnimator for animating background:

private ObjectAnimator createEnteringFadeAnimator() {
return
ObjectAnimator.ofFloat(mMainContainer, "alpha", 0.0f, 1.0f);
}

Animating image position and ImageView Matrix.

The fun comes if we look at createEnteringImageAnimation() from previous paragraph.

private AnimatorSet createEnteringImageAnimation() {
Log.v(TAG, ">> createEnteringImageAnimation");

ObjectAnimator positionAnimator =
createEnteringImagePositionAnimator();

ObjectAnimator matrixAnimator =
createEnteringImageMatrixAnimator();

AnimatorSet enteringImageAnimation = new AnimatorSet();

enteringImageAnimation
.playTogether(positionAnimator, matrixAnimator);

Log.v(TAG, "<< createEnteringImageAnimation");
return enteringImageAnimation;
}

This method combines 2 animations and return AnimatorSet.

Method createEnteringImagePositionAnimator() creates animator that animates image transition on the screen from the place where image on previous screen was to the place where it should be on ImageDetailsActivity screen.

private ObjectAnimator createEnteringImagePositionAnimator() {

Log.v(TAG, "createEnteringImagePositionAnimator");

PropertyValuesHolder propertyLeft = PropertyValuesHolder.ofInt(
"left",
mAnimatedImage.getLeft(),
mToLeft);
PropertyValuesHolder propertyTop = PropertyValuesHolder.ofInt(
"top",
mAnimatedImage.getTop(),
mToTop - getStatusBarHeight());

PropertyValuesHolder propertyRight = PropertyValuesHolder.ofInt(
"right",
mAnimatedImage.getRight(),
mToLeft + mToWidth);
PropertyValuesHolder propertyBottom = PropertyValuesHolder.ofInt(
"bottom",
mAnimatedImage.getBottom(),
mToTop + mToHeight - getStatusBarHeight());

ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(
mAnimatedImage,
propertyLeft,
propertyTop,
propertyRight,
propertyBottom);
animator.addListener(new SimpleAnimationListener() {
@Override
public void onAnimationEnd(Animator animation) {
// set new parameters of animated ImageView. This will
// prevent blinking of view when set visibility to
// visible in Exit animation


FrameLayout.LayoutParams layoutParams =
(FrameLayout.LayoutParams) mAnimatedImage
.getLayoutParams();
layoutParams.height = mImageTo.getHeight();
layoutParams.width = mImageTo.getWidth();

layoutParams.setMargins(
mToLeft,
mToTop - getStatusBarHeight(),
0,
0);
}
});
return animator;
}

So, the method creates an ObjectAnimator that animates View properties:

  1. “left” will animate ImageView position over the X axis.
  2. “top” will animate ImageView position over the Y axis. You may notice that final top position subtracts the height of status bar. That’s because the value of mToTop doesn’t takes into account the height of status bar. It’s a top position on the screen.
  3. “bottom” together with “top” will basically animate ImageView height. Status bar height is also subtracted from mToHeight.
  4. “right” together with “left” causes ImageView width to change.

When animation ends it fixes the position of the image by setting margins to the ImageView layout params.

Animating ImageView Matrix.

To animate ImageView Matrix transform we need to call a method ImageView.animateTransform(). But this method is invisible for developers. So we will create custom property:

/**
* This property is passed to ObjectAnimator when we are animating
* image matrix of ImageView
*/

Property<ImageView, Matrix> ANIMATED_TRANSFORM_PROPERTY
= new Property<ImageView, Matrix>(Matrix.class, "animatedTransform"){
/**
* This is copy-paste form ImageView#animateTransform - method
* is invisible in sdk
*/

@Override
public void set(ImageView imageView, Matrix matrix) {
Drawable drawable = imageView.getDrawable();
if (drawable == null) {
return;
}
if (matrix == null) {
drawable.setBounds(
0,
0,
imageView.getWidth(),
imageView.getHeight());
} else { drawable.setBounds(
0,
0,
drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight());

Matrix drawMatrix = imageView.getImageMatrix();

if (drawMatrix == null) {
drawMatrix = new Matrix();
imageView.setImageMatrix(drawMatrix);
}
imageView.setImageMatrix(matrix);
}
imageView.invalidate();
}
@Override
public Matrix get(ImageView object) {
return null;
}
};

This custom property is basically a copy-paste from ImageView#animateTransform. What it does is that when Animator will try to animate property of animatedTransform the set(ImageView, Matrix) method will be called. And this method sets new Matrix to the ImageView and calls invalidate() so that ImageView will be redrawn.

Also, to “know” how ImageView Matrix should be changed during animation we need to have so called MatrixEvaluator:

/**
* This class is passed to ObjectAnimator in order to animate
* changes in ImageView image matrix
*/

public class MatrixEvaluator implements TypeEvaluator<Matrix> {
float[] mTempStartValues = new float[9];
float[] mTempEndValues = new float[9];
Matrix mTempMatrix = new Matrix(); @Override
public Matrix evaluate(float fraction,
Matrix startValue,
Matrix endValue) {
startValue.getValues(mTempStartValues);
endValue.getValues(mTempEndValues);

for (int i = 0; i < 9; i++) {
float diff = mTempEndValues[i] - mTempStartValues[i];
mTempEndValues[i] =
mTempStartValues[i]
+ (fraction * diff);
}
mTempMatrix.setValues(mTempEndValues);
return mTempMatrix;
}
}

This class (MatrixEvaluator) together with ANIMATED_TRANSFORM_PROPERTY are passed to the ObjectAnimator. To animate ImageView Matrix.

There is this method called createEnteringImageMatrixAnimator(). It is responsible for creating ObjectAnimator that animates ImageView Matrix.

private ObjectAnimator createEnteringImageMatrixAnimator() {

Matrix initMatrix = MatrixUtils.getImageMatrix(mAnimatedImage);
// store the data about original matrix into array.
// this array will be used later for exit animation

initMatrix.getValues(mInitThumbnailMatrixValues);

final Matrix endMatrix = MatrixUtils.getImageMatrix(mImageTo);

mAnimatedImage.setScaleType(ImageView.ScaleType.MATRIX);

return ObjectAnimator.ofObject(
mAnimatedImage,
MatrixEvaluator.ANIMATED_TRANSFORM_PROPERTY,
new MatrixEvaluator(), initMatrix, endMatrix);
}

This method also saves the values of initial Matrix so that these values could be used when creating “Exit animation”

Also there is one very important thing: the animated ImageView Scale type has to be set to ScaleType.Matrix.

Animate Exiting ImageDetailsActivity.

There is nothing special about animating exit from the screen. Basically we run the same animation in the reverse. The only thing that we really need to handle is that animate exiting if entering animation is in the middle. We have to stop entering animation (ImageView view transition should also stop somewhere in the middle of it’s path) and then animate ImageView back to it’s initial position.

We also have to handle pressing “back” button.

@Override
public void onBackPressed() {
//We don't call super to keep this activity on the screen when
//back is pressed

Log.v(TAG, "onBackPressed");

mEnterScreenAnimations.cancelRunningAnimations();

Bundle initialBundle = getIntent().getExtras();
int toTop =
initialBundle.getInt(KEY_THUMBNAIL_INIT_TOP_POSITION);
int toLeft =
initialBundle.getInt(KEY_THUMBNAIL_INIT_LEFT_POSITION);
int toWidth =
initialBundle.getInt(KEY_THUMBNAIL_INIT_WIDTH);
int toHeight =
initialBundle.getInt(KEY_THUMBNAIL_INIT_HEIGHT);

mExitScreenAnimations.playExitAnimations(
toTop,
toLeft,
toWidth,
toHeight,
mEnterScreenAnimations.getInitialThumbnailMatrixValues());
}

Here is the flow:

  1. We cancel “entering” animation if it’s still running. “Entering” animation is designed in the way that it will leave animated ImageView at exact same spot where it was during transition when animation was cancelled.
  2. We retrieve ImageView initial properties from the bundle that was passed from ImagesListActivity. These values now are passed to the ExitScreenAnimations class that will use them as final properties when running “exiting” animation.
  3. It gets initial values of ImageView Matrix that were stored when “entering” animation was created.

There is almost no difference between “entering” and “exiting” animation. Please have a look at the source code on the Github for more details.

That’s it.

Cheers ;)

--

--