I saw an interesting take on the RevealTransition from Johannes Homeier. The circular reveal was to shrink and cross-fade to a circle, then move, then grow to the final position. At first I thought that this would be a fairly easy thing to do and the more I thought about it, the more interesting it was, so I thought I’d give it a go.
Fortunately for me, Johannes provided me a nice mockup so I could picture it:
How would you make this transition work? I’m sure there are several ways to do this, but I decided to make a complete transition in the called Activity that replaces ChangeBounds. To accommodate the cross-faded Views, I needed to create Views and add them to the overlay and do all of the animation in the overlay.
I started with the project I used for the Reveal Activity Transition. First, let’s look at the view hierarchy for the called Activity:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin"> <ImageView android:transitionName="hello" android:id="@+id/planter" android:layout_width="match_parent" android:layout_height="wrap_content" android:adjustViewBounds="true" android:layout_alignParentTop="true" android:src="@drawable/planter" /> </RelativeLayout>
This is much simpler than we had in the Reveal Activity Transitions project. Because I’m replacing the entire shared element transition, including the ChangeBounds transition, I can just do a normal shared element transition. No funny stuff required.
Now, my shared element transition is very simple:
<transition xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" class="com.sample.revealactivitytransition.CircleTransition" app:color="@android:color/holo_green_dark"/>
Here, I’m using a custom Transition with a custom tag “color” in the transition XML. Very nice.
Now, what do we do in the CircleTransition?
- I need a bitmap image of the start state so that I can do a cross-fade.
- Reveal in reverse the shared element start state and a uniform color.
- Cross-fade in the color over the start state image.
- Move a circle View from the start position to the final position.
- Reveal the end state view and the solid color
- Cross-fade the solid color
That’s not so bad. First, I need to get a bitmap of the starting state. I could share this with my transition in a more efficient way, but I want my Transition to be generic and reusable in other situations. I just had it take a snapshot of the View when capturing the start state:
@Override public void captureStartValues(TransitionValues transitionValues) { final View view = transitionValues.view; if (view.getWidth() <= 0 || view.getHeight() <= 0) { return; } captureValues(transitionValues); Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); view.draw(canvas); transitionValues.values.put(PROPERTY_IMAGE, bitmap); }
I don’t need the bitmap of the end state because the View will exist in the end state. I can already cross-fade to that View.
When creating the Animator, I need to add the bitmap into the overlay as well as the solid-color Views that are cross-faded in and out. Then, it is just a matter of using the ObjectAnimator and the circular reveal Animator. There is one trick: the circular reveal Animator runs on the render thread and after the animator runs, the view is completely revealed. If I tie the removal of view to the onAnimatorEnd, then there may be a frame in which the animator completes and the view is revealed, showing a blink. I needed to hide the view one frame early to make sure that View doesn’t blink exposed.
That crossed-out section is wrong! I learned from John Reck that the RevealAnimator guarantees that the onAnimatorEnd will be received prior to the next UI draw call. If we set view Visibility, it will register in the correct frame.
shrinkingAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { shrinkingView.setVisibility(View.INVISIBLE); startView.setVisibility(View.INVISIBLE); circleView.setVisibility(View.VISIBLE); } });
Because we’re doing reveal and fade simultaneously on the solid Views, we really should make the system work a little more efficiently. Typically, we would want any fading View to be on its own hardware layer. However, we really shouldn’t do that here — the reveal would cause the layer to be redrawn on every frame (bad!). Having hasOverlappingRendering() return false will make it more efficient. ImageViews do that normally, but I didn’t think of that before making the project.
There’s still some work to do in the Activity. We need to have the start state show the Hello World button instead of a shrunken planter image, so just like in the previous project, we need to move the snapshot into the layout. I don’t want to use the snapshot View because I want the shared element to do the entire Transition.
@Override public void onSharedElementStart(List sharedElementNames, List sharedElements, List sharedElementSnapshots) { ImageView sharedElement = (ImageView) findViewById(R.id.planter); for (int i = 0; i < sharedElements.size(); i++) { if (sharedElements.get(i) == sharedElement) { View snapshot = sharedElementSnapshots.get(i); Drawable snapshotDrawable = snapshot.getBackground(); sharedElement.setBackground(snapshotDrawable); sharedElement.setImageAlpha(0); forceSharedElementLayout(); break; } } } @Override public void onSharedElementEnd(List sharedElementNames, List sharedElements, List sharedElementSnapshots) { ImageView sharedElement = (ImageView) findViewById(R.id.planter); sharedElement.setBackground(null); sharedElement.setImageAlpha(255); }
I’m just setting the background of the ImageView to the snapshot and hiding the planter picture with setImageAlpha(0). The captureStartValues will capture a bitmap of the View with the shared element snapshot. Now, we don’t have any guarantees about the snapshot View — it could be any class and the image may not be in the background. Therefore, I need to create the snapshot View myself:
@Override public View onCreateSnapshotView(Context context, Parcelable snapshot) { View view = new View(context); view.setBackground(new BitmapDrawable((Bitmap) snapshot)); return view; }
and in the calling Activity, I have to provide the right Parcelable:
@Override public Parcelable onCaptureSharedElementSnapshot(View sharedElement, Matrix viewToGlobalMatrix, RectF screenBounds) { int bitmapWidth = Math.round(screenBounds.width()); int bitmapHeight = Math.round(screenBounds.height()); Bitmap bitmap = null; if (bitmapWidth > 0 && bitmapHeight > 0) { Matrix matrix = new Matrix(); matrix.set(viewToGlobalMatrix); matrix.postTranslate(-screenBounds.left, -screenBounds.top); bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); canvas.concat(matrix); sharedElement.draw(canvas); } return bitmap; }
This is stolen right from the support library code (and simplified a bit). Take a look at the results:
This idea looks very similar to the shared element transition in the Google Play Games app as well (the transition is shown in the first video on this page: http://www.androiddesignpatterns.com/2014/12/activity-fragment-content-transitions-in-depth-part2.html)!
It is similar! It appears that they aren’t using a circular reveal there — I see a shrinking rounded rect fading in. It happens very fast in the video, so it is hard to see exactly what is going on.
In your post you use the following code:
circleAnimator.setStartDelay(duration - ValueAnimator.getFrameDelay());
You also say “If I tie the removal of view to the
onAnimatorEnd()
, then there may be a frame in which the animator completes and the view is revealed, showing a blink.”The part that worries me is the “may”… is this unpredictable and if so, why? I ask because delaying an animation by the device’s frame delay is something I’ve never seen done in application-level code before and just seemed like something that should already be handled by the framework.
You can set a View to INVISIBLE in the onAnimatorEnd() and it is guaranteed to take effect before the next frame. I’m not sure removal is likewise guaranteed, but I’m sure there is a way to work around that.
I’ve fixed the code and edited the post.
“Because we’re doing reveal and fade simultaneously on the solid Views, we really should make the system work a little more efficiently. Typically, we would want any fading View to be on its own hardware layer. However, we really shouldn’t do that here — the reveal would cause the layer to be redrawn on every frame (bad!). Having
hasOverlappingRendering()
return false will make it more efficient.ImageView
s do that normally, but I didn’t think of that before making the project.”I assume you are referring to the
NoOverlapView
that you’ve introduced in your sample project. Are you saying that this simple wrapper is not actually necessary (since theImageView#hasOverlappingRendering()
will return false anyway?yes, precisely
One last question… the sample project is written around Activity Transitions, but is writing something similar for Fragment Transitions possible as well? I ask because according to the
SharedElementCallback
documentation, the create/capture snapshot methods apparently won’t be called when fragment transitions take place.With Fragment Transitions, you are guaranteed to know more about the Views than if you are in different applications. You can capture the image of the shared element during the onSharedElementStart and pass it directly to the onSharedElementEnd using a member field. Or, since you know the View, you could duplicate it exactly in your launched Fragment. You don’t need the complexity that Activity Transitions needs.
In your post you mention “This is stolen right from the support library code (and simplified a bit).”
I’m just wondering… where in the support library did you steal the code from? Do you have a link? Thanks. 🙂
https://android.googlesource.com/platform/frameworks/support/+/refs/heads/master/v4/java/android/support/v4/app/SharedElementCallback.java
Hi George, I’ve been reading material online (including your blog posts) to implement scene transitions for a project I am working on. While experimenting, I encountered a heavy leak of sharedElementSnapshots that causes apps to crash very quickly. While trying to figure out a workaround and browsing the scene transition source code I later noticed that you implemented some of these APIs. I also found that this issue has been reported at different stages a few times but have not seen any workaround.
https://code.google.com/p/android-developer-preview/issues/detail?id=1543
https://code.google.com/p/android/issues/detail?id=190196
https://code.google.com/p/android/issues/detail?id=170469
Curious as to how apps have been using these APIs since the simplest xusage causes amounts of MBs to be leaked due to the bitmap snapshots. I created a sample project to demo the leak and attached it here, based on the RevealActivityTransitions project you posted:
https://code.google.com/p/android/issues/detail?id=190196
I was wondering if you could provide some input as to whether there is any way this leak can be avoided, without disabling shared element transitions themselves or nulling snapshots. Currently I am trying to reimplement onCaptureSharedElementSnapshot and onCreateSnapshotView to be able to null the bitmaps after using them.
Yes, it is unfortunate that this wasn’t caught and fixed sooner. The only way I can think to fix this locally is to override the onCaptureSharedElementSnapshot and either return null (I think this works) or place the parcel from super into a Bundle that you can later clear. Most don’t use the snapshot.
If you need the snapshot, you’ll have to also override the onCreateSnapshotView to unpackage the parcel from the bundle before calling super (or handle the snapshot yourself in any way you want).
Sadly, this will just slow the leak down, but at least it won’t be kilobytes on each call.
Sadly, I don’t know a way to do it without nulling the snapshot before or after use.