Android Shared Element Transition: Transforming an ImageView from a circle to a rectangle and back again
Asked Answered
M

2

32

I'm trying to do a shared element transition between two activities.

The first activity has a circle imageview and the second activity has a rectangular imageview. I just want the circle to transition from the first activity to the second activity where it becomes a square and back to the circle when I press back.

I find that the transition is not so neat - in the animation below, you can see that the rectangular imageview seem to reduce in size until it matches the size of the circle. The square imageview appears for a split second and and then the circle appears. I want to get rid of the square imageview so that the circle becomes the end point of the transition.

Anyone know how this is done? enter image description here

I have create a small test repo that you can download here: https://github.com/Winghin2517/TransitionTest

The code for my first activity - the imageview sits within the MainFragment of my first activity:

public class MainFragment extends android.support.v4.app.Fragment {

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_view, container,false);
        final ImageView dot = (ImageView) view.findViewById(R.id.image_circle);
        Picasso.with(getContext()).load(R.drawable.snow).transform(new PureCircleTransformation()).into(dot);
        dot.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Intent i = new Intent(getContext(), SecondActivity.class);
                View sharedView = dot;
                String transitionName = getString(R.string.blue_name);
                ActivityOptionsCompat transitionActivityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), sharedView, transitionName);
                startActivity(i, transitionActivityOptions.toBundle());
            }
        });
        return view;
    }
}

This is my second activity which contains the rectangular imageview:

public class SecondActivity extends AppCompatActivity {

    ImageView backdrop;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_second);
        backdrop = (ImageView) findViewById(R.id.picture);
        backdrop.setBackground(ContextCompat.getDrawable(this, R.drawable.snow));
    }

    @Override
    public void onBackPressed() {
        supportFinishAfterTransition();
        super.onBackPressed();

    }
}

This is the PureCircleTransformation class that I pass into Picasso to generate the circle:

package test.com.transitiontest;

import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;

import com.squareup.picasso.Transformation;

public class PureCircleTransformation implements Transformation {

    private static final int STROKE_WIDTH = 6;

    @Override
    public Bitmap transform(Bitmap source) {
        int size = Math.min(source.getWidth(), source.getHeight());

        int x = (source.getWidth() - size) / 2;
        int y = (source.getHeight() - size) / 2;

        Bitmap squaredBitmap = Bitmap.createBitmap(source, x, y, size, size);
        if (squaredBitmap != source) {
            source.recycle();
        }

        Bitmap bitmap = Bitmap.createBitmap(size, size, source.getConfig());

        Canvas canvas = new Canvas(bitmap);

        Paint avatarPaint = new Paint();
        BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
        avatarPaint.setShader(shader);

        float r = size / 2f;
        canvas.drawCircle(r, r, r, avatarPaint);

        squaredBitmap.recycle();
        return bitmap;
    }

    @Override
    public String key() {
        return "circleTransformation()";
    }
}

I do understand that in my first activity, the circle is just 'cut' out by applying the Picasso transformation class and that the imageview is just a square layout cut out so that it appears as a circle. Maybe this is the reason why the animation looks like this as I'm transitioning from a rectangular to a square, but I really want the the transition to go from the rectangular to a circle.

I think there is a way to do this. In the whatsapp app, I can see the effect but I just cannot seem to figure out how they managed to do it - If you click on the profile picture of your friends on whatsapp, the app expands the circle imageview to a square. Clicking back will return the square to the circle.

enter image description here

Maestoso answered 28/9, 2016 at 13:46 Comment(4)
Is this what you're looking for? Link I ask because its not exactly what WhatsApp is using.Disruption
Actually yes - how did you transform it like that? I'm looking for an animation that would be quicker than what you have posted but i can just change the duration. Can you please post it as a solution, as well as the link to your repo?Maestoso
Hi SImon, sorry about the delay, got a bit busy over the weekend. I see that you're okay with Beloo's answer. I've made this functionality into an android open source library available here. Less code to maintain for a developer is always better :).Disruption
Your library is really impressive, well done. My app targets api 16 upwards, if you can finalise ImageTransitionCompat, I would incorporate your library within my next build of my app.Maestoso
T
29

