24

Background

Facebook app has a nice transition animation between a small image on a post, and an enlarged mode of it that the user can also zoom to it.

As I see it, the animation not only enlarges and moves the imageView according to its previous location and size, but also reveals content instead of stretching the content of the imageView.

This can be seen using the next sketch i've made:

enter image description here

The question

How did they do it? did they really have 2 views animating to reveal the content?

How did they make it so fluid as if it's a single view?

the only tutorial i've seen (link here) of an image that is enlarged to full screen doesn't show well when the thumbnail is set to be center-crop.

Not only that, but it works even on low API of Android.

does anybody know of a library that has a similar ability?


EDIT: I've found a way and posted an answer, but it's based on changing the layoutParams , and i think it's not efficient and recommended.

I've tried using the normal animations and other animation tricks, but for now that's the only thing that worked for me.

If anyone know what to do in order to make it work in a better way, please write it down.

Community
  • 1
  • 1
android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • I made a custom ImageView and ValueAnimator. when value of valueAnimator is updated, then call invalide() of custom imageView. Then, I call clipRect() with calculated Rect. I think it is more efficient than changing layoutParams. – sso.techie Nov 07 '18 at 05:39
  • @sso.techie Interesting. Could you please post an answer and maybe even publish on Github? – android developer Nov 07 '18 at 06:10

7 Answers7

4

Ok, i've found a possible way to do it. i've made the layoutParams as variables that keep changing using the ObjectAnimator of the nineOldAndroids library. i think it's not the best way to achieve it since it causes a lot of onDraw and onLayout, but if the container has only a few views and doesn't change its size, maybe it's ok.

the assumption is that the imageView that i animate will take the exact needed size in the end, and that (currently) both the thumbnail and the animated imageView have the same container (but it should be easy to change it.

as i've tested, it is also possible to add zoom features by extending the TouchImageView class . you just set the scale type in the beginning to center-crop, and when the animation ends you set it back to matrix, and if you want, you can set the layoutParams to fill the entire container (and set the margin to 0,0).

i also wonder how come the AnimatorSet didn't work for me, so i will show here something that works, hoping someone could tell me what i should do.

here's the code:

MainActivity.java

public class MainActivity extends Activity {
    private static final int IMAGE_RES_ID = R.drawable.test_image_res_id;
    private static final int ANIM_DURATION = 5000;
    private final Handler mHandler = new Handler();
    private ImageView mThumbnailImageView;
    private CustomImageView mFullImageView;
    private Point mFitSizeBitmap;

    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mFullImageView = (CustomImageView) findViewById(R.id.fullImageView);
        mThumbnailImageView = (ImageView) findViewById(R.id.thumbnailImageView);
        mHandler.postDelayed(new Runnable() {

            @Override
            public void run() {
                prepareAndStartAnimation();
            }

        }, 2000);
    }

    private void prepareAndStartAnimation() {
        final int thumbX = mThumbnailImageView.getLeft(), thumbY = mThumbnailImageView.getTop();
        final int thumbWidth = mThumbnailImageView.getWidth(), thumbHeight = mThumbnailImageView.getHeight();
        final View container = (View) mFullImageView.getParent();
        final int containerWidth = container.getWidth(), containerHeight = container.getHeight();
        final Options bitmapOptions = getBitmapOptions(getResources(), IMAGE_RES_ID);
        mFitSizeBitmap = getFitSize(bitmapOptions.outWidth, bitmapOptions.outHeight, containerWidth, containerHeight);

        mThumbnailImageView.setVisibility(View.GONE);
        mFullImageView.setVisibility(View.VISIBLE);
        mFullImageView.setContentWidth(thumbWidth);
        mFullImageView.setContentHeight(thumbHeight);
        mFullImageView.setContentX(thumbX);
        mFullImageView.setContentY(thumbY);
        runEnterAnimation(containerWidth, containerHeight);
    }

    private Point getFitSize(final int width, final int height, final int containerWidth, final int containerHeight) {
        int resultHeight, resultWidth;
        resultHeight = height * containerWidth / width;
        if (resultHeight <= containerHeight) {
            resultWidth = containerWidth;
        } else {
            resultWidth = width * containerHeight / height;
            resultHeight = containerHeight;
        }
        return new Point(resultWidth, resultHeight);
    }

    public void runEnterAnimation(final int containerWidth, final int containerHeight) {
        final ObjectAnimator widthAnim = ObjectAnimator.ofInt(mFullImageView, "contentWidth", mFitSizeBitmap.x)
                .setDuration(ANIM_DURATION);
        final ObjectAnimator heightAnim = ObjectAnimator.ofInt(mFullImageView, "contentHeight", mFitSizeBitmap.y)
                .setDuration(ANIM_DURATION);
        final ObjectAnimator xAnim = ObjectAnimator.ofInt(mFullImageView, "contentX",
                (containerWidth - mFitSizeBitmap.x) / 2).setDuration(ANIM_DURATION);
        final ObjectAnimator yAnim = ObjectAnimator.ofInt(mFullImageView, "contentY",
                (containerHeight - mFitSizeBitmap.y) / 2).setDuration(ANIM_DURATION);
        widthAnim.start();
        heightAnim.start();
        xAnim.start();
        yAnim.start();
        // TODO check why using AnimatorSet doesn't work here:
        // final com.nineoldandroids.animation.AnimatorSet set = new AnimatorSet();
        // set.playTogether(widthAnim, heightAnim, xAnim, yAnim);
    }

    public static BitmapFactory.Options getBitmapOptions(final Resources res, final int resId) {
        final BitmapFactory.Options bitmapOptions = new BitmapFactory.Options();
        bitmapOptions.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, bitmapOptions);
        return bitmapOptions;
    }

}

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >

    <com.example.facebookstylepictureanimationtest.CustomImageView
        android:id="@+id/fullImageView"
        android:layout_width="0px"
        android:layout_height="0px"
        android:background="#33ff0000"
        android:scaleType="centerCrop"
        android:src="@drawable/test_image_res_id"
        android:visibility="invisible" />

    <ImageView
        android:id="@+id/thumbnailImageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"
        android:scaleType="centerCrop"
        android:src="@drawable/test_image_res_id" />

