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)
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!