Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.

like image 524
Thierry-Dimitri Roy Avatar asked Aug 19 '13 01:08

Thierry-Dimitri Roy


1 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

like image 101
MH. Avatar answered Oct 01 '22 19:10

MH.