Android ImageView morph: from Square to Circle (Solution updated)

I solved this by completely replacing this CircularRevealView to a custom mask using GradientDrawable with my custom CardView.

my xml (tmp_activity.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"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="@color/background_button"
    tools:context=".TempActivity_">

    <com.myapp.customviews.AnimatableCardView
        android:id="@+id/base_view"
        android:layout_marginLeft="@dimen/margin_medium"
        android:layout_centerVertical="true"
        android:layout_width="@dimen/album_art_small"
        android:layout_height="@dimen/album_art_small"
        app:cardElevation="0dp">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_centerVertical="true"
                android:layout_centerHorizontal="true"
                android:src="@drawable/charlie"
                android:id="@+id/imageView2"/>

    </com.myapp.customviews.AnimatableCardView>
</RelativeLayout>

my activity (Note that I use Android Annotations, not findViewById(..))

@EActivity(R.layout.tmp_activity)
public class TempActivity extends BaseActivity {
    @ViewById(R.id.base_view)
    ViewGroup mParent;

    @ViewById(R.id.imageView2)
    ImageView mImageView;

    GradientDrawable gradientDrawable;

    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

    private volatile boolean isCircle = false;
    @Override
    protected void onViewsCreated() {
        super.onViewsCreated();

        gradientDrawable = new GradientDrawable();
        gradientDrawable.setCornerRadius(30.0f);
        gradientDrawable.setShape(GradientDrawable.RECTANGLE);
        mParent.setBackground(gradientDrawable);

        mImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(final View v) {
                if (isCircle) {
                    makeSquare();
                }
                else {
                    makeCircle();
                }
                isCircle = !isCircle;
            }
        });
    }

    private void makeCircle() {
        ObjectAnimator cornerAnimation =
                ObjectAnimator.ofFloat(gradientDrawable, "cornerRadius", 30f, 200.0f);

        Animator shiftAnimation = AnimatorInflater.loadAnimator(this, R.animator.slide_right_down);
        shiftAnimation.setTarget(mParent);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(500);
        animatorSet.playTogether(cornerAnimation, shiftAnimation);
        animatorSet.start();
    }

    private void makeSquare() {
        ObjectAnimator cornerAnimation =
                ObjectAnimator.ofFloat(gradientDrawable, "cornerRadius", 200.0f, 30f);

        Animator shiftAnimation = AnimatorInflater.loadAnimator(this, R.animator.slide_left_up);
        shiftAnimation.setTarget(mParent);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(500);
        animatorSet.playTogether(cornerAnimation, shiftAnimation);
        animatorSet.start();
    }
}

My custom CardView (AnimatableCardView)

public class AnimatableCardView extends CardView {
    private float xFraction = 0;
    private float yFraction = 0;

    private ViewTreeObserver.OnPreDrawListener preDrawListener = null;

    public AnimatableCardView(Context context) {
        super(context);
    }

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

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

    // Note that fraction "0.0" is the starting point of the view. This includes margins.
    // If this view was placed in (200,300), moveTo="0.1" for xFraction will give you (220,300)
    public void setXFraction(float fraction) {
        this.xFraction = fraction;

        if (((ViewGroup) getParent()).getWidth() == 0) {
            if (preDrawListener == null) {
                preDrawListener = new ViewTreeObserver.OnPreDrawListener() {
                    @Override
                    public boolean onPreDraw() {
                        getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
                        setXFraction(xFraction);
                        return true;
                    }
                };
                getViewTreeObserver().addOnPreDrawListener(preDrawListener);
            }
            return;
        }

        float translationX = Math.max(0, (((ViewGroup) getParent()).getWidth()) * fraction - (getWidth() * getScaleX() / 2));
        setTranslationX(translationX);
    }

    public float getXFraction() {
        return this.xFraction;
    }

    public void setYFraction(float fraction) {
        this.yFraction = fraction;

        if (((ViewGroup) getParent()).getHeight() == 0) {
            if (preDrawListener == null) {
                preDrawListener = new ViewTreeObserver.OnPreDrawListener() {
                    @Override
                    public boolean onPreDraw() {
                        getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
                        setYFraction(yFraction);
                        return true;
                    }
                };
                getViewTreeObserver().addOnPreDrawListener(preDrawListener);
            }
            return;
        }

        float translationY = Math.max(0, (((ViewGroup) getParent()).getHeight()) * fraction - (getHeight() * getScaleY() / 2));
        setTranslationY(translationY);
    }

