📜 ⬆️ ⬇️

Creating your own View for Android - can something go wrong?

“It was evening, there was nothing to do” - that was how the idea was born to make a twist with the possibility of zoom, distributing users by rank depending on the number of their points. Since before that I had no experience in creating my own views of this level, the task seemed to me interesting and quite simple for a beginner ... but, oh, oh, how wrong I was.

In the article I will talk about the challenges I faced from both the Android SDK and the task (the clustering algorithm). The main objective of the article is not to teach how to make so-called “custom view”, but to show the problems that may arise when they are created.

The topic will be interesting to those of you who have little or no experience in creating something like this, as well as those who want to catch the Lulz from the author a hundred and first times to believe in the "flexibility" of the Android SDK.

1. How does it work?


Demonstration of the view (gif)


To begin with, I will briefly describe how the made view is arranged:
Hierarchy (own views are green)
')


RankingsListView

At the head of the table is the RankingsListView (heir of the ScrollView ). He manages a scroll (unexpectedly, yes?) And zoom, and also creates a list from the RankingView .

RankingView

RankingView displays rank (left) and UsersView (right).

UsersView

UsersView , as you might have guessed, is engaged in displaying users and showing animations of combining and disconnecting users into groups.

GroupView


Both the user and the group of users are displayed in one view, called GroupView . Only in the case when one user is displayed, and not a group, there will be no green circle (within which the number of users in the group is displayed). Well, on the right there is an indicator of user / group points with a “%” sign.

Perhaps all with the boring part, go to the problems.

ps Link to the source in the "Conclusion".

2. Android SDK "wants to play a game with you" © Goog ... Saw


Let's start with a harmless one.

2.1. Inflate the layout inside the custom View using DataBinding


DataBinding with its code generation works wonders:

 WidgetGroupViewBinding binding; … binding = DataBindingUtil.setContentView(this, R.layout.activity_main); // binding.title    

A couple of lines and through the variable binding are available on id all the twists indicated in the markup, no matter how complex this markup is. No more:

 @ BindView (R.id.tb_progress) View loadingView; @BindView (R.id.tb_user) View userView; @BindView (R.id.iv_avatar) ImageView avatarView; @BindView (R.id.tv_name) TextView nameView; @BindView (R.id.l_error) View errorView; @BindView (R.id.l_container) ViewGroup containerLayout; 

... and a dozen more lines like ButterKnife . But wait! setContentView() is the Activity method. And what about views

In order to add markup inside the current view, you need to call the inflate(getContext(), R.layout.my_view_layout, this) method, for example, inside the constructor. The last flag is interesting. It adds a view created by markup inside the current view. This means that if you have a root tag in your markup, for example, LinearLayout , and you try to use inflate(…) inside your view inherited from LinearLayout , then you will get two LinearLayout in the hierarchy ...

... And this is quite logical, though not pleasant, because one of LinearLayout 's is redundant. What to do? To get around this is quite simple. You need to use the <merge> inside the markup, wrapping everything in it that should be inside your view, as described here .

But! DataBinding does not support <merge> . Its root tag must be a <layout> , inside of which there must be a single child element — not- <merge> (in fact, there may be another <data> , but this is a completely different story).

As a result, at the moment there is no way to use DataBinding in your own views without generating additional Layouts, which will not have the best effect on performance. ButterKnife is still our everything.

UPD : feature-request at code.google.com .

2.2. Measure & Layout passes


Despite the fact that I had no experience in creating views, I still read occasional articles on this subject, and also saw a section of documentation on the topic “how views are drawn . If you believe the latter, then everything is easier than ever:


Well, oh-oh-oh, very simple. It is for this reason that I thought that it would not be difficult to implement my own view. But this was not the case (spoiler: the author will cut off big problems from the onLayout method hereafter). Here is a list of tips and rules that I derived after creating a view:


In no article that I read on the topic of views, I have not seen such descriptions of these methods. Either no one is confronted with such a thing, or this is the first rule of the custom view club — not to talk about onLayout() (excluded; spent) .

In my case, inside the UsersView.onLayout() , the Y-coordinates of the views change, which causes some views to become visible, while others hide, and this leads to ... (attention to the bottom right):


