How to animate a matrix to "crop-out" an image?
Asked Answered
E

4

8

I'm trying to do an image viewer that when the user clicks on the image, the image is "cropped-out" and reveal the complete image.

For example in the screenshot below the user only see the part of the puppy initially. But after the user has clicked the image, the whole puppy is revealed. The image faded behind the first shows what the results of the animation would be.

screenshot

Initially the ImageView is scaled to 50% in X and Y. And when the user clicks on the image the ImageView is scaled back to 100%, and the ImageView matrix is recalculated.

I tried all sort of way to calculate the matrix. But I cannot seem to find one that works with all type of crop and image: cropped landscape to portrait, cropped landscape to landscape, cropped portrait to portrait and cropped portrait to landscape. Is this even possible?

Here's my code right now. I'm trying to find what to put in setImageCrop().

public class MainActivity extends Activity {

private ImageView img;
private float translateCropX;
private float translateCropY;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    img = (ImageView) findViewById(R.id.img);
    Drawable drawable = img.getDrawable();

    translateCropX = -drawable.getIntrinsicWidth() / 2F;
    translateCropY = -drawable.getIntrinsicHeight() / 2F;

    img.setScaleX(0.5F);
    img.setScaleY(0.5F);
    img.setScaleType(ScaleType.MATRIX);

    Matrix matrix = new Matrix();
    matrix.postScale(2F, 2F); //zoom in 2X
    matrix.postTranslate(translateCropX, translateCropY); //translate to the center of the image
    img.setImageMatrix(matrix);

    img.setOnClickListener(new OnClickListener() {

        @Override
        public void onClick(View v) {
            final PropertyValuesHolder animScaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1F);
            final PropertyValuesHolder animScaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1F);

            final ObjectAnimator objectAnim = ObjectAnimator.ofPropertyValuesHolder(img, animScaleX, animScaleY);

            final PropertyValuesHolder animMatrixCrop = PropertyValuesHolder.ofFloat("imageCrop", 0F, 1F);

            final ObjectAnimator cropAnim = ObjectAnimator.ofPropertyValuesHolder(MainActivity.this, animMatrixCrop);

            final AnimatorSet animatorSet = new AnimatorSet();
            animatorSet.play(objectAnim).with(cropAnim);

            animatorSet.start();

        }
    });
}

public void setImageCrop(float value) {
    // No idea how to calculate the matrix depending on the scale

    Matrix matrix = new Matrix();
    matrix.postScale(2F, 2F);
    matrix.postTranslate(translateCropX, translateCropY);
    img.setImageMatrix(matrix);
}

}

Edit: It's worth mentioning that scaling the matrix linearly will not do. The ImageView is scaled linearly (0.5 to 1). But if I scale the Matrix linearly during the animation, the view is squish during the animation. The end result looks fine, but the image looks ugly during the animation.

Egmont answered 19/8, 2013 at 1:13 Comment(2)
So does the image get larger (like the picture), or does the image just zoom out to show the whole picture, while keeping the same bounds?Ruthenious
@phil The ImageView scales from 0.5 to 1. It keeps the same layout bound.Egmont
A
7

I realise you (and the other answers) have been trying to solve this problem using matrix manipulation, but I'd like to propose a different approach with the same visual effect as outlined in your question.

In stead of using a matrix to manipulate to visible area of the image(view), why not define this visible area in terms of clipping? This is a rather straightforward problem to solve: all we need to do is define the visible rectangle and simply disregard any content that falls outside of its bounds. If we then animate these bounds, the visual effect is as if the crop bounds scale up and down.

Luckily, a Canvas supports clipping through a variety of clip*() methods to help us out here. Animating the clipping bounds is easy and can be done in a similar fashion as in your own code snippet.

If you throw everything together in a simple extension of a regular ImageView (for the sake of encapsulation), you would get something that looks this:

public class ClippingImageView extends ImageView {

    private final Rect mClipRect = new Rect();

