Como lutei com a transição de elemento compartilhado e escrevi minha primeira biblioteca de código-fonte aberto

Não há história mais triste no mundo
do que a história do ViewPager'e e SET'e



Gostaria de avisar que o autor é um novato no Android, portanto, o artigo contém tantas imprecisões técnicas que você deve ser avisado de que declarações tecnicamente confiáveis ​​podem aparecer no artigo.


Para onde o back-end leva


Toda a minha vida eu vi o back-end. No início de 2019, já existe um projeto muito ambicioso, mas inacabado. Uma viagem infrutífera a Zurique para uma entrevista com uma empresa de pesquisa. Inverno, terra, sem humor. Não há força e desejo de levar o projeto adiante.


Eu queria esquecer esse backend terrível para sempre. Felizmente, o destino me deu uma idéia - era uma aplicação móvel. Sua principal característica era o uso não padrão da câmera. O trabalho começou a ferver. Pouco tempo se passou e o protótipo está pronto. O lançamento do projeto estava se aproximando e tudo estava bem e bem proporcionado, até que eu decidi tornar o usuário “ conveniente ”.


ViewPager e transição de elemento compartilhado. Estamos à procura de reconciliação


Ninguém quer clicar nos pequenos botões do menu em 2019, tudo bem e esquerda eles querem deslizar as telas. É dito - feito, feito - quebrado. Então o primeiro ViewPager apareceu no meu projeto (decifrei alguns termos para o mesmo back-end que eu - basta mover o cursor). E a transição de elemento compartilhado (daqui em diante SET ou transição) - o elemento de assinatura do Design de materiais , recusou-se a trabalhar com o ViewPager , deixando-me uma escolha: furtos ou belas animações de transição entre telas. Não queria recusar um ou outro. Então minha pesquisa começou.


Horas de estudo: dezenas de tópicos em fóruns e perguntas não respondidas no StackOverflow. Não importa o que eu abra, me ofereceram a transição do RecyclerView para o ViewPager ou "anexar uma banana Fragment.postponeEnterTransition() ".



Os remédios populares não ajudaram e eu decidi reconciliar o ViewPager e a Shared Element Transition .


ViewPager: Primeiro Sangue


Comecei a refletir: “O problema aparece no momento em que o usuário passa de uma página para outra ...”. E então me dei conta: "Você não terá problemas com o SET durante a mudança de página, se você não mudar a página".



Podemos fazer a transição na mesma página e apenas substituir a página atual pela página de destino no ViewPager .


Primeiro, crie fragmentos com os quais trabalharemos.


 SmallPictureFragment small_picture_fragment = new SmallPictureFragment(); BigPictureFragment big_picture_fragment = new BigPictureFragment(); 

Vamos tentar alterar o fragmento na página atual para outra coisa.


 FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); //  View      int fragmentContId = previousFragment.getView().getParent().getId(); //         fragmentTransaction.replace(fragmentContId, nextFragment); fragmentTransaction.commit(); 

Iniciamos o aplicativo e ... alternamos suavemente para uma tela em branco. Qual o motivo?


Acontece que o contêiner de cada página é o próprio ViewPager , sem intermediários como Page1Container , Page2Container . Portanto, apenas para alterar uma página para outra não funciona, o pager inteiro será substituído.


Bem, para alterar o conteúdo de cada página individualmente, criamos vários fragmentos de contêiner para cada página.


 RootSmallPictureFragment root_small_pic_fragment = new RootSmallPictureFragment(); RootBigPictureFragment root_big_pic_fragment = new RootBigPictureFragment(); 

Algo não começará novamente.


java.lang.IllegalStateException: Não é possível alterar o ID do contêiner do fragmento BigPictureFragment {...}: era 2131165289 agora 2131165290

Não podemos anexar um fragmento da segunda página ( BigPictureFragment ) à primeira, porque ela já está anexada ao contêiner da segunda página.


Depois de cerrarmos os dentes, adicionamos mais fragmentos duplos.


 SmallPictureFragment small_picture_fragment_fake = new SmallPictureFragment(); BigPictureFragment big_picture_fragment_fake = new BigPictureFragment(); 