... cutting the lower view. This happened only at a distance. I had to tinker, but I managed to figure out what, it seems, the daughter view determined that with its current position Y it will appear only half in the parent, and therefore it is possible to trim your Bitmap drawingCache; . Then that additional measurement pass in the form of measureChildren() inside onLayout() came to the rescue, forcing the view to reconsider its caches after changing its Y-coordinate.

2.3. ScrollView does not allow ScrollView child


Perhaps many of you were faced with the need to set the height of the child to ScrollView element layout_height=match_parent , and then instantly failed, because the result was just the same as with wrap_content , and then found an article like this with the description of the fillViewport flag, guessed? And now the question: how to achieve the same result as with the fillViewport flag, but at the same time dynamically change the height of the child element?

Let's take it in order. How can I change the height of an element? Through LayoutParams.height course, no more. Problem solved? Not. Height remained unchanged. What happened? After examining onMeasure() in a child view, I came to the conclusion that ScrollView simply ignored the set height in the parameters, sending it onMeasure() with mode equal to “ UNSPECIFIED ” and then onMeasure() with “ EXACTLY ” and the value of height ', equal to the size of the ScrollView (if fillViewport set). And since the only way to change the height view is to change its LayoutParams , the child doesn't change.

I found two solutions:

  1. Kohl ScrollView so arrogant and clever and ignores LayoutParams , you can simply rewrite the onMeasure() method in the child and add this to the local super.onMeasure() :

     if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED && getLayoutParams().height > 0) { heightMeasureSpec = MeasureSpec.makeMeasureSpec( getLayoutParams().height, MeasureSpec.EXACTLY); } 

    Thereby making ScrollView work for it. But it's natural to create a subclass every time just for this - not a good idea. FrameLayout what class should be a child FrameLayout : FrameLayout , LinearLayout , RelativeLayout , etc. To do for every possible Layout 'and its subclass - just litter. Therefore, here is your solution number 2.

  2. Wrap the child in FrameLayout and resize this FrameLayout 's child.

It was approach number 2 that I used, and it worked out with a bang. However, I'm not sure that this is really a valid way (hack). Perhaps this hack does not work because the extra FrameLayout has due respect takes into account LayoutParams.height , but because inside ScrollView.onLayout() all ScrollView.onLayout() game occur, because of which the child’s size is calculated only partially. This is just a hunch, but I cannot explain the problem with the scrolling (spoiler: the problem will be described later).

Oh yeah, ScrollView has a problem with the bug tracker , the problem with the unchangeable size of the child, but as is usually the case with Android, it is still in New status from 2014.

2.4. background rounded corners


Subject (bottom left and top left):


It would seem that it could be simpler to make a drawable with the <shape> , set the background 'and it's ready ... but no. The task is such that the color changes programmatically, and the rounded edges are added only to the first and last elements.

Oddly enough, the ShapeDrawable class ShapeDrawable not have methods for working with rounded corners, as one would expect. But fortunately, there is a successor to RoundRectShape and PaintDrawable (why this class is needed - do not ask yourself in shock) , which have missing methods. At this problem for almost all applications could be considered solved, but not for this task.

The specificity of the task is such that the maximum zoom in can be of any kind, which means that the view with its background ’will stretch greatly, which leads to ...
Logcat: W/OpenGLRenderer: Path too large to be rendered into a texture
It looks like this, after exceeding a certain size, the background just ceases to be displayed. As you can see from the warning, a certain Path too large to be able to draw it into a texture. Having rummaged a bit in the source code, I came to the conclusion that this comrade was to blame:

 canvas.drawPath(mPath, paint); 

... in which mPath place a rounded rectangle:

 mPath.addRoundRect(mInnerRect, mInnerRadii, Path.Direction.CCW); 

This problem can be solved only by inheriting, for example, ColorDrawable and in its draw() method by calling not drawPath() , but:

 canvas.clipPath(roundedPath); 

But, unfortunately, this approach has a relatively significant drawback: canvas.clipPath() not affected by antialias . However, in the absence of another way to do this, you have to be content with this.

2.5. Positioning View inside FrameLayout


This task arose before me when I tried to implement UsersView , within which GroupView could be anywhere along the OY axis.

The first thing that came to mind (and what I thought was the only possible way to move a view inside FrameLayout ) was to use MarginLayoutParams and change the topMargin parameter. This is also indicated by the majority of responses to stackoverflow ( one , two , three ).

