Создание собственной View под Android – может ли что-то пойти не так?

2413916c8172453082805f24ba91b8e3.png
«Дело было вечером, делать было нечего» — именно так родилась идея сделать вью с возможностью зума, распределяющую юзеров по рангам в зависимости от кол-ва их очков. Так как до этого я не имел опыта в создании собственных вьюшек такого уровня, задача показалась мне интересной и достаточно простой для начинающего… но, *ох*, как же я ошибался.

В статье я расскажу о том, с какими проблемами мне пришлось столкнутся как со стороны Android SDK, так и со стороны задачи (алгоритма кластеризации). Основная задача статьи — не научить делать так называемыми «custom view», а показать проблемы, которые могут возникнуть при их создании. Тема будет интересна тем из вас, кто имеет мало (или не имеет вовсе) опыта в создании чего-то подобного, а также тем, кто хочет словить лулзов с автора в сто первый раз уверовать в «гибкость» Android SDK.

1. Как это работает?


Демонстрация работы вьюшки (гифка)
ceffaf2a46e04da6a6cab5cd21548114.gif

Для начала кратко опишу то, как устроено сделанная вьюшка:
Иерархия (зеленым отмечены собственные вьюшки)
d5f7bafd91114ae7abc215b23564f622.png

RankingsListView
b0f6a94af08347659cfdcd66c99b2ef8.png
Во главе стола — RankingsListView (наследник ScrollView). Он управляет скроллом (неожиданно, да?) и зумом, а также занимается созданием списка из RankingView.

RankingView
49bfa57e54074f99baca82b34f0dacd6.png
RankingView отображает ранг (слева) и UsersView (справа).

UsersView
232784fc34ef4bcc9437ef281e6895df.png
UsersView, как вы могли уже догадаться, занимается отображением юзеров и показом анимаций объединения и разъединения юзеров в группы.

GroupView
0b49686a0c6c4996b2eb4f24c7bf945b.png
И юзер, и группа юзеров отображаются одним вью, называемым GroupView. Только в случае, когда отображается один юзер, а не группа, будет отсутствовать зеленый круг (внутри которого отображается кол-во юзеров в группе). Ну, а справа расположен показатель очков юзера/группы со знаком »%».

Пожалуй всё со скучной частью, переходим к проблемам.
p.s. Ссылка на исходники в «Заключении».

2. Android SDK «хочет сыграть с тобой в игру» © Goog… Пила


Начнем с безобидного.

2.1. Inflate разметки внутрь кастомного View с использованием DataBinding


DataBinding с её генерацией кода творит чудеса:
WidgetGroupViewBinding binding;
…
binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
// binding.title доступен для работы

Пару строк и через переменную binding доступны по id все вью, указанные в разметке, какой бы сложной эта разметка не была. Никаких больше:
@ 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;

… и ещё с десяток подобных строк как при ButterKnife. Но постойте! setContentView() — это метод Activity. А что же вьюшкам делать

Для того, чтобы добавить разметку внутрь текущего вью нужно вызвать метод inflate(getContext(), R.layout.my_view_layout, this), например, внутри конструктора. Интересен последний флаг. Он добавляет вью, созданную по разметке, внутрь текущего вью. Это приводит к тому, что если в вашей разметке корневой тег, например, LinearLayout, и вы попробуете использовать inflate(…) внутри вашей вьюшки, унаследованной от LinearLayout, то вы получите два LinearLayout в иерархии…

…И это вполне логично, хоть и не приятно, ведь один из LinearLayout«ов избыточен. Что же делать? Обойти подобное достаточно просто. Нужно воспользоваться тегом внутри разметки, обернув в него всё, что должно быть внутри вашего вью, как это описано здесь.

Но! DataBinding не поддерживает . Его корневым тегом обязан быть тег , внутри которого должен быть единственный дочерний элемент-не- (на самом деле там может быть ещё тег , но уже совсем другая история).

Как итог, на данный момент нет способа использовать DataBinding в собственный вьюшках, не наплодив дополнительных Layout«ов, что не лучшим образом скажется на производительности. ButterKnife по-прежнему наше всё.