I offer to create a custom view, which can animate itself from circle to rect and back and then wrap custom transition around it with adding moving animation.

How it is looks like:
Circle to rect transition gif

Code is below (valuable part).
For full sample, check my github.

CircleRectView.java:

public class CircleRectView extends ImageView {

private int circleRadius;
private float cornerRadius;

private RectF bitmapRect;
private Path clipPath;

private void init(TypedArray a) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2
            && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
        setLayerType(LAYER_TYPE_SOFTWARE, null);
    }

    if (a.hasValue(R.styleable.CircleRectView_circleRadius)) {
        circleRadius = a.getDimensionPixelSize(R.styleable.CircleRectView_circleRadius, 0);
        cornerRadius = circleRadius;
    }
    clipPath = new Path();
    a.recycle();
}

public Animator animator(int startHeight, int startWidth, int endHeight, int endWidth) {
    AnimatorSet animatorSet = new AnimatorSet();

    ValueAnimator heightAnimator = ValueAnimator.ofInt(startHeight, endHeight);
    ValueAnimator widthAnimator = ValueAnimator.ofInt(startWidth, endWidth);

    heightAnimator.addUpdateListener(valueAnimator -> {
        int val = (Integer) valueAnimator.getAnimatedValue();
        ViewGroup.LayoutParams layoutParams = getLayoutParams();
        layoutParams.height = val;

        setLayoutParams(layoutParams);
        requestLayoutSupport();
    });

    widthAnimator.addUpdateListener(valueAnimator -> {
        int val = (Integer) valueAnimator.getAnimatedValue();
        ViewGroup.LayoutParams layoutParams = getLayoutParams();
        layoutParams.width = val;

        setLayoutParams(layoutParams);
        requestLayoutSupport();
    });

    ValueAnimator radiusAnimator;
    if (startWidth < endWidth) {
        radiusAnimator = ValueAnimator.ofFloat(circleRadius, 0);
    } else {
        radiusAnimator = ValueAnimator.ofFloat(cornerRadius, circleRadius);
    }

    radiusAnimator.setInterpolator(new AccelerateInterpolator());
    radiusAnimator.addUpdateListener(animator -> cornerRadius = (float) (Float) animator.getAnimatedValue());

    animatorSet.playTogether(heightAnimator, widthAnimator, radiusAnimator);

    return animatorSet;
}

/**
 * this needed because of that somehow {@link #onSizeChanged} NOT CALLED when requestLayout while activity transition end is running
 */
private void requestLayoutSupport() {
    View parent = (View) getParent();
    int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
    int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.EXACTLY);
    parent.measure(widthSpec, heightSpec);
    parent.layout(parent.getLeft(), parent.getTop(), parent.getRight(), parent.getBottom());
}

@Override
public void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    //This event-method provides the real dimensions of this custom view.

    Log.d("size changed", "w = " + w + " h = " + h);

    bitmapRect = new RectF(0, 0, w, h);
}

@Override
protected void onDraw(Canvas canvas) {

    Drawable drawable = getDrawable();

    if (drawable == null) {
        return;
    }

    if (getWidth() == 0 || getHeight() == 0) {
        return;
    }

    clipPath.reset();
    clipPath.addRoundRect(bitmapRect, cornerRadius, cornerRadius, Path.Direction.CW);
    canvas.clipPath(clipPath);
    super.onDraw(canvas);
}

}

