Привет, Хабрамир! Меня зовут Оксана и я 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
Комментарии (3)
ainoneko
20.10.2017 07:22Правила выделения нужны такие:
И кто придумал именно такие правила?
- кликаем на букву, она подсвечивается
- если подсвечена одна буква, и на нее кликнуть повторно — подсветка сбрасывается
- если подсвечена одна буква, и кликнуть на другую — будут подсвечены эти две плюс все что между ними
- если подсвечено больше одной буквы и кликнуть на любую — будет подсвечена только эта любая
Почему нет «кликнуть на первую букву и протащить до последней»?
Это всё для идеальных роботов: нет возможности сдвинуть выделение на одну букву — будет выделена только одна.
maniacscientist
Кхе-кхе. А тоже самое для iOS? Нет, NSAttributedString не надо, по толщине это тот же WebView…
nubideus
Поскольку в задании буквы выделяются не стандартным выделением, можно слова отобразить вообще без текстового поля, и соответственно не использовав при этом NSAttributedString, если у Вас есть какие то предрассудки на этот счет.
То есть отрисовывать отдельно каждую букву. В ios есть средства, с помощью которых можно получить вектор каждого символа(CGPath) и получить координаты и размеры символов.