2.2. Measure & Layout passes


Несмотря на то, что я не имел опыта в создании вьюшек, я всё же читал изредка попадавшиеся на глаза статьи по этой тематике, а также же видел раздел документации, посвященной теме «как вьюшки отрисовываются». Если верить последней, то всё проще простого:
  • вызывается пару раз onMeasure() для определения размеров;
  • вызывается onLayout() для расположения элемента внутри контейнера;
  • вызывается onDraw() для отрисовки.

Ну о-о-о-очень просто. Именно по этой причине я считал, что реализовать собственную вьюшку не составит труда. Но не тут-то было (спойлер: автор отхватит больших проблем от метода onLayout далее по тексту). Вот список советов и правил, которые я вывел после создания вьюшки:
  • onMeasure() предназначен только для определения размеров. Не имеет абсолютно никакого смысла помещать в него что-либо ещё. Причина не только в том, что метод вызывается несколько раз, но и в том, что нельзя с уверенностью сказать, будет ли вызван он ещё раз до onLayout() или же текущий подсчитанный размер — финальный;
  • onSizeChanged(), который по какой-то невероятной причине зачастую не упоминается в статьях по кастомным вьюшкам, вызывается до onLayout(), но внутри метода layout(), вызванным родителем. Суть такова, что layout() вызывает setFrame(), который вызывает onSizeChanged(), а уже потом (после выхода из setFrame()) вызывается onLayout(). Это означает, что внутри метода onSizeChanged() вы всё ещё не можете положиться на то, что все дочерние вью внутри вашего вью расположены как нужно. Более того, у них ещё не были вызваны их onSizeChanged(), не говоря уже об onLayout();
  • Внутри onLayout() можно вызывать measureChildren() самостоятельно. Иначе говоря, если вью того пожелает, он может ещё несколько раз прогнать measurement pass;
  • Последним методом в onLayout() лучше оставлять super.onLayout(). В противном случае вы можете создать ситуацию, при которой у дочерних вью будет дважды вызван onSizeChanged() с onLayout(). А уж если не повезет, то и вовсе можете зациклиться так, что каждые 16 мс (60 fps ведь) будет вызываться requestLayout() (спойлер: автор здесь себе в ногу выстрелил, но об этом позднее).

Ни в одной статье, которую я читал по теме вьюшек, я не видел подобных описаний этих методов. То ли никто не сталкивается с подобным, то ли это первое правило клуба кастомных вью — не говорить об onLayout() (исключен; потрачено).

В моем случае внутри UsersView.onLayout() происходит изменение Y-координат вьюшек, из-за чего некоторые вьюшки становятся видны, а другие прячутся, и это приводит к… (внимание на правый низ):
2f430c41cb6f44c49118fa04c8b4bbea.jpg
…обрезанию нижней вьюшки. Подобное происходило только при отдалении. Пришлось потупить повозиться, но удалось разобраться, что, похоже, дочерняя вьюшка определила, что со своей текущей позицией Y она в родителе появится лишь на половину, а посему можно обрезать свой Bitmap drawingCache;. Тут на помощь и пришел тот самый дополнительный «measurement pass» в виде measureChildren() внутри onLayout(), заставивший вьюшку пересмотреть свои кэши после изменения её Y-координаты.

2.3. ScrollView не разрешает менять размер ребенка


Пожалуй, многие из вас сталкивались с необходимостью установить высоту дочернего к ScrollView элементу layout_height=match_parent, после чего мгновенно терпели неудачу, ведь результат был всё равно как при wrap_content, а затем находили статью вроде этой с описанием флага fillViewport, угадал? А теперь вопрос: как добиться такого же результата, как с флагом fillViewport, но при этом динамически изменять высоту дочернего элемента?

