Il n'y a pas d'histoire plus triste au monde
que l'histoire de ViewPager'e et SET'e
Je voudrais avertir que l'auteur est un androïde débutant, donc l'article contient tellement d'inexactitudes techniques que vous devriez plutôt être averti que des déclarations techniquement fiables peuvent apparaître dans l'article.
Où mène le backend
Toute ma vie, j'ai vu le backend. Début 2019, il y a déjà un projet très ambitieux mais inachevé. Un voyage infructueux à Zurich pour un entretien avec une société de recherche. Hiver, saleté, pas d'humeur. Il n'y a aucune force et aucun désir de pousser le projet plus loin.
Je voulais oublier pour toujours ce terrible backend. Heureusement, le destin m'a donné une idée - c'était une application mobile. Sa principale caractéristique devait être l'utilisation non standard de la caméra. Le travail a commencé à bouillir. Cela a pris un peu de temps, et maintenant le prototype est prêt. La sortie du projet approchait et tout allait bien et était bien proportionné, jusqu'à ce que je décide de rendre l'utilisateur « pratique ».
Personne ne veut cliquer sur les petits boutons de menu en 2019, à droite et à gauche ils veulent faire glisser les écrans. Il est dit - fait, fait - cassé. Le premier ViewPager
est ViewPager
apparu sur mon projet (j'ai déchiffré certains termes pour le même backend que moi - il suffit de déplacer le curseur). Et la transition de l'élément partagé (ci-après SET ou transition) - l'élément de signature de Material Design , a catégoriquement refusé de travailler avec ViewPager
, me laissant un choix: soit des balayages, soit de belles animations de transition entre les écrans. Je ne voulais refuser ni l'un ni l'autre. Alors ma recherche a commencé.
Heures d'étude: des dizaines de sujets sur les forums et des questions sans réponse sur StackOverflow. Peu importe ce que j'ouvre, on m'a proposé de faire une transition de RecyclerView à ViewPager ou de «joindre un plantain Fragment.postponeEnterTransition()
».
Les remèdes populaires n'ont pas aidé, et j'ai décidé de réconcilier ViewPager
et la Shared Element Transition
moi-même.
J'ai commencé à réfléchir: "Le problème apparaît au moment où l'utilisateur passe d'une page à l'autre ...". Et puis il m'est apparu: "Vous n'aurez pas de problèmes avec SET pendant le changement de page, si vous ne changez pas la page".
Nous pouvons effectuer la transition sur la même page, puis remplacer simplement la page actuelle par la page cible dans le ViewPager
.
Créez d'abord des fragments avec lesquels nous allons travailler.
SmallPictureFragment small_picture_fragment = new SmallPictureFragment(); BigPictureFragment big_picture_fragment = new BigPictureFragment();
Essayons de changer le fragment de la page actuelle pour autre chose.
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
Nous lançons l'application et ... passons en douceur à un écran vierge. Quelle en est la raison?
Il s'avère que le conteneur de chaque page est ViewPager
lui-même, sans aucun intermédiaire comme Page1Container
, Page2Container
. Par conséquent, le fait de changer une page en une autre ne fonctionne pas, tout le pager
sera remplacé.
Eh bien, pour changer le contenu de chaque page individuellement, nous créons plusieurs fragments de conteneur pour chaque page.
RootSmallPictureFragment root_small_pic_fragment = new RootSmallPictureFragment(); RootBigPictureFragment root_big_pic_fragment = new RootBigPictureFragment();
Quelque chose ne recommencera pas.
java.lang.IllegalStateException: impossible de modifier l'ID de conteneur du fragment BigPictureFragment {...}: était 2131165289 maintenant 2131165290
Nous ne pouvons pas attacher un fragment de la deuxième page ( BigPictureFragment
) à la première, car il est déjà attaché au conteneur de la deuxième page.
Après avoir serré les dents, nous ajoutons plus de doubles fragments.
SmallPictureFragment small_picture_fragment_fake = new SmallPictureFragment(); BigPictureFragment big_picture_fragment_fake = new BigPictureFragment();
Gagné! Le code de transition que j'avais copié à partir des extensions GitHub contenait déjà des animations de fondu d'entrée et de sortie . Par conséquent, avant la transition, tous les éléments statiques du premier fragment ont disparu, puis les images ont bougé, et ce n'est qu'alors que les éléments de la deuxième page sont apparus. Pour l'utilisateur, cela ressemble à un véritable mouvement entre les pages.
Toutes les animations sont passées, mais il y a un problème. L'utilisateur est toujours sur la première page, mais devrait être sur la seconde.
Pour résoudre ce problème, nous remplaçons soigneusement la page ViewPager
visible ViewPager
une seconde. Et puis nous restaurons le contenu de la première page à son état initial.
handler.postDelayed( () -> {
Quel est le résultat? (animation - 2,7 Mo) Code source complet | 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 |
| ); |
| |
| |
| } |
| } |
| |
| } |
Le projet peut être consulté sur GitHub .
Pour résumer. Le code a commencé à paraître beaucoup plus solide: au lieu de 2 fragments initiaux, j'en ai reçu jusqu'à 6, il y a apparu des instructions qui contrôlent les performances, remplaçant les fragments au bon moment. Et ce n'est que dans la démo.
Dans le même projet, l'une après l'autre dans le code, les sauvegardes ont commencé à apparaître dans les endroits les plus inattendus. Ils n'ont pas permis à l'application de s'effondrer lorsque l'utilisateur a cliqué sur les boutons des «mauvaises» pages ou contraint le travail d'arrière-plan des fragments en double.
Il s'est avéré que l'androïde n'a pas de rappels pour terminer la transition , et son temps d'exécution est très arbitraire et dépend de nombreux facteurs (par exemple, la vitesse à laquelle RecyclerView
charge dans le fragment résultant). Cela a conduit au fait que la substitution de fragments dans handler.postDelayed()
souvent handler.postDelayed()
exécutée trop tôt ou trop tard, ce qui n'a fait qu'exacerber le problème précédent.
Le dernier point fort était que pendant l'animation, l'utilisateur pouvait simplement glisser sur une autre page et regarder deux écrans jumeaux, après quoi l'application l'a également tirée sur l'écran souhaité.
Artefacts intéressants de cette approche (animation - 2,7 Mo) Cet état de fait ne me convenait pas, et moi, plein de colère juste, j'ai commencé à chercher une autre solution.
Essayer PageTransformer
Il n'y avait toujours pas de réponse sur Internet, et je me suis demandé: comment cette transition pourrait-elle être accélérée autrement? Quelque chose dans le sous-cortex de la conscience m'a chuchoté: "Utilisez PageTransformer
, Luke." L'idée me semblait prometteuse et j'ai décidé d'écouter.
L'idée est de créer PageTransformer
, qui, contrairement à Android SET , ne nécessitera pas plusieurs répétitions de setTransitionName(transitionName)
et FragmentTransaction.addSharedElement(sharedElement,name)
deux côtés de la transition. Il déplacera les éléments après le glissement et aura une interface simple du formulaire:
public void addSharedTransition(int fromViewId, int toViewId)
Nous procédons au développement. Je vais enregistrer les données de la addSharedTransition(fromId, toId)
dans Set
from Pair
et les récupérer dans la méthode PageTransfomer
public void transformPage(@NonNull View page, float position)
À l'intérieur, je vais parcourir toutes les paires de View
enregistrées, entre lesquelles j'ai besoin de faire une animation. Et je vais essayer de les filtrer pour que seuls les éléments visibles soient animés.
Tout d'abord, vérifions si les éléments à animer ont été créés. Nous ne sommes pas pointilleux, et si la View
pas View
créée avant le début de l'animation, nous ne casserons pas l'intégralité de l'animation (comme la transition d'élément partagé ), mais nous la reprendrons lorsque l'élément sera créé.
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) {
Je trouve les pages entre lesquelles le mouvement se produit (comment je détermine le numéro de page sera décrit ci-dessous).
View fromPage = pages.get(fromPageNumber); View toPage = pages.get(toPageNumber);
Si les deux pages sont déjà créées, je recherche une paire de View
sur elles, que je dois animer.
If (fromPage != null && toPage != null) { fromView = fromPage.findViewById(fromViewId); toView = toPage.findViewById(toViewId);
À ce stade, nous avons sélectionné Afficher, qui se trouvent sur les pages entre lesquelles l'utilisateur fait défiler.
Il est temps d'obtenir beaucoup de variables. Je calcule des points de référence:
Dans le dernier extrait, j'ai défini slideToTheRight
, et déjà dans ce document, il me sera utile. Le signe dans la translation
dépend, ce qui détermine que la View
volera à sa place ou quelque part hors de l'écran.
float pageWidth = getScreenWidth(); float sign = slideToTheRight ? 1 : -1; float translationY = (deltaY + deltaHeight / 2) * sign * (-position); float translationX = (deltaX + sign * pageWidth + deltaWidth / 2) * sign * (-position);
Fait intéressant, les formules de décalage pour X
et Y
pour la View
, sur la page de démarrage et la page résultante, se sont avérées être les mêmes, malgré les différents décalages initiaux.
Mais avec l'échelle, malheureusement, cette astuce ne fonctionnera pas - vous devez déterminer si cette View
point de départ ou de fin de l'animation.
Cela peut surprendre quelqu'un, mais transformPage(@NonNull View page, float position)
est appelé plusieurs fois: pour chaque page mise en cache (la taille du cache est personnalisable). Et, afin de ne pas redessiner la View
animée plusieurs fois, pour chaque appel à transformPage()
, nous modifions uniquement ceux qui se trouvent sur la page
courante.
Nous définissons la position et l'échelle des éléments animés Sélectionnez les pages pour dessiner des animations
ViewPager
pas pressé de partager des informations entre les pages qui défilent. Comme je l’ai promis, je vais maintenant vous expliquer comment nous obtenons ces informations. Dans notre PageTransformer
nous implémentons encore une autre interface ViewPager.OnPageChangeListener
. Après avoir étudié la sortie de onPageScrolled()
via System.out.println()
je suis arrivé à la formule suivante:
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) {
C’est tout. Nous l'avons fait! L'animation surveille les gestes des utilisateurs. Pourquoi choisir entre glisser et transition d'élément partagé , quand vous pouvez tout quitter.
En écrivant cet article, j'ai ajouté l'effet de la disparition des éléments statiques - il est encore très grossier, donc il n'a pas été ajouté à la bibliothèque.
Voyez ce qui s'est passé à la fin (animation - 2,4 Mo) Code source complet | /** |
| * 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; |
| } |
| } |
À quoi ressemble la bibliothèque
La configuration s'est avérée assez concise.
La configuration complète de notre exemple ressemble à ceci 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
, y compris toutes les transitions, pourrait ressembler à ceci:
smallCatImageView.setOnClickListener( v -> activity.viewPager.setCurrentItem(2) );
Pour que le code ne disparaisse pas, j'ai mis la bibliothèque dans le référentiel JCenter et sur GitHub . J'ai donc pris contact avec le monde open source. Vous pouvez l'essayer sur votre projet en ajoutant simplement
dependencies {
Toutes les sources sont disponibles sur GitHub
Conclusion
Même si Internet ne connaît pas la réponse, cela ne signifie pas que ce n'est pas le cas. Recherchez des solutions de contournement, essayez jusqu'à ce que cela fonctionne. Peut-être serez-vous le premier à aller au fond des choses et à les partager avec la communauté.