Android

30 mai, 2017

Transição, Zoom Pinch e Drag to back de ImageView no Android

Publicidade

Nesse artigo vou explicar de forma objetiva como fazer transição de imagens entre Activities distintas usando o framework Glide para carregar as imagens, aplicar zoom no detalhe da imagem com o framework PhotoView e Drag to back de forma nativa.

Transição de imagen entre as Activities

Primeiro vamos carregar uma imagem utilizando o Glide:

 private void loadImage(ImageView imageView) {
        Glide.with(this)
                .load(getString(R.string.img_url))
                .into(imageView);
    }

Passamos no método loadImage a ImageView para ser carregada pelo Glide. No Glide.load() passamos a URL dá imagem e no into() a imageView. Isso é feito na nossa MainActivity.

Em seguida setamos o click na nossa ImageView para fazer a transição:

private void transition(View view, String url, String id) {
        Intent intent = new Intent(this, ZoomPichActivity.class);
        intent.putExtra("url", url);
        intent.putExtra("id", id);

        ViewCompat.setTransitionName(view, id);
        ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, id);

        startActivity(intent, options.toBundle());
    }

    @OnClick(R.id.photoOne)
    public void onClickPhotoOne(){
        transition(photoOne, getString(R.string.img_url), getString(R.string.transition));
    }

Utilizamos a notação @OnClick do ButterKnife e o método transition() que criamos, onde passamos a nossa ImageView, a url da imagem e o id de transição para a outra View.

Criamos o nosso Intent passando a url e o id no putExtra() para ser setado na outra Activity.

Setamos no ViewCompat.setTransitionName() o nome da transição e a transição no ActivityOptionsCompat.makeSceneTransitionAnimation() e iniciamos a nossa próxima Activity passando nossa transição.

Custom FrameLayout

Para começar a criar nossa activity que receberá a transição, vamos começar pelo widget que utilizaremos no layout.

Vamos criar nosso widget FrameTouch, que será utilizado na nossa segunda activity. Utilizamos o GestureDetector e o ScaleGestureDetector para pegar os eventos de touch na nossa View.

Primeiro implementamos o ScaleGestureDetector.OnScaleGestureListener, que permiti que o ZoomPinch continue funcionando corretamente, com os métodos de Scale.

public class FrameTouch extends FrameLayout implements ScaleGestureDetector.OnScaleGestureListener {

...

@Override
    public boolean onScale(ScaleGestureDetector detector) {
        return true;
    }

    @Override
    public boolean onScaleBegin(ScaleGestureDetector detector) {
        return true;
    }

    @Override
    public void onScaleEnd(ScaleGestureDetector detector) {

    }

Criamos a classe MyGestureListener para pegar os eventos de transladação da View. No método onScroll() chamamos o onScrollMovie() e passamos os atributos distanceX e distanceY para transladar a imagem na view que a implementa.

O método onScrollMovie faz parte da interface OnFrameTouchListener.

...

private class MyGestureListener extends GestureDetector.SimpleOnGestureListener {

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            frameOnTouch.onScrollMovie(distanceX, distanceY);

            return super.onScroll(e1, e2, distanceX, distanceY);
        }

        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return super.onSingleTapUp(e);
        }
    }

Para finalizar nosso widget temos mais alguns métodos.

No dispatchTouchEvent(), verificamos que a view recebeu um evento de Touch e a partir disso, disparamos o que queremos fazer.

Quando o event for ACTION_UP ele chama o método onFrameTouchUp(), que vai verificar se a imagem está pronta para voltar a view anterior ou continuar na sua view, onde ela for implementada através da interface FrameOnTouch.

public interface FrameOnTouch {
        void onFrameTouchUp();
        void onScrollMovie(float x, float y);
    }


    public FrameTouch(Context context) {
        super(context);
        detector = new GestureDetectorCompat(context, new MyGestureListener());
    }

    public FrameTouch(Context context, AttributeSet attrs) {
        super(context, attrs);
        detector = new GestureDetectorCompat(context, new MyGestureListener());
        scaleDetector = new ScaleGestureDetector(context, this);
    }

    public void setFrameOnTouch(FrameOnTouch frameOnTouch) {
        this.frameOnTouch = frameOnTouch;
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_UP)
            frameOnTouch.onFrameTouchUp();

        super.dispatchTouchEvent(event);
        return detector.onTouchEvent(event);

    }

E nosso xml vai ficar assim:

<?xml version="1.0" encoding="utf-8"?>
<example.com.zoompinch.widget.FrameTouch xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/backgroundZoom"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/black"
    android:orientation="vertical"
    android:layout_gravity="center">

    <ImageView
        android:id="@+id/photo"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"/>
