
Привет, Хабрамир! Меня зовут Оксана и я Android-разработчик в небольшой, но очень классной команде Trinity Digital.
Сегодня я буду рассказывать про маленькую часть большого проекта.
Проект зовется “Школа 2100” и представляет собой коллекцию электронных учебников с разными фичами: поиском, закладками-заметками, дополнительными материалами, тестовыми заданиями, etc. И как раз в том, что названо “тестовыми заданиями” кроется предмет обсуждения.
Среди прочих разных тестов есть необходимость реализовать задание на разбор слова по составу (он же морфологический разбор). Выглядеть оно должно примерно так:

Вкратце: есть набор слов — их нужно отобразить в виде прокручиваемого списка. Вверху списка должны быть кнопочки, которые позволяют ассоциировать части слова с определенными морфемами (приставка, корень, суффикс, окончание, основа).
Выделяем часть слова, жмем кнопочку — графическое обозначение морфемы отрисовывается. И, плюс, маленький крестик для удаления.
Чтобы сделать всю эту красоту, нам понадобится реализовать механизм выделения частей слова — собственно, дальше разбираемся именно с этой задачей.
Правила выделения нужны такие:
- кликаем на букву, она подсвечивается
- если подсвечена одна буква, и на нее кликнуть повторно — подсветка сбрасывается
- если подсвечена одна буква, и кликнуть на другую — будут подсвечены эти две плюс все что между ними
- если подсвечено больше одной буквы и кликнуть на любую — будет подсвечена только эта любая
Вдобавок ко всему, между буквами слова должно быть некоторое расстояние (трекинг), большее, чем в стандартном шрифте. Это для того, чтобы потом было удобно рисовать морфемы и они не “слипались” визуально.
Для реализации была выбрана комбинация TextView + Spannable, которая обладает достаточными возможностями и вместе с тем довольно проста в работе.
Вообще, Spannable — это такой интерфейс, который описывает маркировку текста объектами, связанными с форматированием этого текста. Форматирующие объекты — это экземпляры классов, которые реализуют интерфейс ParcelableSpan. Есть готовые реализации (например, UnderlineSpan, ForegroundColorSpan, StrikethoughSpan и прочие), но мы реализуем этот интерфейс сами, потому что нам нужны одновременно трекинг и цвет.
Собственно, только для того чтобы сделать трекинг, уже понадобится кастомная реализация (если и есть готовая, то она не была найдена).
ДЕМО
Итак, переходим на просторы уютненького демо-проекта, в котором будет:

- WordAnswerView — класс-наследник TextView, в котором и будет происходить все самое интересное
- MainActivity — главный экран, там мы создадим экземпляр WordAnswerView
- activity_main.xml — разметочка-контейнер
Начнем с activity_main.xml, там все просто:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" tools:context="ru.trinitydigital.textselecting.MainActivity" android:id="@+id/container"> </RelativeLayout>
Теперь MainActivity:
package ru.trinitydigital.textselecting; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.widget.RelativeLayout; public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RelativeLayout container = (RelativeLayout) findViewById(R.id.container); container.addView(new WordAnswerView(this, "hello", convertDpToPx(30))); } }
И наконец WordAnswerView, будем разбирать поэтапно. Создаем наследника TextView и определяем нужные свойства:
public class WordAnswerView extends TextView { // тут будем хранить исходную строку private final String originalText; // это число определяет, сколько пикселей отведено на трекинг (расстояние между буквами) private float tracking = convertDpToPx(16); // цвет выделения private int selectionColor = Color.parseColor("#5591F6"); // специальное значение для отсуттсвия выделения private static final int NO_SELECTION = -1; // начало и конец выделения (индексы символов в строке) private int selectionBegin = NO_SELECTION, selectionEnd = NO_SELECTION; // та самая штука, которая отвечает за отрисовку трекинга и выделения private SelectionTrackingSpan selectionTrackingSpan = new SelectionTrackingSpan(); // понадобится позже, чтобы определять, на какую букву был клик private int baseWidth; }
В конструкторе вот что:
public WordAnswerView(Context context, CharSequence text, float textSizePx) { super(context); // запоминаем текст originalText = text.toString(); setTextSize(textSizePx); setTextColor(Color.BLACK); // это нужно для того, чтобы на каждую букву приходилась одинаковая ширина, // так будет гораздо удобней отрисовывать морфемы setTypeface(Typeface.MONOSPACE); setPadding((int) tracking, 0, (int) tracking, 0); // на всю строку устанавливаем наш спан, который будет отвечать за форматирование SpannableString s = new SpannableString(originalText); s.setSpan(selectionTrackingSpan, 0, originalText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); setText(s); }
Еще нужно ловить касания, так что добавим кода в конструктор:
public WordAnswerView(Context context, CharSequence text, float textSizePx) { // … setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_UP: // считаем индекс символа, на который кликнули int index = (int)(event.getX() / baseWidth); // и устанавливаем границы выделения согласно описанным выше правилам if (selectionBegin == index && selectionEnd == NO_SELECTION) { selectionBegin = NO_SELECTION; selectionEnd = NO_SELECTION; invalidate(); break; } if (selectionBegin == NO_SELECTION) { selectionBegin = index; } else if (selectionEnd == NO_SELECTION) { selectionEnd = index; if (selectionBegin > selectionEnd) { int tmp = selectionBegin; selectionBegin = selectionEnd; selectionEnd = tmp; } } else { selectionBegin = index; selectionEnd = NO_SELECTION; } invalidate(); break; } return false; } }); }
Кстати, чтобы все у нас было хорошо с определением того, на которую букву кликнули, надо еще добавить вот это:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); baseWidth = w / originalText.length(); }
Добираемся до самой сути — напишем класс SelectionTractingSpan:
public class SelectionTrackingSpan extends ReplacementSpan { @Override public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { // размер будет достаточный для того чтобы нарисовать буквы + расстояния между ними return (int)(paint.measureText(text, start, end) + tracking * (end - start)); } @Override public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { float dx = x; for (int i = start; i < end; i++) { // если символ не попадает в выделенную часть, будем рисовать его просто черным if (i < selectionBegin || i >= (selectionEnd != NO_SELECTION ? selectionEnd + 1 : selectionBegin + 1)) paint.setColor(Color.BLACK); else paint.setColor(selectionColor); canvas.drawText(text, i, i + 1, dx, y, paint); dx += paint.measureText(text, i, i + 1) + tracking; } } }
Итого, рецепт довольно прост:
- Наследуемся от TextView
- Ловим касания и запоминаем границы выделения
- Создаем наследника ReplacementSpan, который будет красить текст в зависимости от этих границ
+ делать трекинг
Profit :)
→ Исходники в нашем github