我如何应对共享元素转换并编写了我的第一个开源库

世界上没有悲伤的故事
ViewPager'eSET'e的故事



我想警告作者是一个新手android,所以本文包含了许多技术上的不准确之处,您应该警告您,技术上可靠的声明可能会出现在文章中。


后端在哪里


我一生都看到了后端。 2019年初,已经有一个非常雄心勃勃但尚未完成的项目。 一次前往苏黎世的无结果旅行,采访了一家搜索公司。 冬天,泥土,没有心情。 没有力量和渴望进一步推动该项目。


我想永远忘记这个可怕的后端。 幸运的是,命运给了我一个主意-那是一个移动应用程序。 它的主要功能是非标准使用相机。 工作开始沸腾了。 花了一些时间,现在原型已经准备就绪。 该项目即将发布,一切都很好,结构合理,直到我决定让用户“ 方便 ”为止。


ViewPager和共享元素过渡。 我们正在寻找和解


没有人想要在2019年单击小菜单按钮,他们想左右滑动屏幕。 据说-做,做-坏了。 因此,第一个ViewPager出现在我的项目中(我为与我相同的后端解密了一些术语-只需移动光标即可)。 共享元素过渡 (以下称为SET或过渡) ViewPager Design的签名元素,断然拒绝与ViewPager配合ViewPager ,这让我可以选择:在屏幕之间滑动或漂亮的过渡动画。 我不想拒绝任何一个。 于是我的搜索开始了。


学习时间:论坛上的数十个主题和StackOverflow上的未解答问题。 无论我打开什么,都可以RecyclerView过渡到ViewPager或“附加芭蕉Fragment.postponeEnterTransition() ”。



民间补救措施无济于事,所以我决定自己调和ViewPagerShared Element Transition


ViewPager:第一滴血


我开始反思:“问题出现在用户从一页转到另一页的那一刻……”。 然后我想到:“如果不更改页面,您在设置页面时不会遇到SET问题。”



我们可以在同一页面上进行转换 ,然后在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本身,没有诸如Page1ContainerPage2Container类的任何中介。 因此,仅将一页更改为另一页不起作用,将替换整个pager


好吧,要单独更改每个页面的内容,我们为每个页面创建几个容器片段。


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

某些事情将不会再次开始。


java.lang.IllegalStateException:无法更改片段BigPictureFragment {...}的容器ID:现在是2131165289,现在是2131165290

我们无法将第二页的片段( BigPictureFragment )附加到第一页,因为它已经附加到第二页的容器中。


咬紧牙齿后,我们添加了更多的双碎片。


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

赚了! 我曾经从GitHub复制的过渡代码已经包含淡入淡出动画。 因此,在过渡之前第一个片段中的所有静态元素都消失了,然后图片移动了,然后才出现第二页的元素。 对用户而言,这看起来像页面之间的真实移动。


所有动画均已通过,但是存在一个问题。 用户仍在第一页上,但应该在第二页上。


为了解决这个问题,我们小心地将可见的ViewPager页面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)

整个源代码
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
);
}
}
}

可以在GitHub上查看该项目。


总结一下。 该代码开始看起来更加可靠:我得到的不是原来的2个片段,而是6个,其中出现了控制性能的指令,并在适当的时候替换了片段。 而这仅在演示中。


在同一项目中,在代码中一个接一个地备份,备份开始出现在最意外的位置。 当用户单击“错误”页面上的按钮或限制重复片段的后台工作时,它们不允许应用程序崩溃。


事实证明,android没有回调来完成过渡 ,并且它的执行时间是非常任意的,并且取决于许多因素(例如, RecyclerView在生成的片段中加载的速度如何)。 这导致了这样一个事实,在handler.postDelayed()对片段的替换通常执行得太早或太晚,这只会加剧先前的问题。


最后一个亮点是,在动画过程中,用户可以简单地滑动到另一页并观看两个双屏,然后应用程序还将其拉到所需的屏幕上。


这种方法有趣的工件(动画-2.7 mb)

这种状况不适合我,我充满义愤填began,开始寻求另一种解决方案。


如何在Viewpager中正确进行共享元素转换


尝试PageTransformer


互联网上仍然没有答案,我想:如何才能进一步推动这一转变。 意识皮层中的某事对我低声说:“使用PageTransformer ,Luke。” 这个想法对我来说似乎很有希望,我决定听一听。


想法是制作PageTransformer ,它与Android SET不同,在过渡的两边都不需要多次重复setTransitionName(transitionName)FragmentTransaction.addSharedElement(sharedElement,name) 。 滑动后它将移动元素,并具有以下形式的简单界面:


  public void addSharedTransition(int fromViewId, int toViewId) 

我们着手发展。 我将把addSharedTransition(fromId, toId)方法中的数据保存为“从Pair Set ,并在addSharedTransition(fromId, toId)方法中获取它


 /**     ,   ,     */ 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); 

在此阶段,我们选择了“视图”,它位于用户在其间滚动的页面上。


现在该获取很多变量了。 我计算参考点:


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

有趣的是,尽管初始偏移不同,但起始页面和结果页面上View XY的偏移公式却是相同的。


但是不幸的是,使用缩放比例时,此技巧将不起作用-您需要考虑此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不急于在正在滚动的页面之间共享信息。 如我所言,现在我将告诉您我们如何获得此信息。 在我们的ViewPager.OnPageChangeListener PageTransformer我们实现了另一个ViewPager.OnPageChangeListener接口。 通过System.out.println()研究了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(); } } 

仅此而已。 我们做到了! 动画监视用户手势。 当您可以保留所有内容时,为什么要在滑动和共享元素过渡之间进行选择。


在撰写本文时,我添加了静态元素消失的效果-它仍然非常粗糙,因此尚未添加到库中。


看看最终发生了什么(动画-2.4 mb)

整个源代码
/**
* 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;
}
}

库的工作原理如下


该配置非常简洁。


我们的示例的完整设置如下所示
 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 (包括所有转换)可能看起来像这样:


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

为了使代码不会消失,我将库放在JCenter存储库和GitHub上 。 因此,我与开源世界取得了联系。 您只需添加即可在项目中进行尝试


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

所有资源都可以在GitHub找到


结论


即使Internet不知道答案,也不意味着不是。 寻找解决方法,尝试直到解决。 也许您将是第一个深入了解并与社区分享的人。

Source: https://habr.com/ru/post/zh-CN451116/


All Articles