Привет, Хабрамир! Меня зовут Оксана и я 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)


  1. maniacscientist
    19.10.2017 23:58

    Кхе-кхе. А тоже самое для iOS? Нет, NSAttributedString не надо, по толщине это тот же WebView…


    1. nubideus
      20.10.2017 14:34

      Поскольку в задании буквы выделяются не стандартным выделением, можно слова отобразить вообще без текстового поля, и соответственно не использовав при этом NSAttributedString, если у Вас есть какие то предрассудки на этот счет.
      То есть отрисовывать отдельно каждую букву. В ios есть средства, с помощью которых можно получить вектор каждого символа(CGPath) и получить координаты и размеры символов.


  1. ainoneko
    20.10.2017 07:22

    Правила выделения нужны такие:
    • кликаем на букву, она подсвечивается
    • если подсвечена одна буква, и на нее кликнуть повторно — подсветка сбрасывается
    • если подсвечена одна буква, и кликнуть на другую — будут подсвечены эти две плюс все что между ними
    • если подсвечено больше одной буквы и кликнуть на любую — будет подсвечена только эта любая
    И кто придумал именно такие правила?
    Почему нет «кликнуть на первую букву и протащить до последней»?
    Это всё для идеальных роботов: нет возможности сдвинуть выделение на одну букву — будет выделена только одна.