Давайте по порядку. Как вообще можно изменить высоту элемента? Через LayoutParams.height конечно же, больше никак. Проблема решена? Нет. Высота осталась неизменной. Что же произошло? Изучив onMeasure() в дочернем вью, я пришел к выводу, что ScrollView просто игнорирует установленный height в параметрах, отсылая ему сначала onMeasure() с mode равным »UNSPECIFIED», а затем onMeasure() с »EXACTLY» и значением height«а, равным размеру ScrollView (если установлен fillViewport). А так как единственный способ изменить height вьюшки — изменить его LayoutParams — то ребенок и не меняется.

Решений я нашел два:

  1. Коль ScrollView такой наглый умный и игнорирует LayoutParams, можно просто переписать метод onMeasure() у ребенка и перед тамошним super.onMeasure() добавить это:
    
    	if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED
    		&& getLayoutParams().height > 0) {
    		heightMeasureSpec = MeasureSpec.makeMeasureSpec(
    			getLayoutParams().height, MeasureSpec.EXACTLY);
    	}

    Тем самым сделав работу ScrollView за него. Но естественно создавать каждый раз подкласс лишь для этого — не очень хорошая идея. Мало ли, какого именно класса должно быть дочернее вью: FrameLayout, LinearLayout, RelativeLayout и т.д. Делать для каждого возможного Layout«а свой подкласс — только мусорить. Поэтому вот вам решение №2.
  2. Обернуть ребенка во FrameLayout и изменять размер ребенка этого FrameLayout«а.

Именно подход №2 использовал я, и он сработал на ура. Однако, я не уверен, что это действительно допустимый способ (хак). Возможно, данный хак работает не потому, что дополнительный FrameLayout оказывает должное уважение принимает во внимание LayoutParams.height, а потому, что внутри ScrollView.onLayout() происходит всякая дичь нечто, из-за чего просчитывается размер ребенка лишь частично. Это всего лишь догадка, но иначе объяснить проблему со скроллом (спойлер: проблема будет описана позднее) я не могу.

Ах да, проблема с неизменяемым размером ребенка у ScrollView есть на багтрекере, но как это обычно и бывает с Android«ом, оно всё ещё в статусе New с 2014-го года.

2.4. Закругленные углы у background


Сабж (слева снизу и слева сверху):
b113ba0243f44f93b0375cc4ad58e85d.png
Казалось бы, что может быть проще — сделать drawable с тегом , установить background«ом и готово…, но нет. Задача такова, что цвет меняется программно, а закругленные края добавляются лишь первому и последнему элементам.

Как ни странно, у класса ShapeDrawable нет методов для работы с закругленными углами, как можно было бы ожидать. Но к счастью, есть наследник RoundRectShape и PaintDrawable (зачем нужен этот класс — не спрашивайте, сам в шоке), у которых присутствуют недостающие методы. На этом проблему для практически всех приложений можно было бы считать решенной, но не для данной задачи.

Специфика задачи такова, что максимальный zoom in может быть каким угодно, а значит вьюшка с её background«ом сильно растянутся, что приводет к…
Logcat: W/OpenGLRenderer: Path too large to be rendered into a texture
Выглядит это так, что после превышения некоторого размера, background просто перестает отображаться. Как можно понять из предупреждения, некий Path слишком большой, чтобы можно было его отрисовать в текстуру. Чуток покопавшись в исходниках, я пришел к выводу, что виной всему этот товарищ:

canvas.drawPath(mPath, paint);

… в mPath которого и помещают закругленный прямоугольник:
mPath.addRoundRect(mInnerRect, mInnerRadii, Path.Direction.CCW);

Решить данную проблему можно только унаследовав, например, ColorDrawable и в его методе draw() вызвав не drawPath(), а:
canvas.clipPath(roundedPath);

Но, к сожалению, у данного подхода есть относительно существенный недостаток: canvas.clipPath() не подвержен antialias. Однако за неимением другого способа сделать подобное, приходится довольствоваться этим.

2.5. Позиционирование View внутри FrameLayout


Данная задача встала передо мной при попытке реализации UsersView, внутри которого GroupView могли быть на любом месте вдоль оси OY.