</RelativeLayout>

CustomImageView.java

public class CustomImageView extends ImageView {
    public CustomImageView(final Context context) {
        super(context);
    }

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

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

    public void setContentHeight(final int contentHeight) {
        final LayoutParams layoutParams = getLayoutParams();
        layoutParams.height = contentHeight;
        setLayoutParams(layoutParams);
    }

    public void setContentWidth(final int contentWidth) {
        final LayoutParams layoutParams = getLayoutParams();
        layoutParams.width = contentWidth;
        setLayoutParams(layoutParams);
    }

    public int getContentHeight() {
        return getLayoutParams().height;
    }

    public int getContentWidth() {
        return getLayoutParams().width;
    }

    public int getContentX() {
        return ((MarginLayoutParams) getLayoutParams()).leftMargin;
    }

    public void setContentX(final int contentX) {
        final MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams();
        layoutParams.leftMargin = contentX;
        setLayoutParams(layoutParams);
    }

    public int getContentY() {
        return ((MarginLayoutParams) getLayoutParams()).topMargin;
    }

    public void setContentY(final int contentY) {
        final MarginLayoutParams layoutParams = (MarginLayoutParams) getLayoutParams();
        layoutParams.topMargin = contentY;
        setLayoutParams(layoutParams);
    }

}
android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • Very nice! Thanks! And if you're still wondering why the AnimatorSet doesn't work, you still need to call `start()` after calling `playTogether()`. See http://developer.android.com/reference/android/animation/AnimatorSet.html#playTogether(android.animation.Animator...) – nbarraille Aug 16 '14 at 02:31
  • `set.start()` is required. And also if you are applying duration or interpolation to a set then you must call appropriate methods AFTER calling `set.playXX()` method. – M-Wajeeh Dec 04 '14 at 14:22
  • @M-WaJeEh I need to tell it to play before telling it for how long and using which interpolation ? How could it be? Also, BTW, Android Lollipop has a nice API for this operation : https://developer.android.com/training/material/animations.html#Transitions – android developer Dec 04 '14 at 19:51
  • Yes it will be clear if you look at the code and also `set.playXX()` does not actually play as I mentioned above. You have to call `set.start()`. `set.playXX()` is more like play these animations when I call `start()`. – M-Wajeeh Dec 05 '14 at 06:18