The disadvantage of this approach is that changing the LayoutParams causes a requestLayout() , and this is an extremely expensive operation, especially if it is called for all GroupView inside UsersView , even taking into account that the layout pass itself is postponed until better times (next 16ms-frame) .

But fortunately, there is another way - View.setY() (the answer to stackoverflow ). Ideal for animations and for children inside unchanging Layout . It does not call requestLayout() , but uses only the fields of the view itself and affects only the phase (pass) of the layout. And since the miscalculation of new positions of the views occurs directly in onLayout() before calling super.onLayout() , the requestLayout() is not needed at all.

2.6. See the font indents? Not? And they see you! © includeFontPadding


When users are combined into a group, an icon is added that displays the number of users in the group. Here's what she looks like now:



But how she looked before:



Do you notice the difference? Have an unpleasant feeling when you look at the second picture? If yes, then you understand me. For a long time I could not figure out why, when I look at this icon, it does not look as attractive as expected. Having guessed to take the paint in hand and calculate how many pixels are left / right / top / bottom of the text, I understood the reason - the text is not centered to the end. Something is clearly not so gravitational text. Well no. Gravity was set correctly, other parameters too. Everything looked perfect.

In general, I will not torment, the decision turned out to be very simple, but I could not find it right away simply because I did not understand that at all. By the way, here is a link to the solution . The bottom line is that, as it turns out, the fonts themselves have paddings, which were the cause of non-centering. By adding the TextView parameter includeFontPadding=false , the problem disappeared completely.

2.7. ViewPropertyAnimator does not have a reverse() method


I wanted to make an animation of hiding rank icons at a certain size. It should look like this:


To determine when the animation starts, we compare the current size with the required size for all views and, if necessary, use view.animate().setDuration(fadeDuration).alpha(0 or 1) .

However, this works well only with fast fade animations. However, if the fade is slow, then with a sharp zoom in after zoom out , the alpha channel of the view will not be 1 or 0, but, for example, 0.5. Because of this, the animation will play from 0.5 to 0 for the same fadeDuration . It will look like the animation slowed down 2 times. Adding something like view.setAlpha(0 or 1) before calling view.animate() is not a good solution. The view will begin to flicker during fast zoom.

Ideally, there would have to be some method of the form setReverseDuration() (without parameters) that would understand that, “yeah, I played the fade animation for 500ms, therefore reverse animation will play the same amount”. But this is not, alas. The only way out that I managed to find is to make such handles. In my case, the animation was pretty simple, so it was enough for me to hide it:

 final float realDuration = iconView.getAlpha() * animationDuration; 
... and this to show:

 final float realDuration = (1 - iconView.getAlpha()) * animationDuration; 

Well, then as usual: view.animate().setDuration((long) realDuration) - and everything is in openwork.

UPD : feature-request at code.google.com .

2.8. ScaleGestureDetector (aka "pinch", aka "zoom")


The ScaleGestureDetector API ScaleGestureDetector pretty good - hung up the listener and wait for yourself events, not forgetting to transfer all onTouchEvent() 's into the detector itself. However, not everything is so rosy.

2.8.1. Small notes


First, nowhere is it said how to differentiate inside onTouchEvent() events between the ScaleGestureDetector and ScrollView (after all, I remind you, it happens inside the RankingsListView , which is the heir of the ScrollView ). As a result, the method looks like this:

 @Override public boolean onTouchEvent(MotionEvent ev) { scaleDetector.onTouchEvent(ev); super.onTouchEvent(ev); return true; } 

And so it is advised to do all stackoverflow-answers ( example ). However, this approach has a disadvantage. The scroll happens even when you produce a pinch. It may seem like a trifle, but in fact it is very unpleasant to accidentally flip through the view when trying to figure it out.

I was ready to scour for a long and tedious search for a difficult solution to the division of responsibility between super.onTouchEvent() and scaleDetector.onTouchEvent() ... and I was really looking for ... But as it turned out, the solution was terribly simple:

 @Override public boolean onTouchEvent(MotionEvent ev) { scaleDetector.onTouchEvent(ev); if (ev.getPointerCount() == 1) { super.onTouchEvent(ev); } return true; } 