Первое, что приходило на ум (да и то, что я считал единственным возможным способом перемещения вьюшки внутри FrameLayout) — использовать MarginLayoutParams и изменять параметр topMargin. Об этом же свидетельствуют большинство ответов на stackoverflow (раз, два, три).

Недостатком данного подхода является то, что изменение LayoutParams вызывает requestLayout(), а это крайне дорогая операция, особенно если она вызывается у всех GroupView внутри UsersView, даже с учетом того, что непосредственно сам layout pass откладывается до лучших времен (следующего 16 мс-фрейма).

Но к счастью, есть другой способ — View.setY() (ответ на stackoverflow). Идеально для анимаций и для дочерних элементов внутри неизменяющихся Layout«ов. Он не вызывает requestLayout(), а использует только поля самой вьюшки и влияет лишь на фазу (pass) layout«а. А так как просчет новых позиций вьюшек происходит прямо в onLayout() до вызова super.onLayout(), requestLayout() и вовсе не нужен.

2.6. Видишь отступы шрифта? Нет? А они тебя видят! © includeFontPadding


Когда юзеры объединяются в группу, добавляется иконка, отображающая кол-во юзеров в группе. Вот как она выглядит сейчас:
8948672ad6294350b3fa5725368a7f76.png
А вот как она выглядела раньше:
b59531753d9e4c919c65d534ff151372.png
Замечаете разницу? Есть неприятное чувство, когда смотрите на вторую картинку? Если да, то вы меня понимаете. Я долго не мог сообразить, почему, когда я смотрю на эту иконку, она выглядит не так привлекательно, как предполагалось. Догадавшись взять пэинт в руки и подсчитать, сколько же пикселей слева/справа/сверху/снизу от текста, я понял причину — текст не центрирован до конца. Что-то явно не так гравитацией текста. Да?… Нет. Гравитация была установлена верно, другие параметры тоже. Всё выглядело идеально.

В общем, не буду томить, решение оказалось очень простым, но вот найти сходу его не получалось просто потому, что я не понимал, что вообще. Вот, кстати, ссылка на решение. Суть в том, что, оказывается, у шрифтов самих по себе есть padding«и, которые и являлись причиной нецентрируемости. Добавив TextView параметр includeFontPadding=false, проблема исчезла целиком и полностью.

2.7. У ViewPropertyAnimator нет метода reverse()


Хотелось мне сделать анимацию сокрытия иконок рангов при определенном размере. Выглядеть это должно было так:
0df5c23e980f44a69bf5bbd16f0db5a6.gif
Чтобы определить момент запуска анимации, сверяем текущий размер с необходимым для умещения всех вьюшек и, если нужно, используем view.animate().setDuration(fadeDuration).alpha(0 or 1).

Однако, подобное работает хорошо только при быстрой анимации fade«а. Однако если fade будет медленным, то при резком zoom in после zoom out, альфа канал вьюшки будет не 1 или 0, а, например, 0.5. Из-за чего, анимация будет проигрывается от 0.5 до 0 за те же fadeDuration. Выглядеть это будет так, словно анимация замедлилась в 2 раза. Добавлять до вызова view.animate() нечто вроде view.setAlpha(0 or 1) не является хорошим решением. Вьюшка начнет мерцать при быстром зуме.

В идеале, здесь должен был бы быть какой-нибудь метод вида setReverseDuration() (без параметров), который бы понял, что, «ага, я проигрывал анимацию fade«а 500 мс, поэтому столько же и будет играть reverse-анимация». Но такого нет, увы. Единственный выход, что мне удалось найти, делать подобное ручками. В моем случае анимация была довольно простая, так что мне хватило этого для скрытия:

final float realDuration = iconView.getAlpha() * animationDuration;
… и этого для показа:
final float realDuration = (1 - iconView.getAlpha()) * animationDuration;

Ну, а дальше как обычно: view.animate().setDuration((long) realDuration) — и всё в ажуре.

2.8. ScaleGestureDetector (он же «пинч», он же «зум»)