2

Another solution, if you just want to make an animation of an image from small to large, you can try ActivityOptions.makeThumbnailScaleUpAnimation or makeScaleUpAnimationand see if they suit you.

http://developer.android.com/reference/android/app/ActivityOptions.html#makeThumbnailScaleUpAnimation(android.view.View, android.graphics.Bitmap, int, int)

Fernando Gallego
  • 4,064
  • 31
  • 50
  • that's a wonderful thing, but it has some disadvantages : only scales up to the entire screen, only available on API 16 and above, only works between activities, no ability to customize the animation in any way. – android developer Jun 24 '14 at 13:26
2

You can achieve this through Transition Api, and this the resut gif:

Demo

essential code below:

private void zoomIn() {
    ViewGroup.LayoutParams layoutParams = mImage.getLayoutParams();
    int width = layoutParams.width;
    int height = layoutParams.height;
    layoutParams.width = (int) (width * 2);
    layoutParams.height = height * 2;
    mImage.setLayoutParams(layoutParams);
    mImage.setScaleType(ImageView.ScaleType.FIT_CENTER);

    TransitionSet transitionSet = new TransitionSet();
    Transition bound = new ChangeBounds();
    transitionSet.addTransition(bound);
    Transition changeImageTransform = new ChangeImageTransform();
    transitionSet.addTransition(changeImageTransform);
    transitionSet.setDuration(1000);
    TransitionManager.beginDelayedTransition(mRootView, transitionSet);
}

View demo on github

sdk version >= 21

weigan
  • 4,625
  • 1
  • 13
  • 14
0

I found a way to get a similar affect in a quick prototype. It might not be suitable for production use (I'm still investigating), but it is quick and easy.

  1. Use a fade transition on your activity/fragment transition (which starts with the ImageView in exactly the same position). The fragment version:

    final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();    
    fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);    
    ...etc    
    

    The activity version:

    Intent intent = new Intent(context, MyDetailActivity.class);
    startActivity(intent);
    getActivity().overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
    

    This gives a smooth transition without a flicker.

  2. Adjust the layouts dynamically in the onStart() of the new fragment (you need to save member fields to the appropriate parts of your UI in onCreateView, and add some flags to ensure this code only gets called once).

    @Override    
    public void onStart() {    
        super.onStart();
    
        // Remove the padding on any layouts that the image view is inside
        mMainLayout.setPadding(0, 0, 0, 0);
    
        // Get the screen size using a utility method, e.g. 
        //  http://stackoverflow.com/a/12082061/112705
        // then work out your desired height, e.g. using the image aspect ratio.
        int desiredHeight = (int) (screenWidth * imgAspectRatio);
    
        // Resize the image to fill the whole screen width, removing 
        // any layout margins that it might have (you may need to remove 
        // padding too)
        LinearLayout.LayoutParams layoutParams =
                new LinearLayout.LayoutParams(screenWidth, desiredHeight);
        layoutParams.setMargins(0, 0, 0, 0);
    
        mImageView.setLayoutParams(layoutParams);
    }    
    