Ganhou! O código de transição que uma vez copiei das extensões do GitHub já continha animações fade in e fade out . Portanto, antes da transição, todos os elementos estáticos do primeiro fragmento desapareceram, as imagens foram movidas e somente então os elementos da segunda página apareceram. Para o usuário, isso parece um movimento real entre as páginas.


Todas as animações passaram, mas há um problema. O usuário ainda está na primeira página, mas deve estar na segunda.


Para corrigir isso, substituímos cuidadosamente a página visível do ViewPager uma segunda. E então restauramos o conteúdo da primeira página ao seu estado inicial.


  handler.postDelayed( () -> { //       activity.viewPager.setCurrentItem(nextPage, false); FragmentTransaction transaction = fragmentManager.beginTransaction(); //   . //       //    Shared Element Transition transaction.replace(fragmentContainer, previousFragment); transaction.commitAllowingStateLoss(); }, //      ,     FADE_DEFAULT_TIME + MOVE_DEFAULT_TIME + FADE_DEFAULT_TIME ); } 

Qual é o resultado? (animação - 2,7 mb)

Código fonte inteiro
public class FragmentTransitionUtil {
private static final long FADE_DEFAULT_TIME = 500;
private static final long MOVE_DEFAULT_TIME = 1000;
public static void perform(
MainActivity activity,
Fragment previousFragment,
Fragment nextFragment,
Map<View, String> sharedElements,
int nextPage
) {
FragmentManager fragmentManager = activity.getSupportFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
if (previousFragment != null) {
// 1. Exit for Previous Fragment
Fade exitFade = new Fade();
exitFade.setDuration(FADE_DEFAULT_TIME);
previousFragment.setExitTransition(exitFade);
// 2. Shared Elements Transition
TransitionSet enterTransitionSet = new TransitionSet();
enterTransitionSet.addTransition(
new TransitionSet() {
{
setOrdering(ORDERING_TOGETHER);
addTransition(new ChangeBounds()).
addTransition(new ChangeTransform()).
addTransition(new ChangeImageTransform());
}
}
);
enterTransitionSet.setDuration(MOVE_DEFAULT_TIME);
enterTransitionSet.setStartDelay(FADE_DEFAULT_TIME);
nextFragment.setSharedElementEnterTransition(enterTransitionSet);
// 3. Enter Transition for New Fragment
Fade enterFade = new Fade();
enterFade.setStartDelay(MOVE_DEFAULT_TIME + FADE_DEFAULT_TIME);
enterFade.setDuration(FADE_DEFAULT_TIME);
nextFragment.setEnterTransition(enterFade);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (sharedElements != null) {
for (Map.Entry<View, String> viewStringEntry : sharedElements.entrySet()) {
View view = viewStringEntry.getKey();
String transName = viewStringEntry.getValue();
view.setTransitionName(transName);
fragmentTransaction.addSharedElement(
view,
transName
);
}
}
}
int fragmentContId = ((ViewGroup) previousFragment.getView().getParent()).getId();
fragmentTransaction.replace(fragmentContId, nextFragment);
fragmentTransaction.commit();
final Handler handler = new Handler();
handler.postDelayed(
() -> {
// Stealthy changing page visible to user. He won’t notice!
activity.viewPager.setCurrentItem(nextPage, false);
FragmentTransaction transaction = fragmentManager.beginTransaction();
// Restore previous fragment. It contains inappropriate view now
transaction.replace(fragmentContId, previousFragment);
transaction.commitAllowingStateLoss();
},
FADE_DEFAULT_TIME + MOVE_DEFAULT_TIME + FADE_DEFAULT_TIME
);
}
}
}

O projeto pode ser visualizado no GitHub .


Para resumir. O código começou a parecer muito mais sólido: em vez de 2 fragmentos iniciais, obtive até 6, apareceram instruções nele que controlam o desempenho, substituindo fragmentos no momento certo. E isso é apenas na demonstração.


No mesmo projeto, um após o outro no código, os backups começaram a aparecer nos locais mais inesperados. Eles não permitiram que o aplicativo desmoronasse quando o usuário clicou nos botões das páginas "erradas" ou restringiu o trabalho em segundo plano de fragmentos duplicados.


Verificou-se que o android não possui retornos de chamada para concluir a transição , e seu tempo de execução é muito arbitrário e depende de muitos fatores (por exemplo, a rapidez com que o RecyclerView carrega no fragmento resultante). Isso levou ao fato de que a substituição de fragmentos em handler.postDelayed() frequentemente executada muito cedo ou muito tarde, o que exacerbava o problema anterior.


O último destaque foi que, durante a animação, o usuário pode simplesmente deslizar para outra página e assistir a duas telas duplas, após o que o aplicativo também a puxa para a tela desejada.


Artefatos interessantes dessa abordagem (animação - 2,7 mb)

Esse estado de coisas não me agradou e eu, cheio de raiva justa, comecei a procurar outra solução.


Como fazer a transição de elemento compartilhado no Viewpager corretamente


Tentando o PageTransformer


Ainda não havia respostas na Internet, e pensei: de que outra forma essa transição pode ser iniciada. Algo no subcórtex da consciência sussurrou para mim: "Use o PageTransformer , Luke". A ideia parecia promissora para mim e eu decidi ouvir.


A idéia é criar o PageTransformer , que, diferentemente do Android SET , não exigirá várias repetições de setTransitionName(transitionName) FragmentTransaction.addSharedElement(sharedElement,name) setTransitionName(transitionName) e FragmentTransaction.addSharedElement(sharedElement,name) nos dois lados da transição. Ele moverá os elementos após o furto e terá uma interface simples do formulário:


  public void addSharedTransition(int fromViewId, int toViewId) 

Prosseguimos para o desenvolvimento. Vou salvar os dados do addSharedTransition(fromId, toId) em Set from Pair e obtê-los no método PageTransfomer


 /**     ,   ,     */ public void transformPage(@NonNull View page, float position) 

Por dentro, examinarei todos os pares de View salvos, entre os quais preciso fazer uma animação. E tentarei filtrá-los para que apenas elementos visíveis sejam animados.


Primeiro, vamos verificar se os elementos que precisam ser animados foram criados. Não somos exigentes e, se a View não View criada antes do início da animação, não interromperemos a animação inteira (como a Transição de Elemento Compartilhado ), mas a buscaremos quando o elemento for criado.


 for (Pair<Integer,Integer> idPair : sharedElementIds) { Integer fromViewId = idPair.first; Integer toViewId = idPair.second; View fromView = activity.findViewById(fromViewId); View toView = activity.findViewById(toViewId); if (fromView != null && toView != null) { 

Encontro as páginas entre as quais o movimento ocorre (como determino o número da página será descrito abaixo).


 View fromPage = pages.get(fromPageNumber); View toPage = pages.get(toPageNumber); 

Se as duas páginas já tiverem sido criadas, estou procurando um par de View nelas, que preciso animar.


 If (fromPage != null && toPage != null) { fromView = fromPage.findViewById(fromViewId); toView = toPage.findViewById(toViewId); 

Nesse estágio, selecionamos Exibir, que fica nas páginas entre as quais o usuário rola.


É hora de obter muitas variáveis. Calculo pontos de referência:


 //         //     float fromX = fromView.getX() - fromView.getTranslationX(); float fromY = fromView.getY() - fromView.getTranslationY(); float toX = toView.getX() - toView.getTranslationX(); float toY = toView.getY() - toView.getTranslationY(); float deltaX = toX - fromX; float deltaY = toY - fromY; //      float fromWidth = fromView.getWidth(); float fromHeight = fromView.getHeight(); float toWidth = toView.getWidth(); float toHeight = toView.getHeight(); float deltaWidth = toWidth - fromWidth; float deltaHeight = toHeight - fromHeight; //       boolean slideToTheRight = toPageNumber > fromPageNumber; 

No último trecho, defino slideToTheRight e, já neste, será útil para mim. A translation depende disso, o que determina que o View voará para seu lugar ou em algum lugar fora da tela.


 float pageWidth = getScreenWidth(); float sign = slideToTheRight ? 1 : -1; float translationY = (deltaY + deltaHeight / 2) * sign * (-position); float translationX = (deltaX + sign * pageWidth + deltaWidth / 2) * sign * (-position); 

Curiosamente, as fórmulas de deslocamento para X e Y para o modo de View , na página inicial e na página resultante, foram as mesmas, apesar dos diferentes deslocamentos iniciais.


Porém, com a escala, infelizmente, esse truque não funcionará - é preciso considerar se essa View ponto inicial ou final da animação.


Pode ser uma surpresa para alguém, mas o transformPage(@NonNull View page, float position) é chamado várias vezes: para cada página em cache (o tamanho do cache é personalizável). E, para não redesenhar a View animada várias vezes, para cada chamada a transformPage() , alteramos apenas as que estão na page atual.


Definimos a posição e a escala dos elementos animados
 //   View     if (page.findViewById(fromId) != null) { //      fromView.setTranslationX(translationX); fromView.setTranslationY(translationY); / /   float scaleX = (fromWidth == 0) ? 1 : //  View   , //        (fromWidth + deltaWidth * sign * (-position)) / fromWidth; float scaleY = (fromHeight == 0) ? 1 : //  View   , //        (fromHeight + deltaHeight * sign * (-position)) / fromHeight; fromView.setScaleX(scaleX); fromView.setScaleY(scaleY); } //   View     if (page.findViewById(toId) != null) { toView.setTranslationX(translationX); toView.setTranslationY(translationY); float scaleX = (toWidth == 0) ? 1 : (toWidth + deltaWidth * sign * (-position)) / toWidth; float scaleY = (toHeight == 0) ? 1 : (toHeight + deltaHeight * sign * (-position)) / toHeight; toView.setScaleX(scaleX); toView.setScaleY(scaleY); } 

Selecione páginas para desenhar animações


ViewPager não tem pressa em compartilhar informações entre as páginas que estão rolando. Como prometi, agora vou contar como obtemos essas informações. Em nosso PageTransformer implementamos outra interface ViewPager.OnPageChangeListener . Tendo estudado a saída de onPageScrolled() através de System.out.println() cheguei à seguinte fórmula:


 public void onPageScrolled( int position, float positionOffset, int positionOffsetPixels ) { Set<Integer> visiblePages = new HashSet<>(); visiblePages.add(position); visiblePages.add(positionOffset >= 0 ? position + 1 : position - 1); visiblePages.remove(fromPageNumber); toPageNumber = visiblePages.iterator().next(); if (pages == null || toPageNumber >= pages.size()) toPageNumber = null; } public void onPageSelected(int position) { this.position = position; } public void onPageScrollStateChanged(int state) { if (state == SCROLL_STATE_IDLE) { //      ,    fromPageNumber = position; resetViewPositions(); } } 

Isso é tudo. Nós conseguimos! A animação monitora os gestos do usuário. Por que escolher entre deslizar e Transição de elemento compartilhado , quando você pode deixar tudo.


Ao escrever este artigo, adicionei o efeito do desaparecimento de elementos estáticos - ele ainda é muito bruto, portanto não foi adicionado à biblioteca.


Veja o que aconteceu no final (animação - 2,4 mb)

Código fonte inteiro
/**
* PageTransformer that allows you to do shared element transitions between pages in ViewPager.
* It requires view pager sides match screen sides to function properly. I.e. ViewPager page width
* must be equal to screen width. <br/>
* Usage:<br/>
* <code>
* sharedElementPageTransformer.addSharedTransition(R.id.FirstPageTextView, R.id.SecondPageTextView)</code>
* </code>
*
*
*/
public class SharedElementPageTransformer implements ViewPager.PageTransformer, ViewPager.OnPageChangeListener {
/** Android need the correction while view scaling for some reason*/
private static float MAGICAL_ANDROID_RENDERING_SCALE = 1;
// private static float MAGICAL_ANDROID_RENDERING_SCALE = 0.995f;
// External variables
private final Activity activity;
List<Fragment> fragments;
private Set<Pair<Integer,Integer>> sharedElementIds = new HashSet<>();
//Internal variables
private List<View> pages;
private Map<View, Integer> pageToNumber = new HashMap<>();
private Integer fromPageNumber = 0;
private Integer toPageNumber;
/** current view pager position */
private int position;
/**
* @param activity activity that hosts view pager
* @param fragments fragment that are in view pager in the SAME ORDER
*/
public SharedElementPageTransformer(Activity activity, List<Fragment> fragments) {
this.activity = activity;
this.fragments = fragments;
}
@Override
public void transformPage(@NonNull View page, float position) {
updatePageCache();
if (fromPageNumber == null || toPageNumber == null) return;
for (Pair<Integer,Integer> idPair : sharedElementIds) {
Integer fromViewId = idPair.first;
Integer toViewId = idPair.second;
View fromView = activity.findViewById(fromViewId);
View toView = activity.findViewById(toViewId);
if (fromView != null && toView != null) {
//Looking if current Shared element transition matches visible pages
View fromPage = pages.get(fromPageNumber);
View toPage = pages.get(toPageNumber);
if (fromPage != null && toPage != null) {
fromView = fromPage.findViewById(fromViewId);
toView = toPage.findViewById(toViewId);
// if both views are on pages user drag between apply transformation
if (
fromView != null
&& toView != null
) {
// saving shared element position on the screen
float fromX = fromView.getX() - fromView.getTranslationX();
float fromY = fromView.getY() - fromView.getTranslationY();
float toX = toView.getX() - toView.getTranslationX();
float toY = toView.getY() - toView.getTranslationY();
float deltaX = toX - fromX;
float deltaY = toY - fromY;
// scaling
float fromWidth = fromView.getWidth();
float fromHeight = fromView.getHeight();
float toWidth = toView.getWidth();
float toHeight = toView.getHeight();
float deltaWidth = toWidth - fromWidth;
float deltaHeight = toHeight - fromHeight;
int fromId = fromView.getId();
int toId = toView.getId();
boolean slideToTheRight = toPageNumber > fromPageNumber;
if (position <= -1) {
} else if (position < 1) {
float pageWidth = getSceenWidth();
float sign = slideToTheRight ? 1 : -1;
float translationY = (deltaY + deltaHeight / 2) * sign * (-position);
float translationX = (deltaX + sign * pageWidth + deltaWidth / 2) * sign * (-position);
if (page.findViewById(fromId) != null) {
fromView.setTranslationX(translationX);
fromView.setTranslationY(translationY);
float scaleX = (fromWidth == 0) ? 1 : (fromWidth + deltaWidth * sign * (-position)) / fromWidth;
float scaleY = (fromHeight == 0) ? 1 : (fromHeight + deltaHeight * sign * (-position)) / fromHeight;
fromView.setScaleX(scaleX);
fromView.setScaleY(scaleY * MAGICAL_ANDROID_RENDERING_SCALE);
}
if (page.findViewById(toId) != null) {
toView.setTranslationX(translationX);
toView.setTranslationY(translationY);
float scaleX = (toWidth == 0) ? 1 : (toWidth + deltaWidth * sign * (-position)) / toWidth;
float scaleY = (toHeight == 0) ? 1 :(toHeight + deltaHeight * sign * (-position)) / toHeight;
toView.setScaleX(scaleX);
toView.setScaleY(scaleY);
}
} else {
}
}
}
}
}
}
private float getSceenWidth() {
Point outSize = new Point();
activity.getWindowManager().getDefaultDisplay().getSize(outSize);
return outSize.x;
}
/**
* Creating page cache array to determine if shared element on
* currently visible page
*/
private void updatePageCache() {
pages = new ArrayList<>();
for (int i = 0; i < fragments.size(); i++) {
View pageView = fragments.get(i).getView();
pages.add(pageView);
pageToNumber.put(pageView, i);
}
}
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
Set<Integer> visiblePages = new HashSet<>();
visiblePages.add(position);
visiblePages.add(positionOffset >= 0 ? position + 1 : position - 1);
visiblePages.remove(fromPageNumber);
toPageNumber = visiblePages.iterator().next();
if (pages == null || toPageNumber >= pages.size()) toPageNumber = null;
}
@Override
public void onPageSelected(int position) {
this.position = position;
}
@Override
public void onPageScrollStateChanged(int state) {
if (state == SCROLL_STATE_IDLE) {
fromPageNumber = position;
resetViewPositions();
}
}
private void resetViewPositions() {
for (Pair<Integer, Integer> idPair : sharedElementIds) {
View sharedElement = activity.findViewById(idPair.first);
if(sharedElement != null) {
sharedElement.setTranslationX(0);
sharedElement.setTranslationY(0);
sharedElement.setScaleX(1);
sharedElement.setScaleY(1);
}
sharedElement = activity.findViewById(idPair.second);
if(sharedElement != null) {
sharedElement.setTranslationX(0);
sharedElement.setTranslationY(0);
sharedElement.setScaleX(1);
sharedElement.setScaleY(1);
}
}
}
/**
* Set up shared element transition from element with <code>fromViewId</code> to
* element with <code>toViewId</code>. Note that you can setup each transition
* direction separately. e.g. <br/>
* <code>addSharedTransition(R.id.FirstPageTextView, R.id.SecondPageTextView)</code><br/>
* and<br/>
* <code>addSharedTransition(R.id.SecondPageTextView, R.id.FirstPageTextView)</code><br/>
* are different.
* @param fromViewId
* @param toViewId
*/
public void addSharedTransition(int fromViewId, int toViewId) {
addSharedTransition(fromViewId, toViewId, false);
}
/**
* Set up shared element transition from element with <code>fromViewId</code> to
* element with <code>toViewId</code>. Note that you can setup each transition
* direction separately. e.g. <br/>
* <code>addSharedTransition(R.id.FirstPageTextView, R.id.SecondPageTextView)</code><br/>
* and<br/>
* <code>addSharedTransition(R.id.SecondPageTextView, R.id.FirstPageTextView)</code><br/>
* are different.
* @param fromViewId
* @param toViewId
* @param bothDirections to include backward transition from toViewId to fromViewId aswell
*/
public void addSharedTransition(int fromViewId, int toViewId, boolean bothDirections) {
sharedElementIds.add(new Pair<>(fromViewId, toViewId));
if(bothDirections) {
sharedElementIds.add(new Pair<>(toViewId, fromViewId));
}
}
/**
* In case there is "ladder" appears between while transition.
* You may try to tune that magical scale to get rid of it.
* @param magicalAndroidRenderingScale float between 0 and infinity. Typically very close to 1.0
*/
public static void setMagicalAndroidRenderingScale(float magicalAndroidRenderingScale) {
MAGICAL_ANDROID_RENDERING_SCALE = magicalAndroidRenderingScale;
}
}

Como é o trabalho com a biblioteca


A configuração acabou bem concisa.


A configuração completa do nosso exemplo é semelhante a esta
 ArrayList<Fragment> fragments = new ArrayList<>(); fragments.add(hello_fragment); fragments.add(small_picture_fragment); fragments.add(big_picture_fragment); SharedElementPageTransformer transformer = new SharedElementPageTransformer(this, fragments); transformer.addSharedTransition(R.id.smallPic_image_cat2, R.id.bigPic_image_cat, true); transformer.addSharedTransition(R.id.smallPic_text_label3, R.id.bigPic_text_label, true); transformer.addSharedTransition(R.id.hello_text, R.id.smallPic_text_label3, true); viewPager.setPageTransformer(false, transformer); viewPager.addOnPageChangeListener(transformer); 

onClick , incluindo todas as transições, pode ser assim:


 smallCatImageView.setOnClickListener( v -> activity.viewPager.setCurrentItem(2) ); 

Para que o código não desapareça, coloquei a biblioteca no repositório do JCenter e no GitHub . Então entrei em contato com o mundo de código aberto. Você pode experimentá-lo em seu projeto simplesmente adicionando


 dependencies { //... implementation 'com.github.kirillgerasimov:shared-element-view-pager:0.0.2-alpha' } 

Todas as fontes estão disponíveis no GitHub


Conclusão


Mesmo que a Internet não saiba a resposta, isso não significa que não. Procure soluções alternativas, tente até que funcione. Talvez você seja o primeiro a chegar ao fundo disso e compartilhá-lo com a comunidade.

Source: https://habr.com/ru/post/pt451116/


All Articles