Brilliant, yes? super.onTouchEvent() does not track the id finger that was used to scroll for the first time, so even if you started scrolling with finger # 1, and finished with finger # 2 - it is ok with him. Unfortunately, I was so sure that the Android SDK would once again put a stick in the wheels, that I bothered to try this only after: googling and studying the sources. What can I say, the Android SDK can sometimes work as it should surprise.

Secondly , if you suffer from a micro-optimization disease, then you will have to monitor the size of your child views with extreme caution. As you already know, with pinch, I increase the height for the child inside the child of ScrollView . This child is a LinearLayout whose children are layout_weight=1 . In other words, they are all the same height ... although no.

It is absolutely never noticeable, but its subsidiary views can not always be the same height, because the pixels are atomic units. That is, if LinearLayout has a height of 1001 and it has 2 children, then one of them will be 501 and the other 500. Notice this by sight is almost impossible, but there may be indirect consequences.

When I talked about ViewPropertyAnimator and reverse() , I showed an animation of hiding the rank icon. The check itself is simple - we onLayout() up the height of 2 TextView and ImageView in onLayout() , and if they don’t get inside the current view at once, then hide the fade ImageView . It is also worth noting that this total height (“height threshold”, so to speak) does not change. As a result, if the threshold is 500 pixels, then in the described case, for one I twist the size of 500, the icon will hide, while the second, the size of 501, will not.

The situation is rare and not too critical (it was not so simple (but not difficult) to move the mouse so slowly to detect hidden and hidden icons at the same time). But still, if you don’t like this behavior, you can fix it only in one way - do not use getHeight() to getHeight() with a threshold. In onSizeChanged() inside LinearLayout find the smallest size of all child elements and notify everyone that they compare the threshold with this number. I called it shared height and it looks like this for me:

 private void updateChildsSharedHeight() { int minChildHeight = Integer.MAX_VALUE; for (int i = 0; i < binding.lRankings.getChildCount(); ++i) { minChildHeight = Math.min(minChildHeight, binding.lRankings.getChildAt(i).getMeasuredHeight()); } for (int i = 0; i < binding.lRankings.getChildCount(); ++i) { RankingView child = (RankingView) binding.lRankings.getChildAt(i); child.setSharedHeight(minChildHeight); } } 

And the reconciliation with the threshold value is:

 int requiredHeight = binding.tvScore.getHeight() + binding.tvTitle.getHeight() + binding.ivIcon.getHeight(); boolean shouldHideIcon = requiredHeight > sharedHeight; 


UPD : feature-request at code.google.com .

2.8.2. Problems


And now let's talk not about ScaleGestureDetector at the ScaleGestureDetector , but about its problems.

2.8.2.1. Minimum pinch



And-and-and-and-and-and ... he (minimum pinch) is not disabled. During coding, I didn’t know about any “minimum distance for pinch triggering”, so I had to study the logs a bit in order to understand whether it was a cant in my code or something else. The logs said that if the distance between the fingers was less than 510 pixels, then the ScaleGestureDetector simply stopped responding to the touch, sending the event onScaleEnd() . Information that there is some kind of "minimal pinch" was not present either on the docks or on stackoverflow. Perhaps, I would not even notice this, if the debugging was not done on the emulator. On it, the pinch distance can be even millimeter, which was the reason for searching for information on the subject. However, it turned out to be much closer than I thought, namely, as always, in the source code:

 mMinSpan = res.getDimensionPixelSize(com.android.internal.R.dimen.config_minScalingSpan); 

And-and-and-and-and ... of course, the class has no methods for changing this field, and of course com.android.internal.R.dimen.config_minScalingSpan is magical 27mm. For me in general, the existence of a minimal pinch seems to be a very strange phenomenon. Even if there is a sense in this, why not give the opportunity to change it?
The solution to the problem is usually reflection.

2.8.2.2. Slop


For those who do not know what a "slop" is (like me), I translate:
Slop - nonsense, nonsense © ABBYY Lingvo

Okay, okay, jokes aside."Slop" is such a state when it is considered that the user accidentally touched the screen and in fact did not want to move / scroll / zoom / anything else. Such "protection against accidental movement." Gif will explain:


... on the gif it can be seen that minimal movements are allowed before the pinch starts, which will not be considered a pinch. In principle, a good thing, plus.

But ... the slop is also not changeable! ScaleGestureDetectordescribes it like this:

 mSpanSlop = ViewConfiguration.get(context).getScaledTouchSlop() * 2; 
