Home » Android » android – How to animate a matrix to "crop-out" an image?

android – How to animate a matrix to "crop-out" an image?

Posted by: admin June 15, 2020 Leave a comment

Questions:

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.

How to&Answers:

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

Answer:

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

Answer:

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 the 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.
        }


    }
});

Answer:

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);
}