Сам по себе API у ScaleGestureDetector довольно хороший — повесил listener и ждешь себе эвенты, не забыв передавать все onTouchEvent()'ы в сам детектор. Однако, не всё так радужно.

2.8.1. Небольшие замечания


Во-первых, нигде не сказано, как разграничить внутри onTouchEvent() эвенты между собственно самим ScaleGestureDetector и ScrollView (ведь, напоминаю, дело происходит внутри RankingsListView, который является наследником ScrollView). Как итог, метод выглядит так:
@Override
public boolean onTouchEvent(MotionEvent ev) {
   scaleDetector.onTouchEvent(ev); 
   super.onTouchEvent(ev); 
   return true;
}

И таким его советуют делать все stackoverflow-ответы (пример). Однако такой подход обладает недостатком. Скролл происходит даже тогда, когда вы производите пинч. Может показаться что это пустяк, но на деле очень неприятно случайно листнуть вьюшку, когда пытался её прозумить.

Я был готов долго и нудно рыскать в поисках сложного решения разграничения ответственности между super.onTouchEvent() и scaleDetector.onTouchEvent()… и я и правда искал… Но как оказалось, решение было ужасно простое:

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

Гениально, да? super.onTouchEvent() не отслеживает id пальца, которым был произведен скролл в первый раз, поэтому даже если вы начали скролл пальцем №1, а закончили пальцем №2 — ему норм, схавает. К сожалению, я так был уверен, что Android SDK в который раз вставит палки в колеса, что удосужился попробовать подобное только после: гуглинга и изучения исходников. Что сказать, Android SDK умеет иногда работать как надо удивлять.

Во-вторых, если вы страдаете микро оптимизационной болезнью, то вам придётся следить за размером ваших дочерних вью с особой осторожностью. Как вы уже знаете, при пинче я увеличиваю высоту для child внутри child у ScrollView. Этим child«ом является LinearLayout, дочерним элементам которого прописан layout_weight=1. Иначе говоря, все они одной высоты… хотя нет.

Это совершенно никогда не заметно, но его дочерние вью не всегда могут быть одной высоты, ведь пиксели — атомарные единицы. То есть, если LinearLayout имеет высоту 1001 и у него 2 дочерних элемента, то один из них будет размером 501, а другой 500. Заметить это на глаз практически нереально, но вот косвенные последствия могут быть.

Когда я говорил про ViewPropertyAnimator и reverse(), я показал анимацию сокрытия иконки ранга. Сама проверка простая — суммируем высоту 2-ух TextView и ImageView в onLayout(), и если они не влазят внутрь текущей вьюшки разом, то прячем fade«ом ImageView. Стоит отметить также, что эта суммарная высота (так сказать «порог высоты») не меняется. Как итог, если порог равен 500 пикселей, то в описанном случае, у одного вью размером 500 иконка спрячется, а у второго, размером 501, нет.

Ситуация редкая и не слишком критичная (было не так уж просто (но и не сложно) двигать мышкой так медленно, чтобы обнаружить не скрытые и скрытые иконки одновременно). Но всё же если вам не нравится такое поведение, исправить это можно только одним способом — не использовать getHeight() для сверки с порогом. В onSizeChanged() внутри LinearLayout«а находите наименьший размер у всех дочерних элементов и оповещаете всех о том, чтобы они сравнивали порог именно с этим числом. Я назвал это shared height и выглядит у меня это так:

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

А сама сверка с пороговым значением так:
int requiredHeight = binding.tvScore.getHeight() + binding.tvTitle.getHeight() + binding.ivIcon.getHeight();
boolean shouldHideIcon = requiredHeight > sharedHeight;

2.8.2. Проблемы


А теперь поговорим не о придирках к ScaleGestureDetector, а о его проблемах.

2.8.2.1. Минимальный пинч


