Нет повести печальнее на свете,
чем повесть о ViewPager’e и SET’e



Хочется предупредить, что автор ? новичок андроид, поэтому статья содержит столько технических неточностей, что вас, скорее, нужно предупредить о том, что в статье могут встретиться технически достоверные утверждения.


Куда приводит бекенд


Всю жизнь я пилил бекенд. Начало 2019 года, за плечами уже один очень амбициозный, но недоделанный проект. Бесплодная поездка в Цюрих на собеседование в одну поисковую компанию. Зима, грязь, настроение никакое. Сил и желания тянуть проект дальше нету.


Хотелось навсегда забыть этот страшный бекенд. К счастью, судьба подкинула мне идею – это было мобильное приложение. Основной его фишкой должно было стать нестандартное использование камеры. Работа закипела. Прошло немного времени, и вот прототип готов. Релиз проекта близился и все было хорошо и стройно, пока я не решил сделать пользователю “удобно”.


ViewPager и Shared Element Transition. Ищем пути примирения


Никто не хочет нажимать на мелкие кнопки меню в 2019, все направо и налево хотят свайпать экраны. Сказано – сделано, сделано – сломано. Так на моем проекте появился первый ViewPager (я расшифровал некоторые термины для таких же бекендщиков как я – просто подведите курсор). А Shared Element Transition (далее SET или transition) – сигнатурный элемент Material Design, наотрез отказался работать c ViewPager, оставив меня перед выбором: либо свайпы, либо красивые transition-анимации между экранами. Я не хотел отказываться ни от того, ни от другого. Так начались мои поиски.


Часы изучения: десятки тем на форумах и вопросов на StackOverflow без ответа. Что бы я ни открывал, мне предлагали совершить transition из RecyclerView во ViewPager или “приложить подорожник Fragment.postponeEnterTransition()”.



Народные средства не помогли, и я решил заняться примирением ViewPager и Shared Element Transition самостоятельно.


ViewPager: Первая кровь


Я начал размышлять: “Проблема появляется в тот момент, когда пользователь переходит с одной страницы на другую...”. И тут меня осенило: “У тебя не будет проблем с SET во время смены страницы, если не менять страницы”.



Мы можем делать transition на одной и той же странице, а потом просто подменять в ViewPager текущую страницу на целевую.


Для начала создадим фрагменты, с которыми будем работать.


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

Попробуем поменять фрагмент в текущей странице на что-то еще.


FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

// берем View в котором лежит текущий фрагмент
int fragmentContId = previousFragment.getView().getParent().getId(); 

// и меняем содержимое этого контейнера на следующий фрагмент
fragmentTransaction.replace(fragmentContId, nextFragment); 
fragmentTransaction.commit();

Запускаем приложение и… плавно переходим на пустой экран. В чем причина?


Оказывается, контейнером каждой из страниц является сам ViewPager, без всяких посредников вида Page1Container, Page2Container. Поэтому просто так поменять одну страницу на другую не получится, заменится весь pager.


Хорошо, чтобы изменять контент каждой страницы в отдельности, создаем несколько фрагментов-контейнеров для каждой страницы.


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

Что-то опять не заводится.


java.lang.IllegalStateException: Can't change container ID of fragment BigPictureFragment{...}: was 2131165289 now 2131165290

Мы не можем прицепить фрагмент второй страницы (BigPictureFragment) к первой, потому что он уже прицеплен к контейнеру второй страницы.


Стиснув зубы добавляем еще фрагментов-дублеров.


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

Заработало! Код transition, который я когда-то скопировал с просторов GitHub, уже содержал анимации fade in и fade out. Поэтому перед transition все статические элементы с первого фрагмента исчезали, потом перемещались снимки, и только затем появлялись элементы второй страницы. Для пользователя это выглядит как настоящее перемещение между страницами.


Все анимации прошли, но есть одна проблема. Пользователь до сих пор на первой странице, а должен быть на второй.


Чтобы это исправить мы аккуратно подменяем видимую страницу ViewPager на вторую. А потом восстанавливаем содержимое первой страницы в начальное состояние.


    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
           );
       }

Что же получилось в итоге? (анимация ? 2.7 mb)

Исходный код целиком

Проект можно посмотреть на GitHub.


Подведем итоги. Код начал выглядеть гораздо солиднее: вместо 2 изначальных фрагментов у меня получилось аж 6, в нем появились инструкции, которые управляют спектаклем, подменяя фрагменты в нужное время. И это только в демо.


В настоящем же проекте в коде один за другим, начали появляться подпорки в самых неожиданных местах. Они не давали приложению развалиться, когда пользователь нажимал на кнопки с “неправильных” страниц или сдерживали фоновую работу дублирующих фрагментов.


Оказалось, что в андроиде нет коллбеков на завершение transition, а время его исполнения весьма произвольное и зависит от множества факторов (например, как быстро загрузится RecyclerView в результирующем фрагменте). Это приводило к тому, что подмена фрагментов в handler.postDelayed() часто исполнялась слишком рано или слишком поздно, что только усугубляло предыдущую проблему.


Последним гвоздем стало то, что во время анимации пользователь мог просто свайпнуть на другую страницу и наблюдать два экрана-близнеца, после чего приложение еще и одергивало его на нужный экран.