Dan J
  • 25,433
  • 17
  • 100
  • 173
  • Sure that what you did here looks like what i've written about? i didn't test what you've written, but by reading the code, it doesn't seem to match it... plus i'm not sure what are the variables you've used (like mImageFrame). – android developer Jan 17 '14 at 23:03
  • Yes, this achieves a very similar affect, without needing to use animators. It just uses a FragmentTransition and then a programmatic layout resize. The code above is simplified, e.g. you would need to avoid the code being called more than once, and obviously you would need to setup member fields to the UI views. – Dan J Jan 18 '14 at 01:32
  • But what FB has isn't fading. it's movement+enlargement from center-crop (of the imageView on the gridView) to fitting the entire screen. the answer i've written demostrates it . i could add fading but that's not the question. fading is the easier part. – android developer Jan 18 '14 at 07:59
  • I agree my code won't look as nice as a proper animation. However, I wanted to separate my detail view into a different activity/fragment (as that page has a different navigation pattern and shows other UI features), so I wanted a similar affect across the activity/fragment transition. I thought I would post the code in case anybody finds it useful. – Dan J Jan 20 '14 at 19:19
  • but that's not what i asked about, plus you ddin't explain how your code allows the animation to work as i've written (and not just fading). – android developer Jan 20 '14 at 20:27
0

I think the easiest way is to animate the height of the ImageView (a regular imageview, not necessary a custom view) while keeping the scaleType to centerCrop until full height, which you can know in advance if you set the image height to wrap_content in your layout and then use a ViewTreeObserver to know when the layout has ended, so you can get the ImageView height and then set the new "collapsed" height. I have not tested it but this is how I would do it.

You can also have a look at this post, they do something similar http://nerds.airbnb.com/host-experience-android/

Fernando Gallego
  • 4,064
  • 31
  • 50
  • This sounds like the exact same solution I've proposed (here: http://stackoverflow.com/a/19616416/878126 ) ... Isn't there a better way, that won't cause multiple layout-operations on the surrounding ? Maybe override onDraw and use the canves ? – android developer Jun 23 '14 at 07:41
0

I'm not sure why everyone is talking about the framework. Using other peoples code can be great at times; but it sounds like what you are after is precise control over the look. By getting access to the graphics context you can have that. The task is pretty simple in any environment that has a graphics context. In android you can get it by overriding the onDraw method and using the Canvas Object. It has everything you need to draw an image at many different scales, positions and clippings. You can even use a matrix if your familiar with that type of thing.

Steps

  1. Make sure you have exact control of positioning, scale, and clip. This means disabling any layouts or auto-alignment that might be setup inside your objects container.

  2. Figure you out what your parameter t will be for linear interpolation and how you will want it to relate to time. How fast or slow, and will there be any easing. t should be dependent on time.

  3. After the thumbnails are cached, load the full scale image in the background. But don't show it yet.

  4. When the animation trigger fires, show the large image and drive your animation with your t parameter using interpolation between the initial properties' states to the final properties' states. Do this for all three properties, position, scale and clip. So for all properties do the following:

    Sinterpolated = Sinitial * (t-1)    +   Sfinal * t;
    // where
    //    t is between 0.0 and 1.0
    //    and S is the states value
    //    for every part of scale, position, and clip
    //
    //    Sinitial is what you are going from
    //    Sfinal   is what you are going to
    //    
    //    t should change from 0.0->1.0 in
    //    over time anywhere from 12/sec or 60/sec.
    

If all your properties are driven by the same parameter the animation will be smooth. As an added bonus, here is a tip for timing. As long as you can keep your t parameter between 0 and 1, easing in or out can be hacked with one line of code:

// After your t is all setup
t = t * t;        // for easing in
// or
t = Math.sqrt(t); // for easing out
0

I made a sample code in Github.

The key of this code is using canvas.clipRect(). But, it only works when the CroppedImageview is match_parent.

To explain simply, I leave scale and translation animation to ViewPropertyAnimator. Then, I can focus on cropping the image.

clipping descriptions

Like above picture, calculate the clipping region, and change the clip region to final view size.

AnimationController