    public ClippingImageView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initClip();
    }

    public ClippingImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initClip();
    }

    public ClippingImageView(Context context) {
        super(context);
        initClip();
    }

    private void initClip() {
        // post to message queue, so it gets run after measuring & layout
        // sets initial crop area to half of the view's width & height
        post(new Runnable() {
            @Override public void run() {
                setImageCrop(0.5f);
            }
        });
    }

    @Override protected void onDraw(Canvas canvas) {
        // clip if needed and let super take care of the drawing
        if (clip()) canvas.clipRect(mClipRect);
        super.onDraw(canvas);
    }

    private boolean clip() {
        // true if clip bounds have been set aren't equal to the view's bounds
        return !mClipRect.isEmpty() && !clipEqualsBounds();
    }

    private boolean clipEqualsBounds() {
        final int width = getWidth();
        final int height = getHeight();
        // whether the clip bounds are identical to this view's bounds (which effectively means no clip)
        return mClipRect.width() == width && mClipRect.height() == height;
    }

    public void toggle() {
        // toggle between [0...0.5] and [0.5...0]
        final float[] values = clipEqualsBounds() ? new float[] { 0f, 0.5f } : new float[] { 0.5f, 0f };
        ObjectAnimator.ofFloat(this, "imageCrop", values).start();
    }

    public void setImageCrop(float value) {
        // nothing to do if there's no drawable set
        final Drawable drawable = getDrawable();
        if (drawable == null) return;

        // nothing to do if no dimensions are known yet
        final int width = getWidth();
        final int height = getHeight();
        if (width <= 0 || height <= 0) return;

        // construct the clip bounds based on the supplied 'value' (which is assumed to be within the range [0...1])
        final int clipWidth = (int) (value * width);
        final int clipHeight = (int) (value * height);
        final int left = clipWidth / 2;
        final int top = clipHeight / 2;
        final int right = width - left;
        final int bottom = height - top;

        // set clipping bounds
        mClipRect.set(left, top, right, bottom);
        // schedule a draw pass for the new clipping bounds to take effect visually
        invalidate();
    }

}

The real 'magic' is the added line to the overridden onDraw() method, where the given Canvas is clipped to a rectangular area defined by mClipRect. All the other code and methods are mainly there to help out with calculating the clip bounds, determining whether clipping is sensible, and the animation.

Using it from an Activity is now reduced to the following:

public class MainActivity extends Activity {

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.image_activity);

        final ClippingImageView img = (ClippingImageView) findViewById(R.id.img);
        img.setOnClickListener(new OnClickListener() {
            @Override public void onClick(View v) {
                img.toggle();
            }
        });
    }
}

Where the layout file will point to our custom ClippingImageView like so:

<mh.so.img.ClippingImageView
        android:id="@+id/img"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/dog" />

To give you an idea of the visual transition:

enter image description here

Avow answered 28/8, 2013 at 23:50 Comment(0)
S
3

This might help... Android image view matrix scale + translate

There seems to be two operations needed:

  1. Scale the container from 0.5 to 1.0
  2. Zoom the inner image from 2.0 to 1.0

I assume this is as simple as

float zoom = 1.0F + value;
float scale = 1.0F / zoom;
img.setScaleX(scale);
img.setScaleY(scale);
...
matrix.postScale(zoom, zoom);

To scale/zoom from the center of the image you might need to do something like this (perhaps swapping the "-" order)...

matrix.postTranslate(translateCropX, translateCropY);
matrix.postScale(zoom, zoom);
matrix.postTranslate(-translateCropX, -translateCropY);

I'm not certain if this will animate (Why does calling setScaleX during pinch zoom gesture cause flicker?). Maybe this will help... http://developer.android.com/training/animation/zoom.html

The result is the inner image remains the same resolution, always.

However you also mention portrait/landscape differences, so this gets a bit more complicated. I find the most difficult thing in cases such as this is to solidly define what you want to have happen in all cases.

I'm guessing you're after a grid of small thumbnails, all of the same size (which mean they all have the same aspect ratio). You want to display images within the thumbnail containers of different aspect ratios and size, but zoomed in. When you select an image, the borders expand (overlapping other images?). There's the constraint that the shown image must keep the same resolution. You also mention that the complete image must be visible after selecting it, and twice the thumbnail size. This last point raises another question - twice the thumbnails height, width or both (in which case do you crop or scale if the image aspect ratio doesn't match). Another issue that might crop up is the case when an image has insufficient resolution.

Hopefully android can do a lot of this for you... How to scale an Image in ImageView to keep the aspect ratio

Somali answered 22/8, 2013 at 17:14 Comment(4)
I have edited my question. Your reasoning is good, but will result in a "squished" image during the animation. But thanks for pointing out the others StackOverflow answers. I will look into them. :)Egmont
it seems strange that a uniform scale would squash the image. this to me points to android not expecting a frequently updating matrix (or doesn't expect it in addition to updating the img scale). What happens if you just animate the matrix or the container individually? Perhaps a more direct approach is to manually draw to a canvas (this would certainly give you more explicit control over what's happening and possible better performance)?Somali
The Android animation works perfectly. This is really a mathematic question. I'm scaling the imageview 1:1 (the ImageView is scaled 0.5 to 1 on the X and Y axis), but the image matrix has a different ratio (3:1 for a landscape image for example). And I can't find the matrix formula that would work for every case.Egmont
does something along the lines of this work... float aspectRatio = translateCropX/translateCropY; matrix.postScale(aspectRatio * zoom, zoom); Saying the image is squished during the animation, but not before and after suggests this won't work. Perhaps instead, something like this is needed... float zoomX = 1.0F + value * aspectRatio; (just a guess)Somali
R
2