</example.com.zoompinch.widget.FrameTouch>

E agora, com o nosso xml pronto utilizando o nosso widget, vamos para nossa ZoomPichActivity.java.

Primeiro vamos configurar o ZoomPinch, finalizar nossa transição, e depois implementar os métodos do OnFrameTouchListener.

Configurando o ZoomPinch

Para ser bem direto vamos ver o código na prática:

private PhotoViewAttacher mAttacher;

{
  ...
        mAttacher = new PhotoViewAttacher(photo);
        mAttacher.setMinimumScale(0.5f);
        mAttacher.setMaximumScale(mAttacher.getMaximumScale());
        mAttacher.setScaleType(ImageView.ScaleType.FIT_CENTER);.
  ...
           Glide.with(this)
                .load(imgUrl)
                .into(new SimpleTarget<GlideDrawable>() {
                    @Override
                    public void onResourceReady(GlideDrawable resource, GlideAnimation<? super GlideDrawable> glideAnimation) {
                        photo.setImageDrawable(resource);
                        mAttacher.update();
                    }
                });
}
        
    @Override
    public void onDestroy() {
        super.onDestroy();
        mAttacher.cleanup();
    }

    @Override
    public void onBackPressed() {
        mAttacher.cleanup();
        super.onBackPressed();
    }

Criamos nossa variável mAttacher e no método onCreate da view, setamos as propriedades e a imageView que vai utilizar o framework. Setamos o mínimo e o máximo do Scale do zoom e o tipo do Zoom. E quando o Glide finalizar o load da imagem, setamos mAttacher.update() para que nossa imagem use as propriedades do framework (essse exemplo do load da imagem ainda é sem transição, que veremos em seguida).

No onDestroy() e onBackPressed() temos que limpar o mAttacher para não causar crash.

Finalizando a transição da imagem e a implementação da interface do FrameOnTouchListener

No código a seguir, finalizamos a transição:

supportPostponeEnterTransition();
        Glide.with(this)
                .load(imgUrl)
                .listener(new RequestListener<String, GlideDrawable>() {
                    @Override
                    public boolean onException(Exception e, String model, Target<GlideDrawable> target, boolean isFirstResource) {
                        supportStartPostponedEnterTransition();
                        return false;
                    }

                    @Override
                    public boolean onResourceReady(GlideDrawable resource, String model, Target<GlideDrawable> target, boolean isFromMemoryCache, boolean isFirstResource) {
                        supportStartPostponedEnterTransition();
                        return false;
                    }
                })
                .into(new SimpleTarget<GlideDrawable>() {
                    @Override
                    public void onResourceReady(GlideDrawable resource, GlideAnimation<? super GlideDrawable> glideAnimation) {
                        photo.setImageDrawable(resource);
                        mAttacher.update();
                    }
                });

Em seguida, temos nossos métodos que fazem a mágica do DragToBack acontecer:

   private double getDistance() {
        int distance = (int) Math.sqrt(photo.getTranslationY()*photo.getTranslationY() + photo.getTranslationX()*photo.getTranslationX());
        return Util.pxToDp(this, distance);
    }

    @Override
    public void onFrameTouchUp() {
        if (getDistance() > 50) {
            onBackPressed();
        } else {
            AnimatorSet animatorSet = new AnimatorSet();

            ObjectAnimator colorAnimator = ObjectAnimator.ofInt(frame, "backgroundColor", calcColor(), Color.BLACK);
            colorAnimator.setEvaluator(new ArgbEvaluator());

            animatorSet.playTogether(
                    ObjectAnimator.ofFloat(photo, "translationX", 0),
                    ObjectAnimator.ofFloat(photo, "translationY", 0),
                    colorAnimator
            );

            animatorSet.start();
        }
    }

    @Override
    public void onScrollMovie(float x, float y) {
        if (mAttacher.getScale() <= 1) {
            frame.setBackgroundColor(calcColor());
            photo.setTranslationY(photo.getTranslationY() - y);
            photo.setTranslationX(photo.getTranslationX() - x);
        }
    }

    private int calcColor() {
        return Color.argb((int)Math.max(0,255 - getDistance() * 1.5), 0, 0, 0);
    }
  • getDistance(): O método getDistance() retorna a distancia que a photo está do seu lugar de origem.
  • calColor(): Calcula a cor conforme a transladação da imagem na view, para dar o efeito de quanto mais longe a imagem estiver do lugar de origem o background vai ficando transparente e quanto mais perto vai voltando a ser preto.
  • onFramTouchUp(): É chamado quando o usuário solta a imagem e verifica se a distancia do seu lugar de origem é o suficiente para setar o onBackPressed() ou continuar na view onde está voltando a ter o background preto.
  • onScrollMove(): É chamado quando o usuário faz o drag na imagem e translada a mesma conforme for arrastada nos eixos X e Y.