... and ViewConfigeurationso:

 mTouchSlop = res.getDimensionPixelSize( com.android.internal.R.dimen.config_viewConfigurationTouchSlop); 

Where exactly is the meaning? What for? Why?It is not clear ... In general, the Android SDK is the best tutorial on reflection.

UPD : feature-request for Slop / Span at code.google.com .

2.8.2.3. Jumps detector.getScaleFactor()at the first pinch


Inside the event onScale(ScaleGestureDetector detector)is transmitted detector, from which using the method detector.getScaleFactor()you can find out the pinch coefficient. So, at the very first pinch, this method returns strange jump-like values. Here are the logs of values ​​when strictly moving zoom out: It looked, to put it mildly, not very much - the size of the view was constantly twitching, and the animations at these jumps looked like - it was better to omit it altogether. For a long time I tried to understand what the problem was. Checked on a real device (you never know, all of a sudden the emulator problem), moved the mouse as if a cat died somewhere over an extra millimeter of movement (you never know, all of a sudden I’m twitching and from here in the logs) - but no. The answer was not found, but I was lucky. So to say "from the bald," I decided to try to send in the event

0.958
0.987
0.970
1.009
0.966
0.967
1.006




. > 1ScaleGestureDetectorMotionEvent.ACTION_CANCEL immediately after initialization:

 scaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener()); long time = SystemClock.uptimeMillis(); MotionEvent motionEvent = MotionEvent.obtain(time - 100, time, MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0); scaleDetector.onTouchEvent(motionEvent); motionEvent.recycle(); 

... and it helped O_o ... After rummaging (which time, and Android SDK?) In the source, it turned out that the first pinch has no slop (yes, this is the one mentioned above). Why is this so - it remains a mystery to me, exactly like why this hack helped. Most likely, somewhere, they namudrili with initialization and part of the class believes that the very first pinch has already passed the test for the slop, and the other part believes that it does not, and as a result, in the heat of the hot battle of the form “passed / failed” of them. ¯ \ _ (ツ) _ / ¯ © SDK

UPD : Bug-report on code.google.com .

2.9. ScrollView.setScroll()triggered only after super.onLayout()?


We return to the problem of ignoring the ScrollViewestablished children height's. The situation is the following: as soon as the pinch occurs, I would like the current focus (where you clicked the mouse and from which point you do zoom at all) remained the same. Why? Well, just because the zoom looks more user-friendly, consider that you do not just change the height of the child element, but just zoom to some user, while everyone else is moving away from it:


It is not difficult to make it through ScaleGestureDetector.getFocusY()with ScrollView.getScrollY(). Then, it would seem, it is enough to do the ScrollView.setScrollY(newPosition)thing in the hat ... But no, the view begins to twitch strangely at the zoom to the lowest child:


The solution was found here . The following happens: at the moment when we do it setScroll(), it checks whether the position of the scroll goes beyond the size of the child element, and if it does, we set the maximum possible position. And since it setScroll()is called at zoom, then the following sequence of actions is obtained:

  1. Calculate newHeight
  2. Calculate newScrollPos
  3. Set newHeight via setLayoutParams ()
  4. Set newScrollPos via setScroll ()

The problem in paragraph number 3. The real heightwill not change until the next super.onLayout(), setScroll()and therefore does not what is expected. Corrected as follows. Instead of setScroll()doing this:

 nextScrollPos = newScrollY; 
... and onLayout()after the method we super.onLayout()call this method:

 private void updateScrollPosition() { if (nextScrollPos != null) { setScrollY(nextScrollPos.intValue()); nextScrollPos = null; } } 

As you remember, I wrote that it would be better not to call any methods after super.onLayout(). This is still the case. Later I will describe another problem associated with this solution. But the fact remains that this is the solution for the scrolling jump problem.

ps However, if you do not use the hack with “change heightfor child inside child inside ScrollView”, then there will be no such problem. But then we come back to the problem of a dozen subclasses.

3. The task of clustering users


Now it's time to talk about the task itself, its algorithm and some of its features.

3.1. What to do with boundary users?


I'm talking about users who are on the border of their rank:


In the picture, boundary users are users with 0%, 30%, 85% points (the user almost completely blocked it with 80%). The easiest way would be to draw them in their legal position (equal to: ), but in this case they will start to call in foreign ranks, which would put it mildly “not comme il faut”. The main reason for refusing this is grouping. Imagine a user with 29% points. He is on the border rank "Newbie". But suddenly he unites with the user with 33% points and their joint group is now located in the position corresponding to 31%, i.e., in the rank of “Good”. I didn’t like the idea that the grouping of views could change the ranks of users, so I refused and decided to restrict users inside the ranks as you saw in the picture above.

Looking ahead, I note that this added a lot of trouble to the algorithm for grouping and the logic of the application as a whole.

3.2. What is the number of points to show the group?


For example, in the group 2 users united with points of 40% and 50%. Where to arrange their joint group and with what points? The answer is simple: 45% of the group position corresponds to the points of the group.

Let's complicate the task. And how to combine these?



There are 2 ways to solve it:

  1. Just as with users of 40% and 50%. That is, we put the group in the position of 2.5%.
  2. Put the group in position , .

The difference of approaches is that in the first case the group view will not be in the center between users, in the second case it will be in the center, which is much more favorable from the point of view of user experience.

Preferring UX , I decided to do it using method No. 2, however, the approach showed a significant drawback: the group points are calculated completely crookedly. The fact is that the user with 0% is actually not located at position 0 (after all, he would have entered the territory of someone else’s rank), so it can be said that everyone in a rank cannot have points less than a certain minScoreone also changes at zoom, because it is calculated by the formula (simplified version):


... because it is userViewHeightunchanged, but containerHeightchanges, then at different points of the pinch, the glasses at the boundary twist will be different.
Moreover, the formula itself:


… , .. , ( . view.getY() , float , , , MarginLayoutParams.topMargin , , ).

№2, №1, -.

3.3.


. What I did.I had at least 5 different implementations that in one way or another were subject to "unpleasant" effects, which forced me to solve the problem again and again in a new way.

I could write a final implementation and finish on this, but I still preferred to describe several of my implementations. If you are interested in the clustering problem described below, think about how you would solve it (what algorithm would you think of / use), and then read paragraph by paragraph, changing your decision, if it is also subject to artifacts, like mine.

3.3.1. Task


Make clustering with good UX, which means that:

  1. Boundary views must remain inside the rank.
  2. – , zoom in zoom out zoom in - .
  3. / ( №2).

3.3.2.


It is important to note that in all implementations, before launching the algorithm itself, all users are sorted by their points and each user turns into a class Group, because in fact, a single user is just a group of one person. Also, using the word “user” I can mean both one user before joining a group, and a group of users.

About the notation. I will mark the user's number with one digit: “7”, - and the groups with two: “78”. The numbers in the group number indicate the numbers of users in it are members, that is, group 78 consists of users 7 and 8.

Each algorithm will be given both a verbal description and pseudo-code.

3.3.3. Algorithm №1


A simple sequential algorithm that unites users in order, if they intersect.

Steps:

1. , i i+1.
2.1. – №1.
2.2. , .
3. 1 .


 for (int i = 0; i < groups.size() - 1; ) { if (isInersected(groups[i], groups[i+1])) { groups[i].addUsersToGroup(groups[i+1]); groups.remove(i+1); } else { ++i; } } 

But this algorithm has a problem with UX:


, , , – UX.

3.3.4. №2


, , . №1. : . , .