class ZoomAnimationController(private val view: CroppedImageView, startRect: Rect, private val viewRect: Rect, imageSize: Size) {
companion object {
    const val DURATION = 300L
}

private val startViewRect: RectF
private val scale: Float

private val startClipRect: RectF
private val animatingRect: Rect
private var cropAnimation: ValueAnimator? = null

init {
    val startImageRect = getProportionalRect(startRect, imageSize, ImageView.ScaleType.CENTER_CROP)
    startViewRect = getProportionalRect(startImageRect, viewRect.getSize(), ImageView.ScaleType.CENTER_CROP)
    scale = startViewRect.width() / viewRect.width()

    val finalImageRect = getProportionalRect(viewRect, imageSize, ImageView.ScaleType.FIT_CENTER)
    startClipRect = getProportionalRect(finalImageRect, startRect.getSize() / scale, ImageView.ScaleType.FIT_CENTER)
    animatingRect = Rect()
    startClipRect.round(animatingRect)
}

fun init() {
    view.x = startViewRect.left
    view.y = startViewRect.top
    view.pivotX = 0f
    view.pivotY = 0f
    view.scaleX = scale
    view.scaleY = scale

    view.setClipRegion(animatingRect)
}

fun startAnimation() {
    cropAnimation = createCropAnimator().apply {
        start()
    }
    view.animate()
            .x(0f)
            .y(0f)
            .scaleX(1f)
            .scaleY(1f)
            .setDuration(DURATION)
            .start()
}

private fun createCropAnimator(): ValueAnimator {
    return ValueAnimator.ofFloat(0f, 1f).apply {
        duration = DURATION
        addUpdateListener {
            val weight = animatedValue as Float
            animatingRect.set(
                    (startClipRect.left * (1 - weight) + viewRect.left * weight).toInt(),
                    (startClipRect.top * (1 - weight) + viewRect.top * weight).toInt(),
                    (startClipRect.right * (1 - weight) + viewRect.right * weight).toInt(),
                    (startClipRect.bottom * (1 - weight) + viewRect.bottom * weight).toInt()
            )
            Log.d("SSO", "animatingRect=$animatingRect")
            view.setClipRegion(animatingRect)
        }
    }
}

private fun getProportionalRect(viewRect: Rect, imageSize: Size, scaleType: ImageView.ScaleType): RectF {
    return getProportionalRect(RectF(viewRect), imageSize, scaleType)
}

private fun getProportionalRect(viewRect: RectF, imageSize: Size, scaleType: ImageView.ScaleType): RectF {
    val viewRatio = viewRect.height() / viewRect.width()
        if ((scaleType == ImageView.ScaleType.FIT_CENTER && viewRatio > imageSize.ratio)
        || (scaleType == ImageView.ScaleType.CENTER_CROP && viewRatio <= imageSize.ratio)) {
        val width = viewRect.width()
        val height = width * imageSize.ratio
        val paddingY = (height - viewRect.height()) / 2f
        return RectF(viewRect.left, viewRect.top - paddingY, viewRect.right, viewRect.bottom + paddingY)
    } else if ((scaleType == ImageView.ScaleType.FIT_CENTER && viewRatio <= imageSize.ratio)
            || (scaleType == ImageView.ScaleType.CENTER_CROP && viewRatio > imageSize.ratio)){
        val height = viewRect.height()
        val width = height / imageSize.ratio
        val paddingX = (width - viewRect.width()) / 2f
        return RectF(viewRect.left - paddingX, viewRect.top, viewRect.right + paddingX, viewRect.bottom)
    }

    return RectF()
}

CroppedImageView

override fun onDraw(canvas: Canvas?) {
    if (clipRect.width() > 0 && clipRect.height() > 0) {
        canvas?.clipRect(clipRect)
    }
    super.onDraw(canvas)
}

fun setClipRegion(rect: Rect) {
    clipRect.set(rect)
    invalidate()
}

it only works when the CroppedImageview is match_parent, because

  1. The paths from start to end is included in CroppedImageView. If not, animation is not shown. So, Making it's size match_parent is easy to think.
  2. I didn't implement the code for special case...
sso.techie
  • 136
  • 7
  • This looks to work very well. Can you please show the code here too? And why does it work only when "CroppedImageview is match_parent" ? – android developer Nov 07 '18 at 18:38