E no fim, nossa Activity tem que ficar assim:

public class ZoomPichActivity extends AppCompatActivity implements FrameTouch.FrameOnTouch{

    @BindView(R.id.photo)
    ImageView photo;

    @BindView(R.id.backgroundZoom)
    FrameTouch frame;

    private PhotoViewAttacher mAttacher;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_zoom_pich);
        overridePendingTransition(R.anim.enter_anim, R.anim.enter_anim);
        ButterKnife.bind(this);

        Intent intent = getIntent();

        String imgUrl = intent.getStringExtra("url");
        String id = intent.getStringExtra("id");

        ViewCompat.setTransitionName(photo, id);

        mAttacher = new PhotoViewAttacher(photo);
        mAttacher.setMinimumScale(0.5f);
        mAttacher.setMaximumScale(mAttacher.getMaximumScale());
        mAttacher.setScaleType(ImageView.ScaleType.FIT_CENTER);

        supportPostponeEnterTransition();
        Glide.with(this)
                .load(imgUrl)
                .listener(new RequestListener<String, GlideDrawable>() {
                    @Override
                    public boolean onException(Exception e, String model, Target<GlideDrawable> target, boolean isFirstResource) {
                        supportStartPostponedEnterTransition();
                        return false;
                    }

                    @Override
                    public boolean onResourceReady(GlideDrawable resource, String model, Target<GlideDrawable> target, boolean isFromMemoryCache, boolean isFirstResource) {
                        supportStartPostponedEnterTransition();
                        return false;
                    }
                })
                .into(new SimpleTarget<GlideDrawable>() {
                    @Override
                    public void onResourceReady(GlideDrawable resource, GlideAnimation<? super GlideDrawable> glideAnimation) {
                        photo.setImageDrawable(resource);
                        mAttacher.update();
                    }
                });

        frame.setFrameOnTouch(this);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        mAttacher.cleanup();
    }

    @Override
    public void onBackPressed() {
        mAttacher.cleanup();
        super.onBackPressed();
    }


    private double getDistance() {
        int distance = (int) Math.sqrt(photo.getTranslationY()*photo.getTranslationY() + photo.getTranslationX()*photo.getTranslationX());
        return Util.pxToDp(this, distance);
    }

    @Override
    public void onFrameTouchUp() {
        if (getDistance() > 50) {
            onBackPressed();
        } else {
            AnimatorSet animatorSet = new AnimatorSet();

            ObjectAnimator colorAnimator = ObjectAnimator.ofInt(frame, "backgroundColor", calcColor(), Color.BLACK);
            colorAnimator.setEvaluator(new ArgbEvaluator());

            animatorSet.playTogether(
                    ObjectAnimator.ofFloat(photo, "translationX", 0),
                    ObjectAnimator.ofFloat(photo, "translationY", 0),
                    colorAnimator
            );

            animatorSet.start();
        }
    }

    @Override
    public void onScrollMovie(float x, float y) {
        if (mAttacher.getScale() <= 1) {
            frame.setBackgroundColor(calcColor());
            photo.setTranslationY(photo.getTranslationY() - y);
            photo.setTranslationX(photo.getTranslationX() - x);
        }
    }

    private int calcColor() {
        return Color.argb((int)Math.max(0,255 - getDistance() * 1.5), 0, 0, 0);
    }

}

Conclusão

De maneira simples, temos uma transição fluída, e que podemos implementar nos nossos projetos.

Por que essa feature foi feita? E como foi feita?

Ela foi feita para um projeto da empresa onde eu trabalho juntamente com o outro Dev Android, Bruno Stone. Se não fosse em conjunto, essa feature não teria saído do campo da ideia. Foi após uma conversa que tivemos de como fazer, olhando como era em outros apps como Facebook e chegamos à conclusão que valia a pena gastar um tempo nisso para que o detalhe desse componente ficasse o melhor possível para UX do app.

Source:

No meu gitHub está o código fonte dessa feature, e um app na Google Play para vocês verem na prática.

Fique à vontade pra mandar um e-mail ou deixar um comentário com alguma dúvida.

Bibliografia: http://mikescamell.com/shared-element-transitions-part-3/