Hello! I really like working with animations - in every Android application, in the creation of which I participate or which I just look at, I would find a place for a couple. In the not so distant April 2016, with my record about the type of classes, Animation started a Live Typing company blog, and later I gave a talk about animations at the next Omsk IT subbotnik. In this article I want to introduce you to our library CannyViewAnimator, as well as immerse you in the process of its development. It is needed for a beautiful visibility switch View. If you are interested in the library, or the history of its creation, or at least the problems that I encountered and their solutions are interesting, then welcome to the article!
But first, for clarity, we present a situation that is trivial in Android development. You have a screen, and on it - a list that comes from the server. While beautiful data is being loaded from a beautiful server, you are showing a loader; as soon as the data has arrived, you look at them: if it is empty, you show a stub, if not, you are actually showing the data.
How to resolve this situation on the UI? Previously, we used the following solution in Live Taiping, which we had once seen in U2020 , and then transferred to our U2020 MVP - this is BetterViewAnimator , View, which inherits from ViewAnimator. The only, but important, difference between BetterViewAnimator and its ancestor is the ability to work with id resources. But he is not perfect.
ViewAnimator is a View that inherits from FrameLayout and which only one of its child is visible at a particular time. To switch the visible child there is a set of methods.
An important disadvantage of BetterViewAnimator is the ability to work only with outdated AnimationFramework. And in this situation CannyViewAnimator comes to the rescue. It supports work with Animator and AppCompat Transition.
Link to the project in Github
During the development of the next screen “list-loader-stub”, I thought that we, of course, use BetterViewAnimator, but for some reason do not use it almost as the main feature - animations. Optimistic, I decided to add animation and stumbled upon what I forgot: ViewAnimator can only work with Animation. Unfortunately, the search for alternatives on Github did not succeed - there were no decent ones, but there was only the Android View Controller , but it is absolutely not flexible and only supports eight pre-defined animations in it. This meant only one thing: you have to write everything yourself.
The first thing I decided to do is think through what I want to end up with:
Having defined my desires, I began to think over the “architecture” of the future project. It turned out three parts:
I decided to set up Animators and Transition using an interface with two parameters: child, which will appear, and child, which will disappear. Every time when the visible child changes, the necessary animation will be taken from the interface implementation. Interface will be three:
I decided to make the interface for Transition alone, since the Transition is superimposed immediately on all appearing and disappearing child. The concept was thought out, and I began to develop.
With my base class, I didn’t think too much and decided to make a carbon copy with the ViewAnimator from the SDK. I just threw out of it work with Animation and optimized the methods in it, since many of them seemed redundant to me. Also, I did not forget to add methods from BetterViewAnimator. The final list of important methods for working with him was:
A little thought, I decided to additionally save the position of the current visible child in onSaveInstanceState () and restore it to onRestoreInstanceState (Parcelable state), immediately displaying it.
The final code is:
public class ViewAnimator extends FrameLayout { private int lastWhichIndex = 0; public ViewAnimator(Context context) { super(context); } public ViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } public void setDisplayedChildIndex(int inChildIndex) { if (inChildIndex >= getChildCount()) { inChildIndex = 0; } else if (inChildIndex < 0) { inChildIndex = getChildCount() - 1; } boolean hasFocus = getFocusedChild() != null; int outChildIndex = lastWhichIndex; lastWhichIndex = inChildIndex; changeVisibility(getChildAt(inChildIndex), getChildAt(outChildIndex)); if (hasFocus) { requestFocus(FOCUS_FORWARD); } } public void setDisplayedChildId(@IdRes int id) { if (getDisplayedChildId() == id) { return; } for (int i = 0, count = getChildCount(); i < count; i++) { if (getChildAt(i).getId() == id) { setDisplayedChildIndex(i); return; } } throw new IllegalArgumentException("No view with ID " + id); } public void setDisplayedChild(View view) { setDisplayedChildId(view.getId()); } public int getDisplayedChildIndex() { return lastWhichIndex; } public View getDisplayedChild() { return getChildAt(lastWhichIndex); } public int getDisplayedChildId() { return getDisplayedChild().getId(); } protected void changeVisibility(View inChild, View outChild) { outChild.setVisibility(INVISIBLE); inChild.setVisibility(VISIBLE); } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { super.addView(child, index, params); if (getChildCount() == 1) { child.setVisibility(View.VISIBLE); } else { child.setVisibility(View.INVISIBLE); } if (index >= 0 && lastWhichIndex >= index) { setDisplayedChildIndex(lastWhichIndex + 1); } } @Override public void removeAllViews() { super.removeAllViews(); lastWhichIndex = 0; } @Override public void removeView(View view) { final int index = indexOfChild(view); if (index >= 0) { removeViewAt(index); } } @Override public void removeViewAt(int index) { super.removeViewAt(index); final int childCount = getChildCount(); if (childCount == 0) { lastWhichIndex = 0; } else if (lastWhichIndex >= childCount) { setDisplayedChildIndex(childCount - 1); } else if (lastWhichIndex == index) { setDisplayedChildIndex(lastWhichIndex); } } @Override public void removeViewInLayout(View view) { removeView(view); } @Override public void removeViews(int start, int count) { super.removeViews(start, count); if (getChildCount() == 0) { lastWhichIndex = 0; } else if (lastWhichIndex >= start && lastWhichIndex < start + count) { setDisplayedChildIndex(lastWhichIndex); } } @Override public void removeViewsInLayout(int start, int count) { removeViews(start, count); } @Override protected void onRestoreInstanceState(Parcelable state) { if (!(state instanceof SavedState)) { super.onRestoreInstanceState(state); return; } SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); lastWhichIndex = ss.lastWhichIndex; setDisplayedChildIndex(lastWhichIndex); } @Override protected Parcelable onSaveInstanceState() { SavedState savedState = new SavedState(super.onSaveInstanceState()); savedState.lastWhichIndex = lastWhichIndex; return savedState; } public static class SavedState extends View.BaseSavedState { int lastWhichIndex; SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(this.lastWhichIndex); } @Override public String toString() { return "ViewAnimator.SavedState{" + "lastWhichIndex=" + lastWhichIndex + '}'; } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel source) { return new SavedState(source); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; protected SavedState(Parcel in) { super(in); this.lastWhichIndex = in.readInt(); } } }
Having finished with ViewAnimator, I proceeded to a rather simple, but no less interesting task: to make support for Transition. The essence of the work is this: when you call the overridden changeVisibility method (View inChild, View outChild), an animation is prepared. From the specified CannyTransition using the interface, Transition is taken and recorded in the class field.
public interface CannyTransition { Transition getTransition(View inChild, View outChild); }
Then in a separate method runs this Transition. I decided to make the launch a separate method with a foundation for the future - the fact is that Transition is launched using the TransitionManager.beginDelayedTransition method, and this imposes some restrictions. After all, Transition will be executed only for those View, which have changed their properties for a certain period of time after calling TransitionManager.beginDelayedTransition . Since in the future we plan to introduce Animators, which can last for a relatively long time, the TransitionManager.beginDelayedTransition should be called immediately before changing Visibility. Well, and then I call super.changeVisibility (inChild, outChild); which changes the visibility of the desired child.
public class TransitionViewAnimator extends ViewAnimator { private CannyTransition cannyTransition; private Transition transition; public TransitionViewAnimator(Context context) { super(context); } public TransitionViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void changeVisibility(View inChild, View outChild) { prepareTransition(inChild, outChild); startTransition(); super.changeVisibility(inChild, outChild); } protected void prepareTransition(View inChild, View outChild) { if (cannyTransition != null) { transition = cannyTransition.getTransition(inChild, outChild); } } public void startTransition() { if (transition != null) { TransitionManager.beginDelayedTransition(this, transition); } } public void setCannyTransition(CannyTransition cannyTransition) { this.cannyTransition = cannyTransition; } }
So I got to the main layer. Initially, I wanted to use LayoutTransition to control Animators, but my dreams were broken about the impossibility of using animations in parallel without crutches. Also, additional problems created the remaining drawbacks of LayoutTransition, such as the need to set the duration for AnimatorSet, the impossibility of manual interruption, etc. It was decided to write its own logic of work. Everything looked very simple: we launch Animator for the disappearing child, expose it to Visibility.GONE for it and immediately make the child appear visible and launch Animator for it.
Here I stumbled upon the first problem: you can’t start Animator for a non-attached View (this is one that hasn’t yet performed onAttach or has already started onDetach ). This did not allow me to change the visibility of any child in the constructor or any other method that is triggered before onAttach. Anticipating a bunch of different situations where it might be needed, and at least a small bunch of issues on Github, I decided to try to rectify the situation. Unfortunately, the simplest solution in the form of a call to the isAttachedToWindow () method rested on the impossibility of calling it up to version 19 of the API, and I really wanted to have support with 14 APIs.
However, View has an OnAttachStateChangeListener, and I did not fail to use it. I redefined the void addView method (View child, int index, ViewGroup.LayoutParams params) and hung up this Listener for each added View. Next, I placed in HashMap a link to the View itself and a Boolean variable indicating its state. If onViewAttachedToWindow (View v) worked, I set the value to true, and if onViewDetachedFromWindow (View v) , then false. Now, just before the launch of Animator, I could check the status of the View and could decide whether to run Animator at all.
After overcoming the first “barricade”, I made two interfaces for receiving Animators: InAnimator and OutAnimator.
public interface InAnimator { Animator getInAnimator(View inChild, View outChild); }
public interface OutAnimator { Animator getOutAnimator(View inChild, View outChild); }
Everything went smoothly until a new problem arose in front of me: after doing Animator, I need to restore the View state .
I did not find the answer to StackOverflow. After half an hour of brainstorming, I decided to use the ValueAnimator's reverse method, making its duration zero.
if (animator instanceof ValueAnimator) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animation.removeListener(this); animation.setDuration(0); ((ValueAnimator) animation).reverse(); } }); }
This helped, and I even gave the same answer to StackOverflow .
Immediately after solving this problem, another one arose: CircularRevealAnimator does not perform its animation, if View has not yet performed onMeasure.
This was bad news, because ViewAnimator has an invisible child Visibility.GONE. This means that they are not measured until the moment when they are given a different type of Visibility - VISIBLE or INVISIBLE. Even if I had changed Visibility to INVISIBLE before starting the animation, it would not solve the problem. Since the measurement of the dimensions of the View occurs when the frame is drawn, and the frame is drawn asynchronously, there is no guarantee that by the time Animator View was started, it would have been measured. I was extremely reluctant to set the delay or use onPreDrawListener, so by default I decided to use Visibility.INVISIBLE instead of Visibility.GONE.
My head was scrolling horror scenes based on how my views are measured at inflate (although they don’t need it at all), which is accompanied by visual lags. Therefore, I decided to conduct a small test, measuring the time of the inflate, with Visibility.INVISIBLE and Visibility.GONE with 10 View and nesting 5. The tests showed that the difference did not exceed 1 millisecond. Either I did not notice how the phones became much more powerful, or Android was so well optimized, but I vaguely recall that once the extra Visibility.INVISIBLE had a bad effect on performance. Well, okay, the problem was defeated.
Not having time to recover from the previous "fight", I rushed to the next. Since in the FrameLayout child lie on top of each other, when simultaneously performing InAnimator and OutAnimator, a situation arises when, depending on the child index, the animation looks different.
Because of all the problems that have arisen with the implementation of Animators, I wanted to quit them, but the feeling “once started - finish” made me move forward. The problem occurs when I try to make the View visible, which lies below the currently displayed View. Because of this, the disappearance animation completely overlaps the appearance animation and vice versa. In search of a solution, I tried to use other ViewGroups, played with the Z property, and tried a whole lot more.
Finally, the idea came at the beginning of the animation just to delete the desired View from the container, add it to the top, and at the end of the animation again, delete and then return to the original place. The idea worked, but on weak devices the animation was jabbed. The hangup was due to the fact that when you delete or add a View, it itself and its parent call requestLayout () , which recounts and redraws them. I had to climb into the jungle of the ViewGroup class. After a few minutes of studying, I came to the conclusion that the order of the View inside the ViewGroup depends only on one array, and then the heirs of the ViewGroup (for example, FrameLayout or LinearLayout) already decide how to display it. Alas, the array, as well as methods of working with it, were marked private. But there was some good news: in Java, this is not a problem, since there is a Java Reflection. Using Java Reflection, I used the methods of working with an array and now I could control the position of the View I needed directly. The result is this method:
public void bringChildToPosition(View child, int position) { final int index = indexOfChild(child); if (index < 0 && position >= getChildCount()) return; try { Method removeFromArray = ViewGroup.class.getDeclaredMethod("removeFromArray", int.class); removeFromArray.setAccessible(true); removeFromArray.invoke(this, index); Method addInArray = ViewGroup.class.getDeclaredMethod("addInArray", View.class, int.class); addInArray.setAccessible(true); addInArray.invoke(this, child, position); Field mParent = View.class.getDeclaredField("mParent"); mParent.setAccessible(true); mParent.set(child, this); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (NoSuchFieldException e) { e.printStackTrace(); } }
This method sets the position I need for the View. Redrawing at the end of these manipulations is not necessary - for you it will make animation. Now, before starting the animation, I could put the View I needed up, and return it at the end of the animation. So, the main part of the story about CannyViewAnimator is over.
public class CannyViewAnimator extends TransitionViewAnimator { public static final int SEQUENTIALLY = 1; public static final int TOGETHER = 2; private int animateType = SEQUENTIALLY; @Retention(RetentionPolicy.SOURCE) @IntDef({SEQUENTIALLY, TOGETHER}) @interface AnimateType { } public static final int FOR_POSITION = 1; public static final int IN_ALWAYS_TOP = 2; public static final int OUT_ALWAYS_TOP = 3; private int locationType = FOR_POSITION; @Retention(RetentionPolicy.SOURCE) @IntDef({FOR_POSITION, IN_ALWAYS_TOP, OUT_ALWAYS_TOP}) @interface LocationType { } private List<? extends InAnimator> inAnimator; private List<? extends OutAnimator> outAnimator; private final Map<View, Boolean> attachedList = new HashMap<>(getChildCount()); public CannyViewAnimator(Context context) { super(context); } public CannyViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } @SafeVarargs public final <T extends InAnimator> void setInAnimator(T... inAnimators) { setInAnimator(Arrays.asList(inAnimators)); } public void setInAnimator(List<? extends InAnimator> inAnimators) { this.inAnimator = inAnimators; } @SafeVarargs public final <T extends OutAnimator> void setOutAnimator(T... outAnimators) { setOutAnimator(Arrays.asList(outAnimators)); } public void setOutAnimator(List<? extends OutAnimator> outAnimators) { this.outAnimator = outAnimators; } @Override protected void changeVisibility(View inChild, View outChild) { if (attachedList.get(outChild) && attachedList.get(inChild)) { AnimatorSet animatorSet = new AnimatorSet(); Animator inAnimator = mergeInAnimators(inChild, outChild); Animator outAnimator = mergeOutAnimators(inChild, outChild); prepareTransition(inChild, outChild); switch (animateType) { case SEQUENTIALLY: animatorSet.playSequentially(outAnimator, inAnimator); break; case TOGETHER: animatorSet.playTogether(outAnimator, inAnimator); break; } switch (locationType) { case FOR_POSITION: addOnStartVisibleListener(inAnimator, inChild); addOnEndInvisibleListener(outAnimator, outChild); break; case IN_ALWAYS_TOP: addOnStartVisibleListener(inAnimator, inChild); addOnEndInvisibleListener(inAnimator, outChild); addOnStartToTopOnEndToInitPositionListener(inAnimator, inChild); break; case OUT_ALWAYS_TOP: addOnStartVisibleListener(outAnimator, inChild); addOnEndInvisibleListener(outAnimator, outChild); addOnStartToTopOnEndToInitPositionListener(outAnimator, outChild); break; } animatorSet.start(); } else { super.changeVisibility(inChild, outChild); } } private AnimatorSet mergeInAnimators(final View inChild, final View outChild) { AnimatorSet animatorSet = new AnimatorSet(); List<Animator> animators = new ArrayList<>(inAnimator.size()); for (InAnimator inAnimator : this.inAnimator) { if (inAnimator != null) { Animator animator = inAnimator.getInAnimator(inChild, outChild); if (animator != null) { animators.add(animator); } } } animatorSet.playTogether(animators); return animatorSet; } private AnimatorSet mergeOutAnimators(final View inChild, final View outChild) { AnimatorSet animatorSet = new AnimatorSet(); List<Animator> animators = new ArrayList<>(outAnimator.size()); for (OutAnimator outAnimator : this.outAnimator) { if (outAnimator != null) { Animator animator = outAnimator.getOutAnimator(inChild, outChild); if (animator != null) animators.add(animator); } } animatorSet.playTogether(animators); addRestoreInitValuesListener(animatorSet); return animatorSet; } private void addRestoreInitValuesListener(AnimatorSet animatorSet) { for (Animator animator : animatorSet.getChildAnimations()) { if (animator instanceof ValueAnimator) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animation.removeListener(this); animation.setDuration(0); ((ValueAnimator) animation).reverse(); } }); } } } private void addOnStartVisibleListener(Animator animator, final View view) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { startTransition(); view.setVisibility(VISIBLE); } }); } private void addOnEndInvisibleListener(Animator animator, final View view) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { startTransition(); view.setVisibility(INVISIBLE); } }); } private void addOnStartToTopOnEndToInitPositionListener(Animator animator, final View view) { final int initLocation = indexOfChild(view); animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { bringChildToPosition(view, getChildCount() - 1); } @Override public void onAnimationEnd(Animator animation) { bringChildToPosition(view, initLocation); } }); } public int getAnimateType() { return animateType; } public void setAnimateType(@AnimateType int animateType) { this.animateType = animateType; } public int getLocationType() { return locationType; } public void setLocationType(@LocationType int locationType) { this.locationType = locationType; } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { attachedList.put(child, false); child.addOnAttachStateChangeListener(new OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View v) { attachedList.put(v, true); } @Override public void onViewDetachedFromWindow(View v) { attachedList.put(v, false); } }); super.addView(child, index, params); } @Override public void removeAllViews() { attachedList.clear(); super.removeAllViews(); } @Override public void removeView(View view) { attachedList.remove(view); super.removeView(view); } @Override public void removeViewAt(int index) { attachedList.remove(getChildAt(index)); super.removeViewAt(index); } @Override public void removeViews(int start, int count) { for (int i = start; i < start + count; i++) { attachedList.remove(getChildAt(i)); } super.removeViews(start, count); } }
New task: add customization via XML. Since I do not like very much the creation of Animator in XML (they seem to me something unreadable and not obvious), I decided to make a set of standard animations with the possibility of displaying them through flags. Plus, this approach will make it easier to define animations via Java code. Since the approach to creating CircularRevalAnimator differs from the standard one, I had to write two types of helper classes: one for ordinary Property, and the other for CircularReval.
The result was six classes:
class PropertyCanny { Animator propertyAnimator; public PropertyCanny(PropertyValuesHolder... holders) { this.propertyAnimator = ObjectAnimator.ofPropertyValuesHolder(holders); } public PropertyCanny(Property<?, Float> property, float start, float end) { this.propertyAnimator = ObjectAnimator.ofFloat(null, property, start, end); } public PropertyCanny(String propertyName, float start, float end) { this.propertyAnimator = ObjectAnimator.ofFloat(null, propertyName, start, end); } public Animator getPropertyAnimator(View child) { propertyAnimator.setTarget(child); return propertyAnimator.clone(); } }
public class PropertyIn extends PropertyCanny implements InAnimator { public PropertyIn(PropertyValuesHolder... holders) { super(holders); } public PropertyIn(Property<?, Float> property, float start, float end) { super(property, start, end); } public PropertyIn(String propertyName, float start, float end) { super(propertyName, start, end); } public PropertyIn setDuration(long millis) { propertyAnimator.setDuration(millis); return this; } @Override public Animator getInAnimator(View inChild, View outChild) { return getPropertyAnimator(inChild); } }
public class PropertyOut extends PropertyCanny implements OutAnimator { public PropertyOut(PropertyValuesHolder... holders) { super(holders); } public PropertyOut(Property<?, Float> property, float start, float end) { super(property, start, end); } public PropertyOut(String propertyName, float start, float end) { super(propertyName, start, end); } public PropertyOut setDuration(long millis) { propertyAnimator.setDuration(millis); return this; } @Override public Animator getOutAnimator(View inChild, View outChild) { return getPropertyAnimator(outChild); } }
class RevealCanny { private final int gravity; public RevealCanny(int gravity) { this.gravity = gravity; } @SuppressLint("RtlHardcoded") protected int getCenterX(View view) { final int horizontalGravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK; if (horizontalGravity == Gravity.LEFT) { return 0; } else if (horizontalGravity == Gravity.RIGHT) { return view.getWidth(); } else { // (Gravity.CENTER_HORIZONTAL) return view.getWidth() / 2; } } protected int getCenterY(View view) { final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK; if (verticalGravity == Gravity.TOP) { return 0; } else if (verticalGravity == Gravity.BOTTOM) { return view.getHeight(); } else { // (Gravity.CENTER_VERTICAL) return view.getHeight() / 2; } } public int getGravity() { return gravity; } }
@TargetApi(Build.VERSION_CODES.LOLLIPOP) public class RevealIn extends RevealCanny implements InAnimator { public RevealIn(int gravity) { super(gravity); } @Override public Animator getInAnimator(View inChild, View outChild) { float inRadius = (float) Math.hypot(inChild.getWidth(), inChild.getHeight()); return ViewAnimationUtils.createCircularReveal(inChild, getCenterX(inChild), getCenterY(inChild), 0, inRadius); } }
@TargetApi(Build.VERSION_CODES.LOLLIPOP) public class RevealOut extends RevealCanny implements OutAnimator { public RevealOut(int gravity) { super(gravity); } @Override public Animator getOutAnimator(View inChild, View outChild) { float outRadius = (float) Math.hypot(outChild.getWidth(), outChild.getHeight()); return ViewAnimationUtils.createCircularReveal(outChild, getCenterX(outChild), getCenterY(outChild), outRadius, 0); } }
With their help, the initialization of animations has become easier and more elegant. Instead:
animator.setInAnimator(new InAnimator() { @Override public Animator getInAnimator(View inChild, View outChild) { return ObjectAnimator.ofFloat(inChild, View.ALPHA, 0, 1); } }); animator.setOutAnimator(new OutAnimator() { @Override public Animator getOutAnimator(View inChild, View outChild) { return ObjectAnimator.ofFloat(outChild, View.ALPHA, 1, 0); } });
You can simply write:
animator.setInAnimator(new PropertyIn(View.ALPHA, 0, 1)); animator.setOutAnimator(new PropertyOut(View.ALPHA, 1, 0));
It turned out even prettier than using lamda expressions. Then, using these classes, I created two lists of standard animations: one for Property - PropertyAnimators , the other for CircularReaval - RevealAnimators . Then, using flags, I found the position in these lists in XML and substituted it. Since CircularRevealAnimator only works with Android 5 and above. I had to create four parameters instead of two:
Further, when parsing parameters from XML, I determine the version of the system. If it is higher than 5.0, then I take values ​​from in and out; if lower, then from pre_lollipop_in and pre_lollipop_out. If the version is lower than 5.0, but pre_lollipop_in and pre_lollipop_out are not specified, the values ​​are taken from in and out.
Despite many problems, I still successfully completed CannyViewAnimator. In general, the strange thing is that every time I want to implement any of my own wishlist, I have to use Java Reflection and go deep into it. This suggests that either the Android SDK is something wrong, or I want too much. If you have any ideas or suggestions, welcome to comments.
I repeat the link to the project below:
Link to the project in Github
Bye everyone!
Source: https://habr.com/ru/post/309740/
All Articles