Интересные артефакты этого подхода (анимация ? 2.7 mb)

Такое положение дел меня не устроило и я, полный праведного гнева, начал поиски другого решения.


Как сделать Shared Element Transition в Viewpager правильно


Пробуем PageTransformer


В интернете ответов по-прежнему не было и я задумался: как же еще можно провернуть этот transition. Что-то на подкорке сознания шептало мне: “Используй PageTransformer, Люк”. Идея показалась мне многообещающей и я решил прислушаться.


Суть идеи в том, чтобы сделать PageTransformer, который, в отличии от Android SET, не будет требовать многократных повторений setTransitionName(transitionName) и FragmentTransaction.addSharedElement(sharedElement,name) с обеих сторон перехода. Будет перемещать элементы вслед за свайпом и иметь простой интерфейс вида:


    public void addSharedTransition(int fromViewId, int toViewId)

Приступим к разработке. Данные из метода addSharedTransition(fromId, toId) я сохраню в Set из Pair и достану их в методе PageTransfomer


/** 
Здесь можно двигать элементы интерфейса, основываясь на том, насколько пользователь сместил экран 
 */
public void transformPage(@NonNull View page, float position)

Внутри я пройду по всем сохраненным парам View, между которыми нужно сделать анимацию. И попытаюсь отфильтровать их, чтобы анимировались только видимые элементы.


Для начала проверим, успели ли создаться элементы, которые нужно анимировать. Мы не привередливые, и, если View не было создано до начала анимации, мы не будем ломать анимацию целиком (как Shared Element Transition), а подхватим ее, когда элемент будет создан.


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

Нахожу страницы, между которыми происходит перемещение (как я определяю номер страницы расскажу ниже).


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

Если обе страницы уже созданы, то я ищу на них пару View, которую нужно анимировать.


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

На данном этапе мы выбрали View, которые лежат на страницах, между которыми скролит пользователь.


Настало время завести много переменных. Вычисляю опорные точки:


// Вычисляем изначальную позицию каждого элемента пары на экране
// и разницу межу ними
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;

В прошлом сниппете я задал slideToTheRight, и уже в этом он мне пригодится. От него зависит знак в translation, который определит полетит View на свое место или куда-то за пределы экрана.


float pageWidth = getScreenWidth();
float sign = slideToTheRight ? 1 : -1;

float translationY = (deltaY + deltaHeight / 2) * sign * (-position);
float translationX = (deltaX + sign * pageWidth + deltaWidth / 2) * sign * (-position);

Интересно, что формулы смещения по X и Y у обоих View, на стартовой и результирующей странице получились одинаковыми, несмотря на разные изначальные смещения.


Но с масштабом, к сожалению, такой трюк не пройдет – нужно учитывать является ли данное View начальной или конечной точкой анимации.


Для кого-то может стать неожиданностью, но transformPage(@NonNull View page, float position) вызывается много раз: для каждой кэшированной страницы (размер кэша настраивается). И, чтобы не перерисовывать анимированное View по несколько раз, на каждый вызов transformPage(), мы изменяем только те, которые находятся на текущей page.


Выставляем положение и масштаб у анимируемых элементов
// Если начальное 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);
}

Выбираем страницы для рисования анимации


ViewPager не спешит делиться сведениями между какими страницами идет скроллинг. Как я и обещал, сейчас расскажу как мы получаем эту информацию. В нашем PageTransformer мы реализуем еще один интерфейс ViewPager.OnPageChangeListener. Поизучав выход onPageScrolled() через System.out.println() я пришел к следующей формуле:


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();
   }
}

Вот и все. Мы сделали это! Анимация следит за жестами пользователя. Зачем выбирать между свайпами и Shared Element Transition, когда можно оставить все.


Во время написания статьи я добавил эффект исчезновения статичных элементов — он пока очень сырой, поэтому в библиотеку не добавлен.


Посмотреть, что же получилось в итоге (анимация ? 2.4 mb)

Исходный код целиком

Как выглядит работа с библиотекой


Конфигурация получилась довольно лаконичной.


Полная настройка для нашего примера выглядит так
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, включающий все transition, может выглядеть так:


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

Чтобы код не пропадал, я поместил библиотеку в репозиторий JCenter и на GitHub. Так я и соприкоснулся с миром opensource. Попробовать ее на своем проекте вы можете, просто добавив


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

Все исходники доступны на GitHub


Заключение


Даже если интернет не знает ответа, это не значит, что его нет. Ищите обходные пути, пробуйте пока не получится. Возможно, вы станете первым, кто докопается до сути и поделится этим с сообществом.

Комментарии (1)


  1. ZZa
    10.05.2019 09:24

    Разбалованные мобайл и жабаскрипт сообщества привыкли к тому, что всё, что только можно себе представить, уже кем-то решено и написано. А взять гораздо менее попсовые технологии, и проблемы, описанные автором, уже возникают на каждом шагу. И хорошо, если у технологии есть хотя бы хорошо написанная документация, и можно обратиться хотя бы к ней, чтобы понять в каком направлении «допиливать» проект оптимальнее. А ведь часто бывает так, что и документация оставляет желать лучшего. Так что я с автором полностью согласен — на Stack Overflow надейся, но и сам не плошай.