8784e2f99999444b8c1b76de6a48f820.gif
И-и-и-и-и-и… он (минимальный пинч) не отключаем. Во время кодинга я ещё не знал ни о какой «минимальной дистанции для срабатывания пинча», поэтому мне пришлось чуток поизучать логов, чтобы понять, косяк ли это в моем коде или же в чем-то ещё. Логи гласили, что если расстояние между пальцами было менее 510 пикселей, то ScaleGestureDetector просто переставал реагировать на касания, присылая эвент onScaleEnd(). Информации о том, что есть какой-то там «минимальный пинч» не присутствовала ни в доках, ни на stackoverflow. Возможно, я бы даже не заметил подобное, если бы отладка происходила не на эмуляторе. На нём дистанция пинча может быть хоть миллиметровой, что и послужило поводом для поиска информации по вопросу. Однако, она оказалась куда ближе, чем я думал, а именно, как всегда, в исходниках:
mMinSpan = res.getDimensionPixelSize(com.android.internal.R.dimen.config_minScalingSpan);

И-и-и-и-и… конечно же у класса нет методов для изменения этого поля, и конечно же com.android.internal.R.dimen.config_minScalingSpan равен магическим 27mm. Для меня вообще существование минимального пинча представляется очень странным явлением. Даже если и есть смысл в подобном, почему не дать возможность его изменить?
Решением проблемы как обычно является рефлексия.

2.8.2.2. Слоп


Для тех, кто не знает, что такое «слоп» (как и я), перевожу:
Slop — чушь, бессмыслица © ABBYY Lingvo

Ладно-ладно, шутки в сторону. «Слоп» это такое состояние, когда считается, что юзер случайно задел экран и на самом деле не хотел ничего двигать/скроллить/зумить/ещё чего. Эдакая «защита от случайного движения». Гифка объяснит:
88cd44bb1882492ea64c467dae7d06f7.gif
… на гифке видно, что до начала пинча допускаются минимальные движения, которые не будут считаться пинчем. В принципе, хорошая вещь, плюсую.

Но… слоп то тоже не изменяем! ScaleGestureDetector описывает его так:

mSpanSlop = ViewConfiguration.get(context).getScaledTouchSlop() * 2;
…, а ViewConfigeuration так:
mTouchSlop = res.getDimensionPixelSize(
        com.android.internal.R.dimen.config_viewConfigurationTouchSlop);

Откуда именно такое значение? Зачем? Почему? Непонятно… В общем, Android SDK — это лучший учебник по рефлексии.

2.8.2.3. Скачки detector.getScaleFactor() при первом пинче


Внутрь эвента onScale(ScaleGestureDetector detector) передается detector, у которого при помощи метода detector.getScaleFactor() можно узнать коэффициент пинча. Так вот, при самом первом пинче, этот метод возвращает странные скачкообразные значения. Вот логи значений при строго движении zoom out:
0.958
0.987
0.970
1.009
0.966
0.967
1.006

Выглядело это, мягко говоря, не очень — постоянно дергался размер вьюшки, а уж как анимации при этих скачках выглядели — лучше и вовсе опустить.

Я долго пытался понять, в чем же проблема. Проверял на реальном устройстве (мало ли, вдруг проблема эмулятора), двигал мышкой так, будто за лишний миллиметр движения где-то умирает котик (мало ли, вдруг я дерганный и отсюда коэф. > 1 в логах) –, но нет. Ответ найден не был, но мне повезло. Так сказать «от балды», решил попробовал отправить в ScaleGestureDetector эвент MotionEvent.ACTION_CANCEL сразу после инициализации:

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

… и это помогло О_о… Покопавшись (который уже раз, а, Android SDK?) в исходниках, обнаружилось, что у первого пинча нет слопа (да-да, это того, о котором писалось выше). Почему так — для меня осталось загадкой, ровно как и то, почему этот хак помог. Вероятнее всего где-то они намудрили с инициализацией и часть класса считает, что самый первый пинч уже прошел проверку на слоп, а другая часть считает, что нет, и в итоге в пылу жаркой битвы вида «прошел / не прошел» попеременно побеждает одна из них. ¯\_(ツ)_/¯ © SDK

2.9. ScrollView.setScroll() срабатывает только после super.onLayout()?


