Cómo luché con la Transición de elementos compartidos y escribí mi primera biblioteca de código abierto

No hay historia más triste en el mundo.
que la historia de ViewPager'e y SET'e



Me gustaría advertir que el autor es un novato en Android, por lo que el artículo contiene tantas imprecisiones técnicas que debería advertirle que pueden aparecer declaraciones técnicamente confiables en el artículo.


Donde lleva el backend


Toda mi vida vi el backend. A principios de 2019, ya hay un proyecto muy ambicioso pero inacabado. Un viaje infructuoso a Zurich para una entrevista con una compañía de búsqueda. Invierno, suciedad, sin humor. No hay fuerza ni ganas de llevar el proyecto más lejos.


Quería olvidar este terrible backend para siempre. Afortunadamente, el destino me dio una idea: era una aplicación móvil. Su característica principal era ser el uso no estándar de la cámara. El trabajo comenzó a hervir. Tomó un poco de tiempo, y ahora el prototipo está listo. El lanzamiento del proyecto se acercaba y todo estaba bien y bien estructurado hasta que decidí hacer que el usuario fuera " conveniente ".


ViewPager y Transición de elementos compartidos. Buscamos la reconciliación


Nadie quiere hacer clic en los pequeños botones del menú en 2019, todos a la derecha e izquierda quieren deslizar las pantallas. Se dice - hecho, hecho - roto. Entonces, el primer ViewPager apareció en mi proyecto (he descifrado algunos términos para el mismo backend que yo, solo mueva el cursor). Y la Transición de elementos compartidos (en adelante SET o transición), el elemento característico de Material Design , se negó rotundamente a trabajar con ViewPager , dejándome con una opción: deslizar o hermosas animaciones de transición entre pantallas. No quería rechazar ni a uno ni a otro. Entonces comenzó mi búsqueda.


Horas de estudio: docenas de temas en foros y preguntas sin respuesta sobre StackOverflow. No importa lo que abra, me ofrecieron hacer una transición de RecyclerView a ViewPager o "adjuntar un Fragment.postponeEnterTransition() plátano".



Los remedios populares no ayudaron, y decidí reconciliar ViewPager y Shared Element Transition yo mismo.


ViewPager: First Blood


Comencé a reflexionar: "El problema aparece en el momento en que el usuario pasa de una página a otra ...". Y luego me di cuenta: "No tendrá problemas con SET durante el cambio de página, si no cambia la página".



Podemos hacer la transición en la misma página y luego simplemente reemplazar la página actual con la página de destino en ViewPager .


Primero, crea fragmentos con los que trabajaremos.


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

Intentemos cambiar el fragmento de la página actual a otra cosa.


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

Iniciamos la aplicación y ... cambiamos sin problemas a una pantalla en blanco. Cual es la razon


Resulta que el contenedor de cada página es ViewPager , sin intermediarios como Page1Container , Page2Container . Por lo tanto, simplemente cambiar una página a otra no funciona, se reemplazará todo el pager .


Bueno, para cambiar el contenido de cada página individualmente, creamos varios fragmentos de contenedor para cada página.


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

Algo no comenzará de nuevo.


java.lang.IllegalStateException: no se puede cambiar la ID del contenedor del fragmento BigPictureFragment {...}: ahora era 2131165289 ahora 2131165290

No podemos adjuntar un fragmento de la segunda página ( BigPictureFragment ) a la primera, porque ya está adjunto al contenedor de la segunda página.


Después de apretar los dientes, agregamos más fragmentos dobles.


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

Ganado! El código de transición que una vez copié de las extensiones de GitHub ya contenía animaciones de aparición y desaparición gradual . Por lo tanto, antes de la transición, todos los elementos estáticos del primer fragmento desaparecieron, luego las imágenes se movieron y solo entonces aparecieron los elementos de la segunda página. Para el usuario, esto parece un movimiento real entre páginas.


Todas las animaciones han pasado, pero hay un problema. El usuario todavía está en la primera página, pero debería estar en la segunda.


Para solucionar esto, reemplazamos cuidadosamente la página de ViewPager visible ViewPager una segunda. Y luego restauramos el contenido de la primera página a su 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 ); } 

Cual es el resultado? (animación - 2.7 mb)

Código fuente completo
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
);
}
}
}

El proyecto se puede ver en GitHub .


Para resumir. El código comenzó a verse mucho más sólido: en lugar de 2 fragmentos iniciales, obtuve hasta 6, aparecieron instrucciones que controlan el rendimiento, reemplazando fragmentos en el momento adecuado. Y esto solo está en la demo.


En el mismo proyecto, uno tras otro en el código, las copias de seguridad comenzaron a aparecer en los lugares más inesperados. No permitieron que la aplicación se desmoronara cuando el usuario hizo clic en los botones de las páginas "incorrectas" o restringió el trabajo de fondo de fragmentos duplicados.