    public float getYFraction() {
        return this.yFraction;
    }
}

slide_right_down.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:ordering="together">
        <objectAnimator
            android:interpolator="@android:anim/accelerate_decelerate_interpolator"
            android:propertyName="xFraction"
            android:duration="@android:integer/config_mediumAnimTime"
            android:valueFrom="0.0"
            android:valueTo="0.5"
            android:valueType="floatType"/>

        <objectAnimator
            android:interpolator="@android:anim/accelerate_decelerate_interpolator"
            android:propertyName="yFraction"
            android:duration="@android:integer/config_mediumAnimTime"
            android:valueFrom="0.0"
            android:valueTo="0.1"
            android:valueType="floatType"/>

        <objectAnimator
            android:duration="@android:integer/config_mediumAnimTime"
            android:propertyName="scaleX"
            android:valueFrom="1.0"
            android:valueTo="1.5"/>

        <objectAnimator
            android:duration="@android:integer/config_mediumAnimTime"
            android:propertyName="scaleY"
            android:valueFrom="1.0"
            android:valueTo="1.5"/>
</set>

slide_left_up.xml

<?xml version="1.0" encoding="utf-8"?>
<set android:ordering="together"
     xmlns:android="http://schemas.android.com/apk/res/android">
    <objectAnimator
        android:propertyName="xFraction"
        android:duration="@android:integer/config_mediumAnimTime"
        android:valueFrom="0.5"
        android:valueTo="0.0"
        android:valueType="floatType"/>
    <objectAnimator
        android:propertyName="yFraction"
        android:duration="@android:integer/config_mediumAnimTime"
        android:valueFrom="0.1"
        android:valueTo="0.0"
        android:valueType="floatType"/>

    <objectAnimator
        android:duration="@android:integer/config_mediumAnimTime"
        android:propertyName="scaleX"
        android:valueFrom="1.5"
        android:valueTo="1.0"/>

    <objectAnimator
        android:duration="@android:integer/config_mediumAnimTime"
        android:propertyName="scaleY"
        android:valueFrom="1.5"
        android:valueTo="1.0"/>
</set>

This is the result (it's a lot faster and smoother from the device)

enter image description here


Another way that you can do this is with a MotionLayout and ImageFilterView. ImageFilterView, introduced in ConstraintLayout version 2.0, allows for the manipulation of images. It can do amazing things like crossfade two images but it can also modify the radius of a given image. My example doesn't have the small bounce that the one that the accepted answer posted but it would be easy to add with KeyFrames.

Here's what my solution looks like: VIDEO

And here are the files required to make it happen

First, your activity/fragment should look like the following (note, this can be a subView as well if you wanted to incorporate this into an existing layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout
    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"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".MainActivity"
    app:layoutDescription="@xml/motion_scene">

    <androidx.constraintlayout.utils.widget.ImageFilterView
        android:id="@+id/puthPic"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:scaleType="centerCrop"
        android:src="@drawable/puth"/>

</androidx.constraintlayout.motion.widget.MotionLayout>

Note the MotionLayout has a field called app:layoutDescription that points to @xml/motion_scene... this is what the motion_scene.xml layout looks like

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">
<Transition
    motion:constraintSetEnd="@+id/end"
    motion:constraintSetStart="@+id/start"
    motion:duration="500"
    motion:motionInterpolator="easeInOut">
    <OnClick
        motion:clickAction="toggle"
        motion:targetId="@+id/puthPic" />
</Transition>
<ConstraintSet android:id="@+id/start">
    <Constraint android:id="@id/puthPic">
        <Layout
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:layout_marginStart="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toTopOf="parent" />
        <CustomAttribute
            motion:attributeName="roundPercent"
            motion:customFloatValue="1" />
    </Constraint>
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
    <Constraint android:id="@id/puthPic">
        <Layout
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:layout_marginEnd="16dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintTop_toTopOf="parent" />
        <CustomAttribute
            motion:attributeName="roundPercent"
            motion:customFloatValue="0.000001" />
    </Constraint>
</ConstraintSet>
</MotionScene>

And just with that small amount of code, the motionLayout interpolates between the location and the circle to square for you!

You'll notice that I put motion:customFloatValue="0.000001" as the end scene value for the percentRound. This is because there is an existing bug that causes the image to stay rectangular if you set the percentRound to 0.0. I have filed this bug and you can see more about it here if you want.

And there you have it, another way to animate between a square and circular view with ease!