Возвращаемся к проблеме с игнорированием ScrollView установленных дочерними элементами height«ов. Ситуация следующая: как только происходит пинч, хотелось бы чтобы текущий фокус (куда ты кликнул мышкой и из какой точки вообще делаешь зум) остался тем же. Почему? Ну просто так зум выглядит более user-friendly, считай, ты не просто изменяешь height дочернего элемента, а именно зумишь к какому-то юзеру, в то время как все остальные разъезжаются от него:
9eeee186aaf34828baa290a15c45c4f4.gif
Сделать это не сложно через ScaleGestureDetector.getFocusY() вместе с ScrollView.getScrollY(). Затем, казалось бы, достаточно сделать ScrollView.setScrollY(newPosition) и дело в шляпе… Но нет, вью начинает странно дергаться при зуме к самому нижнему дочернему элементу:
5240fb494dff40e5a25fa675b2296ba9.gif
Решение нашлось здесь. Происходит следующее: в момент, когда мы делаем setScroll() происходит проверка того, не выходит ли позиция скролла за размер дочернего элемента, и если выходит, то устанавливаем максимально возможную позицию. А так как setScroll() вызывается при зуме, то и получается следующая последовательность действий:
  1. Просчитываем newHeight
  2. Просчитываем newScrollPos
  3. Устанавливаем newHeight через setLayoutParams ()
  4. Устанавливаем newScrollPos через setScroll ()

Проблема в пункте №3. Реальный height не изменится до следующего super.onLayout(), поэтому setScroll() и делает не то, что ожидается. Исправляется следующий образом. Вместо setScroll() делаем так:
nextScrollPos = newScrollY;
…, а в onLayout() после метода super.onLayout() вызываем этот метод:
private void updateScrollPosition() { 
   if (nextScrollPos != null) {
      setScrollY(nextScrollPos.intValue());
      nextScrollPos = null;
   }
}

Как вы помните, я писал, что лучше бы не вызывать никаких методов после super.onLayout(). Это по-прежнему так. Позже я опишу ещё одну проблему, связанную с этим решением. Но факт остается фактом, это — решение для проблемы скачка скролла.

p.s. Однако если не использовать хак с «меняем height для child внутри child внутри ScrollView», то такой проблемы не будет. Но тогда возвращаемся к проблеме десятка подклассов.

3. Задача кластеризации юзеров


Теперь время поговорить о самой задаче, её алгоритме и некоторых её особенностях.

3.1. Что делать с граничными юзерами?


Я говорю о юзерах, которые находятся на границе своего ранга:
4e7c66e6a0204965a9ba1ab445613563.png
На картинке граничные юзеры — это юзеры с очками 0%, 30%, 85% (его почти полностью перекрыл юзер с 80%). Самым простым способом было бы рисовать их на своей законной позиции (равной: $height * score/100$), но в таком случае они начнут заезжать в чужие ранги, что выглядело бы мягко говоря «не комильфо». Основная причина отказа от этого — группировка. Представьте себе юзера с 29% очков. Он находится на границу ранга «Newbie». Но вот он вдруг объединяется с юзером с 33% очками и их совместная группа теперь расположена в позиции, соответствующей 31%, т.е., в ранге «Good». Мне не очень понравилась идея, что группировка вьюшек может менять ранги юзеров, поэтому от неё я отказался и решил ограничить юзеров внутри рангов так, как вы видели на картинке выше.

Забегая вперед отмечу, что это добавило очень много мороки алгоритму группировку и логике работы приложения в целом.

3.2. Какое кол-во очков показывать у группы?


Допустим, в группу объединились 2 юзера с очками 40% и 50%. Где расположить их совместную группу и с какими очками? Ответ простой: 45% у группы, позиция соответствует очкам группы.

Усложним задачу. А этих как объединить?
061cf3fd32be4381b281e682bfd641a7.png
Здесь есть 2 способа решения:

  1. Так же, как с юзерами 40% и 50%. То есть, ставим группу на позицию 2.5%.
  2. Ставим группу в позицию $(view1.positionY – view2. positionY) / 2$, а очки уже вычисляем из позиции вьюшки в пикселях.