Resultó que el Android no tiene devoluciones de llamada para completar la transición , y su tiempo de ejecución es muy arbitrario y depende de muchos factores (por ejemplo, qué tan rápido se carga RecyclerView en el fragmento resultante). Esto llevó al hecho de que la sustitución de fragmentos en handler.postDelayed() menudo se ejecutaba demasiado pronto o demasiado tarde, lo que solo exacerbó el problema anterior.


El último aspecto destacado fue que durante la animación, el usuario podía simplemente pasar a otra página y ver dos pantallas gemelas, después de lo cual la aplicación también lo llevó a la pantalla deseada.


Artefactos interesantes de este enfoque (animación - 2.7 mb)

Este estado de cosas no me convenía, y yo, lleno de ira justa, comencé a buscar otra solución.


Cómo hacer correctamente la Transición de elementos compartidos en Viewpager


Probar PageTransformer


Todavía no había respuestas en Internet, y pensé: ¿de qué otra manera se puede acelerar esta transición? Algo en el subcortex de la conciencia me susurró: "Usa PageTransformer , Luke". La idea me pareció prometedora y decidí escucharla.


La idea es hacer PageTransformer , que, a diferencia de Android SET , no requerirá múltiples repeticiones de setTransitionName(transitionName) y FragmentTransaction.addSharedElement(sharedElement,name) en ambos lados de la transición. Moverá los elementos después del deslizamiento y tendrá una interfaz simple de la forma:


  public void addSharedTransition(int fromViewId, int toViewId) 

Procedemos al desarrollo. addSharedTransition(fromId, toId) los datos del addSharedTransition(fromId, toId) en Set from Pair y los obtendré en el método PageTransfomer


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

En el interior, revisaré todos los pares de View guardados, entre los cuales necesito hacer una animación. Y trataré de filtrarlos para que solo los elementos visibles estén animados.


Primero, verifiquemos si se han creado los elementos que necesitan ser animados. No somos exigentes, y si la View no se creó antes del comienzo de la animación, no dividiremos toda la animación (como la Transición del elemento compartido ), pero la recogeremos cuando se cree el elemento.


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

Encuentro las páginas entre las cuales ocurre el movimiento (cómo determinaré el número de página se describirá a continuación).


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

Si ambas páginas ya están creadas, entonces estoy buscando un par de View en ellas, que necesito animar.


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

En esta etapa, seleccionamos Ver, que se encuentra en las páginas entre las que se desplaza el usuario.


Es hora de obtener muchas variables. Calculo puntos de referencia:


 //         //     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; 

En el último fragmento, configuré slideToTheRight , y ya en esto me será útil. La translation inicio de sesión depende de ello, lo que determina que la View volará a su lugar o en algún lugar fuera de la pantalla.


 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, las fórmulas de desplazamiento para X e Y para ambas View , en la página de inicio y en la página resultante, resultaron ser las mismas, a pesar de las diferentes compensaciones iniciales.


Pero con la escala, desafortunadamente, este truco no funcionará, debe considerar si esta View punto de inicio o finalización de la animación.


Puede ser una sorpresa para alguien, pero transformPage(@NonNull View page, float position) se llama muchas veces: para cada página en caché (el tamaño de la memoria caché es personalizable). Y, para no volver a dibujar la View animada varias veces, para cada llamada a transformPage() , cambiamos solo los que están en la page actual.


Establecemos la posición y la escala de los 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); } 

Seleccionar páginas para dibujar animaciones


ViewPager no tiene prisa por compartir información entre las páginas que se desplazan. Como prometí, ahora te diré cómo obtenemos esta información. En nuestro PageTransformer implementamos otra interfaz ViewPager.OnPageChangeListener . Después de estudiar la salida de onPageScrolled() través de System.out.println() llegué a la siguiente 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(); } } 

Eso es todo Lo hicimos! La animación supervisa los gestos de los usuarios. ¿Por qué elegir entre deslizar y Transición de elementos compartidos , cuando puede dejar todo?


Mientras escribía este artículo, agregué el efecto de la desaparición de elementos estáticos: todavía es muy tosco, por lo que no se ha agregado a la biblioteca.


Vea lo que sucedió al final (animación - 2.4 mb)

Código fuente completo
/**
* 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;
}
}

¿Cómo se ve el trabajo con la biblioteca?


La configuración resultó bastante concisa.


La configuración completa para nuestro ejemplo se ve así
 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 , incluidas todas las transiciones, podría verse así:


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

Para que el código no desaparezca, puse la biblioteca en el repositorio de JCenter y en GitHub . Así que me puse en contacto con el mundo de código abierto. Puedes probarlo en tu proyecto simplemente agregando


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

Todas las fuentes están disponibles en GitHub


Conclusión


Incluso si Internet no conoce la respuesta, esto no significa que no lo sea. Busque soluciones alternativas, intente hasta que funcione. Quizás usted sea el primero en llegar al fondo de esto y compartirlo con la comunidad.

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


All Articles