:
0. i = 0.
1. , i i+1.
2.1. – , . 1.
2.2. , 2.
3. , 1.
4. i == 1 , .
5. 0, i ( 0 1 1 0 – ).


 bool didIntersected, isEvenPass = false; do { didIntersected = false; isEvenPass = !isEvenPass; int i = isEvenPass ? 0 : 1; while (i < (groups.size() - 1)) { if (isInersected(groups[i], groups[i+1)) { didIntersected = true; groups[i].addUsersToGroup(groups[i+1]); groups.remove(i+1); i += 1; } else { i += 2; } } } while(isEvenPass || didIntersected); 

, , (- ). « 21-12» ( ):


Let me explain what happened here. When you run the algorithm, it was found that 1 and 2 users intersect and form a group. The third user intersects with their group, and therefore they all form another group.

However, if you try to disengage them with a faster zoom, then what you see at the bottom of the picture will happen - two users will intersect, but will not form a group.

Even if, after splitting, to launch another merge pass, we will get group 23, which did not exist before. Previously, users united in groups with a size of 2 and 1, and now, after separation, they form groups of size 1 and 2. Hence the name “Problem 21-12”. This has a bad effect on UX when a group is combined differently depending on the zoom speed, which means the algorithm is not suitable.

3.3.5. Algorithm №3


Then it became clear to me that stupidly in the forehead the task could not be solved and I would have to use artillery heavier. Therefore, I decided to combine users based on the distance between them (their views). The smaller the distance between users - the first is their union. And in order to break up groups, you can simply enter the type field Stack<Group> groupsStack;and add each new group there. Thus, when disconnecting, only the topmost element on the stack will need to be checked.

Steps:
0. ( groups.size() – 1, / ).
1. . 0?
YES.1. 2- .
YES.2. . , 2- , .
YES.3. .
YES.4. 1.
NO.1. .


 distances.sortAsc(); while (!distances.isEmpty() && distances.getFirst().distance <= 0) { firstDistance = distances.getFirst(); mergedGroup = new Group(firstDistance.groupLeft, firstDistance.groupRight); groupsStack.push(mergedGroup); distances.removeFirst(); firstDistance.groupLeft.notifyPositionChanged(mergedGroup.position); firstDistance.groupRight.notifyPositionChanged(mergedGroup.position); distances.sortAsc(); } 

I thought "well, this will surely be enough." But no.It turned out, and here there is a problem: the wrong order of combining groups. "How can this be ?!" - you thought? Yes, very simple. What if you make an oh-oh-oh-very sharp zoom?


As a result of the sharp zoom, all users had the same position, because they were quite close to each other before the zoom. Because of this, I sortAsc()did not know what to do, because the distance between users 1 and 2 is 0, but also between users 2 and 3 it is also 0. As a result, the groups merged in the wrong order. This will be especially noticeable when disconnected. The first 12 will be separated, because they are the last to unite (the stack is LIFO . Do you all remember?), Although they are closer to each other.

3.3.6. Algorithm №3.1


sortAsc() « 0». , . , , distance == 0 2- , group.scoreAfterMerge . , GroupsDistance, :

 @Override public int compareTo(@NonNull GroupsDistance obj) { GroupsDistance a = this, b = obj; final int distancesComparison = MathUtil.Compare(b.distance, a.distance); if (distancesComparison != 0) { return distancesComparison; } else { final int namesComparison = a.leftGroup.name.compareTo(b.leftGroup.name); return namesComparison; } } 

… . group.scoreAfterMerge ':

 @Override public int compareTo(@NonNull GroupsDistance obj) { GroupsDistance a = this, b = obj; final int distancesComparison = MathUtil.Compare(b.distance, a.distance); if (distancesComparison != 0) { return distancesComparison; } else { final int scoresComparison = MathUtil.Compare(a.scoreAfterMerge, b.scoreAfterMerge); if (scoresComparison != 0) { return scoresComparison; } else { final int namesComparison = a.leftGroup.name.compareTo(b.leftGroup.name) return namesComparison; } } } 

Indeed, user.scorethey group.scoreAfterMergewill always be unequal (and if they are equal, then there is no difference in the order in which to unite them — they will never get separated anyway). This means that add. comparisons scoreshould be enough to solve the problem ... but no, and in this case there is a flaw.

Remember the condition of the problem: "The boundary view must remain inside the rank"? The problem lies in it. If it were not for this condition, distanceit would always change proportionally between users, and the position of the user view would uniquely display it score, but this is not so. Because of this condition, at the boundary view, the dependence of the position on is violated score. This is not obvious enough, so here is a detailed example of such a “violation”:

, 100. 0, 100. : [0, 100], [_, _]. , 1D . (, [0, 100] [60, 160]) №1 №2, .. .

So, we have a view of rank with a height of 1000 and 3 users: Note: for user No. 3, the position is not [1000, 1100], because going beyond the boundaries of the rank is unacceptable. Therefore, its view was moved to the nearest possible position inside the rank view. It would seem that the distance between No. 1 and No. 2 is 90, between No. 2 and No. 3 is 10. The latter will intersect earlier with the zoom, right? .. Let's zoom with a factor of 0.5 (the rank view has become 500): The distance between No. 1 and No. 2 is -5, between No. 2 and No. 3 is -95. №2 and №3 united before №1 and №2, did they want it all, hurray? .. No: D. Let's return the zoom back and this time we will produce a zoom with a factor of 0.2 (the size of the rank view has become 200):

№1 60%: [600, 700].
№2 79%: [790, 890].
№3 100%: [900, 1000] ( : [1000, 1100]).





№1 60%: [300, 400].
№2 79%: [395, 495].
№3 100%: [400, 500] ( : [500, 600]).




№1 60%: [100, 200] ( : [120, 220]).
№2 79%: [100, 200] ( : [158, 258]).
№3 100%: [100, 200] ( : [200, 300]).


№1 №2 0, №2 №3 0. , user.score … , №1 №2 , №2 №3, .

, , , . .

« . ? , score ?!» — .

3.3.7. №4


. , . . , «A» «B» - . Here is an example:



( ), «B» :

:


«B» «A» , , .

, – , – . , , , , , height , 2 ! willIntersectWhenHeight ' , . – , ( border bound ) / , . .

, , , willIntersectWhenHeight ' GroupCandidates . , , , . , .

 private float getIntersectingHeght() { float intersectingHeight; final Float height = calcIntersectingHeightWithNoBound(); final boolean leftIsBorder = leftGroup.isLeftBorderWhen(height), rightIsBorder = rightGroup.isRightBorderWhen(height); if (!leftIsBorder && !rightIsBorder) { intersectingHeight = space; return; } if (leftIsBorder && !rightIsBorder) { intersectingHeight = (itemHeight + itemHalfHeight) / right.getNormalizedPos(); return; } if (!leftIsBorder && rightIsBorder) { intersectingHeight = (itemHeight + itemHalfHeight) / (1 - left.getNormalizedPos()); return; } if (leftIsBorder && rightIsBorder) { intersectingHeight = ((float) (itemHeight + itemHeight)); return; } return intersectingHeight; } private Float calcIntersectingHeightWithNoBound() { return itemHeight / (right.getNormalizedPos() - left.getNormalizedPos()); } 
: getNormalizedPos() (.., 0 1) .

, , . , .

4. ( UPD : )


. , , - « – », , . , , , , .

4.1. ScrollView.setScroll() ScrollView.onLayout() super.onLayout() ( child' child' ScrollView )


. , ScrollView setScroll() super.onLayout() . Why is it important? requestLayout() . « layout pass» 16-. , requestLayout() , , super.onLayout() ( ), layout pass .

, requestLayout() super.onLayout() , . , ( 16), layout pass, ScrollView.onLayout() , requestLayout() super.onLayout() … , 16- Layout ' . , . requestLayout() super.onLayout() , ScrollView.setScroll() requestLayout() , ? :

4.2. View.setVisibility(GONE) requestLayout()


( ) , . , recycling /, , .

, View.getLocalVisibleRect() ( ) UsersView , groupView.setUsersGroup(group) , groupsCountView.setVisiblity(…) .

But! , ScrollView.setScroll() , View.getLocalVisibleRect() . ScrollView.setScroll() super.onLayout() , groupView.setUsersGroup(group) View.setVisiblity(GONE) , requestLayout() , , .

, , , View.getLocalVisibleRect() , View.setVisiblity(GONE) , requestLayout() . , requestLayout() – !

-, requestLayout() GroupView , ( GroupView «»… . ), « requestLayout() » . «», . , , stackoverflow .

4.999.


, requestLayout() , ( ). , , RecyclerView LayoutManager .

, , RecyclerView , . , requestLayout() , (, child.setVisibility(GONE) ), … child ' Layout ! : layout.addView(myChild) .
:
  1. View ' child ';
  2. measure() , layout() drawChild() child ' ;
  3. child ' , requestLayout() .

№3 . — . View.getLocalVisibleRect() / //.

ps … , , onScrollChanged() onLayout() , setScrollY() ? , View.getLocalVisibleRect() false (, UsersView ). , , :/ , recycling ' . 1.000 . - . NDK.

5.


, . , , SDK . Android' .

- , . , , -_-"… . , .

github.com/Nexen23/RankingListView

« » — . , , .

UPD 1 : Artem_007 , feature-request/bug-report .
UPD 2 : 4.999. «» .

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


All Articles