Разница подходов в том, что в первом случае вьюшка группы будет не по центру между вьюшками юзеров, во втором же она будет именно по центру, что куда более благоприятно с точки зрения user experience.

Предпочитая UX, я решил делать именно способом №2, однако у подхода обнаружился существенный недостаток: очки группы вычисляются совершенно криво. Дело в том, что юзер с 0% на самом деле расположен не в позиции 0 (ведь в таком случае он бы заезжал на территорию чужого ранга), поэтому можно сказать что у всех вьюшек в ранге не может быть очков меньше, чем некоторый minScore, который ещё и меняется при зуме, ведь вычисляется по формуле (упрощенная версия):

$minScore = (userViewHeight / containerHeight) * rankScoreMax$


… так как userViewHeight неизменен, а containerHeight меняется, то в разные моменты пинча очки у граничных вью будут разные.
Более того, сама формула:

$viewGroup.positionY = (view1.positionY – view2.positionY) / 2$


… привносит ошибку округления, т.к. позиция измеряется в пикселях, которая добавляет случайность последней цифре в числе очков (на самом деле всё не совсем так. Если использовать view.getY(), то она возвращает float, а не пиксели, и всё в порядке, а вот если испоьзовать MarginLayoutParams.topMargin, то да, ошибка будет).

Учитывая все эти недостатки способа №2, было принято решение использовать способ №1, хоть и групповая вьюшка будет появляться не точно по центру между юзер-вьюшками.

3.3. Алгоритм


Вот уж где можно разгуляться. Что я и сделал. У меня было по крайней мере 5 различных реализаций, которые так или иначе были подвержены «неприятным» эффектам, которые вынуждали меня вновь и вновь решать проблему по-новому.

Я мог бы написать финальную реализацию и закончить на этом, но всё же предпочел описать несколько своих реализаций. Если вам интересна описанная ниже проблема кластеризации, подумайте, как бы вы её решили (какой алгоритм бы придумали/использовали), а уже затем читайте абзац за абзацем, меняя своё решение, если оно, также как было моё, подвержено артефактам.

3.3.1. Задача


Сделать кластеризацию с хорошим UX, а это значит, что:
  1. Граничные вью должны оставаться внутри ранга.
  2. Юзеры должны объединяться в строго заданном порядке — не допускается, что при zoom in с последующим zoom out и повторным zoom in юзеры будут объединяться в группы как-то иначе.
  3. Юзеры устанавливаются единожды и больше не добавляются/удаляются (правило добавлено из соображений по пункту №2).

3.3.2. Пару слов об алгоритмах


Важно отметить, что во всех реализациях перед запуском самого алгоритма производится сортировка всех юзеров по их очкам и каждый юзер оборачивается в класс Group, ведь по сути, одиночный юзер — это просто группа из одного человека. Также, используя слово «юзер» я могу подразумевать как одного юзера до объединения в группу, так и группу юзеров.

По поводу обозначений. Я буду отмечать номер юзера одной цифрой:»7», —, а группы — двумя:»78». Цифры в номере группы обозначают номера юзеров в неё входящих, то есть группа 78 состоит из юзеров 7 и 8.

К каждому алгоритму будет приведено как словесное описание, так и псевдокод.

3.3.3. Алгоритм №1


Простой последовательный алгоритм, объединяющий юзеров в порядке следования, если они пересекаются.

Шаги:
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;
	}
}

Но у этого алгоритма есть проблема с UX:
f8df5d82e0a84a49a2b68ff0572196c0.png
Он объединяет всех юзеров воедино, если те достаточно близки изначально, что приводит к нерациональному использованию пространства, а значит — алгоритм не подходит по 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» (название поясню позже):
d0b6e4b1f5364191ad70a6a703678863.png
Объясню, что здесь произошло. При запуске алгоритма, было выявлено, что 1 и 2 юзеры пересекаются и образуют группу. С их группой пересекается 3ий юзер,

© Habrahabr.ru