You can have a child View of a ViewGroup that exceeds the bounds of the parent. So, for example, let's say that the clear part of your image above is the parent view, and the ImageView is it's child view, then we just need to scale up or down the image. To do this, start with a FrameLayout with specific bounds. For example in XML:

<FrameLayout
    android:id="@+id/image_parent"
    android:layout_width="200dip"
    android:layout_height="200dip" />

or programmatically:

FrameLayout parent = new FrameLayout(this);
DisplayMetrics dm = getResources().getDisplayMetrics();
//specify 1/5 screen height and 1/5 screen width
ViewGroup.LayoutParams params = new FrameLayout.LayoutParams(dm.widthPixles/5, dm.heightPixles/5);
parent.setLayoutParams(params);

//get a reference to your layout, then add this view:
myViewGroup.addView(parent);

Next, add the ImageView child. You can either declare it in XML:

<FrameLayout
    android:id="@+id/image_parent"
    android:layout_width="200dip"
    android:layout_height="200dip" >
    <ImageView 
        android:id="@+id/image"
        android:layout_width="200dip"
        android:layout_height="200dip"
        android:scaleType="fitXY" />
</FrameLayout>

and retrieve it using

ImageView image = (ImageView) findViewById(R.id.image);

Or, you can create it programmatically:

ImageView image = new ImageView(this);
image.setScaleType(ScaleType.FIT_XY);
parent.addView(image);

Either way, you will need to manipulate it. To set the ImageView bounds to exceed its parent, you can do this:

//set the bounds to twice the size of the parent
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(parent.getWidth()*2, parent.getHeight()*2);
image.setLayoutParams(params);

//Then set the origin (top left corner). For API 11+, you can use setX() or setY().
//To support older APIs, its simple to use NineOldAndroids (http://nineoldandroids.com)
//This is how to use NineOldAndroids:
final float x = (float) (parent.getWidth()-params.width)/2;
final float y = (float) (parent.getHeight()-params.height)/2;
ViewHelper.setX(image, x);
ViewHelper.setY(image, y);

Finally, you can use my droidQuery library to easily handle your View animations (and NineOldAndroids is included in droidQuery, so you can just add droidquery.jar to your libs directory and be able to do everything discussed so far. Using droidQuery (import self.philbrown.droidQuery.*. Auto-imports may screw up), you can handle the clicks, and animations, as follows:

$.with(image).click(new Function() {
    @Override
    public void invoke($ droidQuery, Object... params) {

        //scale down
        if (image.getWidth() != parent.getWidth()) {
            droidQuery.animate("{ width: " + parent.getWidth() + ", " +
                                 "height: " + parent.getHeight() + ", " +
                                 "x: " + Float.valueOf(ViewHelper.getX(parent)) + ", " +//needs to include a decimal in the string so droidQuery knows its a float.
                                 "y: " + Float.valueOf(ViewHelper.getY(parent)) + //needs to include a decimal in the string so droidQuery knows its a float.
                               "}",
                               400,//or some other millisecond value for duration
                               $.Easing.LINEAR,//specify the interpolator
                               $.noop());//don't do anything when complete.
        }
        //scale up
        else {
            droidQuery.animate("{ width: " + parent.getWidth()*2 + ", " +
                                 "height: " + parent.getHeight()*2 + ", " +
                                 "x: " + x + ", " +//needs to include a decimal in the string so droidQuery knows its a float.
                                 "y: " + y + //needs to include a decimal in the string so droidQuery knows its a float.
                               "}",
                               400,//or some other millisecond value for duration
                               $.Easing.LINEAR,//specify the interpolator
                               $.noop());//don't do anything when complete.
        }


    }
});
Ruthenious answered 23/8, 2013 at 13:40 Comment(4)
I don't want to include a FrameLayout around each ImageView because it would affect performance. But your answer does display the animation properly.Egmont
@Thierry-DimitriRoy you can add the Application attribute android:hardwareAccelerated="true" in your AndroidManifest.xml, which should take care of your performance issue.Ruthenious
@Thierry-DimitriRoy - Also, I would assume that other solutions that don't use a parent view will actually be more detrimental to performance, since they would include raw bitmap manipulations or advanced graphics.Ruthenious
using a parent for animation would reduce performance as it require a layout pass. By scaling the bitmap, the manipulation is done on the GPU during the animation.Egmont
S
2

Try with this

public void setImageCrop(float value) {
    Matrix matrix = new Matrix();
    matrix.postScale(Math.max(1, 2 - (1 * value)), Math.max(1, 2 - (1 * value)));
    matrix.postTranslate(Math.min(0, translateCropX - (translateCropX * value)), Math.min(0, translateCropY - (translateCropY * value)));
    img.setImageMatrix(matrix);
}
Sullage answered 28/8, 2013 at 13:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.