@TargetApi(Build.VERSION_CODES.KITKAT)
public class CircleToRectTransition extends Transition {
private static final String TAG = CircleToRectTransition.class.getSimpleName();
private static final String BOUNDS = "viewBounds";
private static final String[] PROPS = {BOUNDS};

@Override
public String[] getTransitionProperties() {
    return PROPS;
}

private void captureValues(TransitionValues transitionValues) {
    View view = transitionValues.view;
    Rect bounds = new Rect();
    bounds.left = view.getLeft();
    bounds.right = view.getRight();
    bounds.top = view.getTop();
    bounds.bottom = view.getBottom();
    transitionValues.values.put(BOUNDS, bounds);
}

@Override
public void captureStartValues(TransitionValues transitionValues) {
    captureValues(transitionValues);
}

@Override
public void captureEndValues(TransitionValues transitionValues) {
    captureValues(transitionValues);
}

@Override
public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
    if (startValues == null || endValues == null) {
        return null;
    }

    if (!(startValues.view instanceof CircleRectView)) {
        Log.w(CircleToRectTransition.class.getSimpleName(), "transition view should be CircleRectView");
        return null;
    }

    CircleRectView view = (CircleRectView) (startValues.view);

    Rect startRect = (Rect) startValues.values.get(BOUNDS);
    final Rect endRect = (Rect) endValues.values.get(BOUNDS);

    Animator animator;

    //scale animator
    animator = view.animator(startRect.height(), startRect.width(), endRect.height(), endRect.width());

    //movement animators below
    //if some translation not performed fully, use it instead of start coordinate
    float startX = startRect.left + view.getTranslationX();
    float startY = startRect.top + view.getTranslationY();

    //somehow end rect returns needed value minus translation in case not finished transition available
    float moveXTo = endRect.left + Math.round(view.getTranslationX());
    float moveYTo = endRect.top + Math.round(view.getTranslationY());

    Animator moveXAnimator = ObjectAnimator.ofFloat(view, "x", startX, moveXTo);
    Animator moveYAnimator = ObjectAnimator.ofFloat(view, "y", startY, moveYTo);

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.playTogether(animator, moveXAnimator, moveYAnimator);

    //prevent blinking when interrupt animation
    return new NoPauseAnimator(animatorSet);
}

MainActivity.java :

 view.setOnClickListener(v -> {
        Intent intent = new Intent(this, SecondActivity.class);
        ActivityOptionsCompat transitionActivityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(MainActivity.this, view, getString(R.string.circle));

        ActivityCompat.startActivity(MainActivity.this, intent , transitionActivityOptions.toBundle());
    });

SecondActivity.java :

@Override
protected void onCreate(Bundle savedInstanceState) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        getWindow().setSharedElementEnterTransition(new CircleToRectTransition().setDuration(1500));
        getWindow().setSharedElementExitTransition(new CircleToRectTransition().setDuration(1500));
    }

    super.onCreate(savedInstanceState);
    ...
 }
@Override
public void onBackPressed() {
    supportFinishAfterTransition();
}

EDITED: Previous variant of CircleToRectTransition wasn't general and worked only in specific case. Check modified example without that disadvantage

EDITED2: It turns out that you don't need custom transition at all, just remove setup logic from SecondActivity and it will be working via default way. With this approach you could set transition duration this way.

EDITED3: Provided backport for api < 18

By the way, you can backport this stuff onto pre-lollipop devices with using such technique. Where you can use animators have been already created

Tulipwood answered 10/10, 2016 at 18:59 Comment(5)
very very good answer. I have just downloaded your repo and it is working very well.Maestoso
I haven't integrated it into my app yet and will accept your answer once I have done so. Not to worry. U will get the bounty before the bounty time ends.Maestoso
Hello I need a way to set the circlerectview circleradius value programmatically. i tried to create a method to set it and then invalidate the view but it doesn't appear to be working. Also, the circlerectview class doesn't seem to work well when i use lower api other than 21. In api 16, the class just draws a square and in api 19, the circlerectview draws the picture with the radius without filling in the entire imageview in the second activity.Maestoso
@Maestoso Your pre-lollipop issue is connected with that devices can't handle clipPath properly on that api's. So i provided backport in init method, see edited answer. Second one, my custom view isn't so general, it draws just a rect with corners = circleRadius param. So when width&height are equal and circle radius is a half of it - circle will be drawn. You should also change the sizes of view if you want to change circle radius programmatically.Tulipwood
It doesn't work, could you update your code, please?Larisalarissa
W
5

There's some code you need to add: basically you have to implement a custom transition. But most of the code can be reused. I'm going to push the code on github for your reference, but the steps needed are:

SecondAcvitiy Create your custom transition:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {

    Transition transition = new CircularReveal();
    transition.setInterpolator(new LinearInterpolator());

    getWindow().setSharedElementEnterTransition(transition);
}

CircularReveal capture view bounds (start and end values) and provide two animations, the first one when you need to animate the circular image view to the big one, the second for the reverse case.

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class CircularReveal extends Transition {

    private static final String BOUNDS = "viewBounds";

    private static final String[] PROPS = {BOUNDS};

    @Override
    public void captureStartValues(TransitionValues transitionValues) {
        captureValues(transitionValues);
    }

    @Override
    public void captureEndValues(TransitionValues transitionValues) {
        captureValues(transitionValues);
    }

    private void captureValues(TransitionValues values) {
        View view = values.view;
        Rect bounds = new Rect();
        bounds.left = view.getLeft();
        bounds.right = view.getRight();
        bounds.top = view.getTop();
        bounds.bottom = view.getBottom();

        values.values.put(BOUNDS, bounds);
    }

    @Override
    public String[] getTransitionProperties() {
        return PROPS;
    }

    @Override
    public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
        if (startValues == null || endValues == null) {
            return null;
        }

        Rect startRect = (Rect) startValues.values.get(BOUNDS);
        final Rect endRect = (Rect) endValues.values.get(BOUNDS);

        final View view = endValues.view;

        Animator circularTransition;
        if (isReveal(startRect, endRect)) {
            circularTransition = createReveal(view, startRect, endRect);
            return new NoPauseAnimator(circularTransition);
        } else {
            layout(startRect, view);

            circularTransition = createConceal(view, startRect, endRect);
            circularTransition.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    view.setOutlineProvider(new ViewOutlineProvider() {
                        @Override
                        public void getOutline(View view, Outline outline) {
                            Rect bounds = endRect;
                            bounds.left -= view.getLeft();
                            bounds.top -= view.getTop();
                            bounds.right -= view.getLeft();
                            bounds.bottom -= view.getTop();
                            outline.setOval(bounds);
                            view.setClipToOutline(true);
                        }
                    });
                }
            });
            return new NoPauseAnimator(circularTransition);
        }
    }

    private void layout(Rect startRect, View view) {
        view.layout(startRect.left, startRect.top, startRect.right, startRect.bottom);
    }

    private Animator createReveal(View view, Rect from, Rect to) {

        int centerX = from.centerX();
        int centerY = from.centerY();
        float finalRadius = (float) Math.hypot(to.width(), to.height());

        return ViewAnimationUtils.createCircularReveal(view, centerX, centerY,
            from.width()/2, finalRadius);
    }

    private Animator createConceal(View view, Rect from, Rect to) {

        int centerX = to.centerX();
        int centerY = to.centerY();
        float initialRadius = (float) Math.hypot(from.width(), from.height());

        return ViewAnimationUtils.createCircularReveal(view, centerX, centerY,
            initialRadius, to.width()/2);
    }

    private boolean isReveal(Rect startRect, Rect endRect) {
        return startRect.width() < endRect.width();
    }
}
Wolgast answered 6/10, 2016 at 20:57 Comment(2)
Hi, what is the NoPauseAnimator?Snelling
NoPauseAnimator --> github.com/BelooS/CircleToRect-ActivityTransition/blob/master/…Conflux

© 2022 - 2024 — McMap. All rights reserved.