Грокаем Spans.

Очень часто, листая очередную сторис из инстаграма или просматривая пост в фейсбуке, я замечал, что невольно задумывался - как реализован тот или иной механизм выделения текста. После небольшого исследования я пришел к выводу, что в Android OS уже релизован мощный механизм работы с текстом - Spans.

Замечание: эта статья основана на официальной документации, ответов со stackoverflow, статьи Spans, a Powerful Concept, AOSP и является агргерующей. Так же для этой статьи я написал тестовое приложение, где вы можете посмотреть приемы, которые я применял в работе со Spannable.

Как и когда применять Span?

  • если Span влияет на форматирование отдельных символов, следует наследоваться от CharacterStyle.
  • если Span влияет на форматирование параграфа, следует наследоваться от ParagraphStyle.
  • если Span изменяет внешний вид символа, следует реализовывать UpdateAppearance.
  • если Span меняет размер или иную метрику шрифта, следует реализовывать UpdateLayout.

Окей, как же это работает?

Флаги

Прежде чем разместить Spannable мы должны определиться с тем, а какие варианты размещения вообще существуют. Типичный код, добавления выделения определенной области текста выглядит так:

SpannableString spannableString = new SpannableString("abcd");
BackgroundColorSpan backgroundSpan = new BackgroundColorSpan(Color.RED);
spannableString.setSpan(backgroundSpan, 1 /* start */, 3 /* end */, Spannable.EXCLUSIVE_EXCLUSIVE);
textView.setText(spannableString);

В данном примере сомнения вызывает только флаг SpannableString.EXCLUSIVE_EXCLUSIVE. Всего существует 4 флага и каждый из них отвечает, как будет взаимодействовать текст на котором уже есть Span и строка, которую добавят к уже существующей. Давайте взглянем на это чуть внимательнее. Для этого модифицируем пример:

SpannableStringBuilder spannableString = new SpannableStringBuilder("abcd");
BackgroundColorSpan backgroundColorSpan = new BackgroundColorSpan(Color.RED);
spannableString.setSpan(backgroundColorSpan, 1, 3, flag);
spannableString.insert(3, "x");
spannableString.insert(1, "x");

SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE

Вставляемый текст не будет перенимать Spannable рядом стоящего текста.

Flag EXCLUSIVE EXCLUSIVE

SpannableString.SPAN_EXCLUSIVE_INCLUSIVE

Вставляемый текст никак не будет перенимать Spannable рядом стоящего текста, если он вставлен слева от родителя, и будет если вствлен справа.

Flag EXCLUSIVE INCLUSIVE

SpannableString.SPAN_INCLUSIVE_EXCLUSIVE

Вставляемый текст никак не будет перенимать Spannable рядом стоящего текста, если он вставлен справа от родителя, и будет если вставлен слева.

Flag INCLUSIVE EXCLUSIVE

SpannableString.SPAN_INCLUSIVE_INCLUSIVE

Вставляемый текст будет перенимать Spannable рядом стоящего текста, позиция не важна.

Flag INCLUSIVE INCLUSIVE

Лейаутинг (размещение)

Для размещения и рендеринга текста в TextView применяется класс Layout. Он содержит флаг boolean mSpannedText, который обозначает является ли текст наследником SpannableString. Layout обрабатывает только ParagraphStyle Spans.

Метод draw вызывает два других метода:

  • drawBackground

Для каждой строки текста, если присутствует LineBackgroundSpan, вызывается LineBackgroundSpan#drawBackground.

  • drawText

Для каждой строки текста вычисляется LeadingMarginSpan и LeadingMarginSpan2 и вызывает LeadingMarginSpan#drawLeadingMargin, когда это необходимо. Также используется AlignmentSpan для определения выравнивания текста. Наконец, если все преобразования для текущей строки вычислены, Layout вызывает TextLine#draw(для каждой строки создается объект TextLine).

TextLine

Согласно документации к android.text.TextLine: Представляет строку стилизованного текста готового для измерений и рендеринга.

TextLine класс содержит 3 набора Spans:

  • MetricAffectingSpan
  • CharacterStyle
  • ReplacementSpan

Однако, наибольшее внимание привлекает метод TextLine#handleRun, в котором все Spans-объекты используются для рендеринга текста. В соответсвии со Span, TextLine вызывает:

  • CharacterStyle#updateDrawState для изменения параметров TextPaint полученных от MetricAffectingSpan и CharacterStyle Spans.
  • TextLine#handleReplacement для ReplacementSpan. Этот метод вызывает Replacement#getSize чтобы получить ширину текста, который необходимо заменить, обновить его размеры, если это необходимо, и наконец вызвать Replacement#draw.

FontMetrics

Для того чтобы понять, как работает FontMetrics достаточно просто взглянуть на следующую картинку.

FontMetrics

Playground

Все примеры, описанные в этой статье реализованы в sample приложении.

BulletSpan

android.text.style.BulletSpan

BulletSpan влияет на стиль всего параграфа и позволяет добавить точку в начало параграфа.

/*
public BulletSpan (int gapWidth, int color)
-gapWidth: gap in px between bullet and text
-color: bullet color (optionnal, default is transparent)
*/

// create a cyan BulletSpan with a gap of 8dp
// note: toPx - converts dp to pixels
span = new BulletSpan(toPx(8), Color.CYAN);

BulletSpan

QuoteSpan

android.text.style.QuoteSpan

QuoteSpan влияет на стиль параграфа и позволяет поставить вертикальную полосу цитирования.

/*
public QuoteSpan (int color)
-color: quote vertical line color (optionnal, default is Color.BLUE)
*/

// create a cyan quote
span = new QuoteSpan(Color.CYAN);

QuoteSpan

AlignmentSpan.Standard

android.text.style.AlignmentSpan.Standard

The AlignmentSpan.Standard форматирует стиль параграфа и позволяет поменять выравнивание текста (нормально, центрирован, выровнен по противоположному краю).

/*
public Standard(Layout.Alignment align)
-align: alignment to set
*/

// align center a paragraph
span = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE);

AlignmentSpan.Standard

UnderlineSpan

android.text.style.UnderlineSpan

UnderlineSpan влияет на отдельный символ. Позволяет проставить подчеркивание у отдельно заданных символов, вызывая Paint#setUnderlineText(true).

// underline a character
span = new UnderlineSpan();

UnderlineSpan

StrikethroughSpan

android.text.style.StrikethroughSpan

StrikethroughSpan также изменяет отдельный символ, добавляя к нему зачеркивание. Вызывает: Paint#setStrikeThruText(true)).

// strikethrough a character
span = new StrikethroughSpan();

StrikethroughSpan

SubscriptSpan

android.text.style.SubscriptSpan

SubscriptSpan позволяет опустить символы, относительно базовой линии, с помощью смещения этой линии TextPaint#baselineShift. Влияет на отдельно взятый символ.

// subscript a character
span = new SubscriptSpan();

Subscript

SuperscriptSpan

android.text.style.SuperscriptSpan

SuperscriptSpan поднимает символы относительно базовой линии, путем ее смещения TextPaint#baselineShift. Влияет на отдельно взятый символ.

// superscript a character
span = new SuperscriptSpan();

Superscript

BackgroundColorSpan

android.text.style.BackgroundColorSpan

BackgroundColorSpan устанавливает фон для каждого символа.

/*
public BackgroundColorSpan (int color)
-color: background color
*/

// set a cyan background
span = new BackgroundColorSpan(Color.CYAN);

BackgroundColorSpan

ForegroundColorSpan

android.text.style.ForegroundColorSpan

ForegroundColorSpan устанавливает цвет текста. Влияет на отдельно взятый символ.

/*
public ForegroundColorSpan (int color)
-color: foreground color
*/

// set a cyan foreground
span = new ForegroundColorSpan(Color.CYAN);

ForegroundColorSpan

ImageSpan

android.text.style.ImageSpan

ImageSpan влияет на форматирование символов. Позволяет заменить 1 или несколько символов изображением. Довольно редко используется, но обладает отличной документацией.

//replace a character by cat image
span = new ImageSpan(getApplicationContext(), R.drawable.cat);

ImageSpan

StyleSpan

android.text.style.StyleSpan

StyleSpan позволяет выставить стиль символа (bold, italic, normal).

/*
public StyleSpan (int style)
-style: int describing the style (android.graphics.Typeface)
*/

// set a bold style
span = new StyleSpan(Typeface.BOLD);

StyleSpan

TypefaceSpan

android.text.style.TypefaceSpan

TypefaceSpan позволяет указать font family (monospace, serif etc) отдельно взятого символа.

/*
public TypefaceSpan (String family)
-family: a font family
*/

// set the serif family
span = new TypefaceSpan("serif");

TypefaceSpan

TextAppearanceSpan

android.text.style.TextAppearanceSpan

TextAppearanceSpan влияет на стиль каждого символа. Позволяет гибко настраивать комплексные параметры текста.

/*
public  TextAppearanceSpan(Context context, int appearance, int colorList)
-context: a valid context
-appearance: text appearance resource (ex: android.R.style.TextAppearance_Small)
-colorList: a text color resource (ex: android.R.styleable.Theme_textColorPrimary)

public TextAppearanceSpan(String family, int style, int size, ColorStateList color, ColorStateList linkColor)
-family: a font family
-style: int describing the style (android.graphics.Typeface)
-size: text size
-color: a text color
-linkColor: a link text color
*/

//set the serif family
span = new TextAppearanceSpan(this/*a context*/, R.style.SpecialTextAppearance);

styles.xml

<style name="SpecialTextAppearance" parent="@android:style/TextAppearance">
    <item name="android:textColor">@color/color1</item>
    <item name="android:textColorHighlight">@color/color2</item>
    <item name="android:textColorHint">@color/color3</item>
    <item name="android:textColorLink">@color/color4</item>
    <item name="android:textSize">28sp</item>
    <item name="android:textStyle">italic</item>
</style>

AbsoluteSizeSpan

android.text.style.AbsoluteSizeSpan

AbsoluteSizeSpan позволяет выставить размер отдельно взятого символа в абсолютных величинах (пикселах).

/*
public AbsoluteSizeSpan(int size, boolean dip)
-size: a size
-dip: false, size is in px; true, size is in dip (optionnal, default false)
*/

// set text size to 8dp
span = new new AbsoluteSizeSpan(toPx(8));

AbsoluteSizeSpan

RelativeSizeSpan

android.text.style.RelativeSizeSpan

RelativeSizeSpan позволяет выставить высоту отдельно взятого символа, относительно текущего размера текста.

/*
public RelativeSizeSpan(float proportion)
-proportion: a proportion of the actual text size
*/

// set text size 2 times bigger 
span = new RelativeSizeSpan(2.0f);

RelativeSizeSpan

ScaleXSpan

android.text.style.ScaleXSpan

ScaleXSpan позволяет растянуть текст вдоль оси x. Влияет на отдельно взятый символ.

/*
public ScaleXSpan(float proportion)
-proportion: a proportion of actual text scale x
*/

//scale x 2 times bigger 
span = new ScaleXSpan(2.0f);

ScaleXSpan

MaskFilterSpan

android.text.style.MaskFilterSpan

MaskFilterSpan оказывает влияение на каждый символ по-отдельности. Позволяет устанавливать android.graphics.MaskFilter для символов.

/*
public MaskFilterSpan(MaskFilter filter)
-filter: a filter to apply
*/

// blur a character
span = new MaskFilterSpan(new BlurMaskFilter(toPx(4), BlurMaskFilter.Blur.NORMAL));
// emboss a character
span = new MaskFilterSpan(new EmbossMaskFilter(new float[] { 1, 1, 1 }, 0.4f, 6, 3.5f));
  • BlurMaskFilter

Важно: эта маска не поддерживает аппартаное ускорение.

  • EmbossMaskFilter

MaskFilterSpan

Примеры работы со Spans

Реализуем свой собственный простой Span

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

Во-первых, мы должны отнаследоваться от абстрактного класса ReplacementSpan.

Внимение: если вы хотите только отрисовать собственный фон, необходимо использовать LineBackgroundSpan, который находится на уровне абзаца.

Необходимо реализовать следующие 2 метода:

  • getSize: метод, возвращающий размер вашей замены.

text: текст, управляемый Span start: начальный индекс текста end: конечный индекс текста fm: метрики шрифта (может быть нулем)

  • draw: здесь вы можете рисовать с помощью Canvas.

x: x-координата, где рисовать текст top: верх линии y: базовый уровень (baseline) bottom: нижняя граница линии

FrameSpan

public class FrameSpan extends ReplacementSpan {

    private int width;

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) {
        return  (int) paint.measureText(text, start, end);
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
        int color = paint.getColor();
        paint.setColor(Color.YELLOW);
        width = (int) paint.measureText(text, start, end);
        canvas.drawRect(x, top, x + width, bottom, paint);
        paint.setColor(color);
    }
}

RoundBackgroundSpan

public class RoundBackgroundSpan implements LineBackgroundSpan {

    private final int padding = 0;
    private final RectF backgroundRect = new RectF();

    @Override
    public void drawBackground(Canvas c,
                               Paint p,
                               int left,
                               int right,
                               int top,
                               int baseline,
                               int bottom,
                               CharSequence text,
                               int start, int end, int lnum) {
        if(text == null) return;
        int textWidth = Math.round(p.measureText(text, start, end));
        int paintColor = p.getColor();
        backgroundRect.set(left - padding, top - lnum == 0 ?  padding / 2  :  -(padding / 2),
        left + textWidth + padding, bottom + (padding / 2));
        p.setColor(Color.CYAN);
        c.drawRoundRect(backgroundRect, toPx(4), toPx(4), p);
        p.setColor(paintColor);
    }
}

Анимируем ForegoundColor

ForegroundColorSpan доступен только для чтения. Это означает, что вы не можете изменить цвет переднего плана после создания экзепляра ForegroundColorSpan. Итак, первое, что нужно сделать, это написать MutableForegroundColorSpan.

MutableForegroundColorSpan

public class MutableForegroundColorSpan extends ForegroundColorSpan {

    @ColorInt
    private int foregroundColor;

    public MutableForegroundColorSpan(@ColorInt int color) {
        super(color);
        foregroundColor = color;
    }

    public MutableForegroundColorSpan(Parcel src) {
        super(src);
        foregroundColor = src.readInt();
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        super.writeToParcel(dest, flags);
        dest.writeInt(foregroundColor);
    }

    @Override
    public void updateDrawState(TextPaint ds) {
        ds.setColor(getForegroundColor());
    }

    @Override
    public int getForegroundColor() {
        return foregroundColor;
    }

    public void setForegroundColor(@ColorInt int foregroundColor) {
        this.foregroundColor = foregroundColor;
    }
}

Создаем Property для Animator

    private final Property<MutableForegroundColorSpan, Integer> MUTABLE_FOREGROUND_COLOR_PROPERTY =
            new Property<MutableForegroundColorSpan, Integer>(Integer.class, "MUTABLE_FOREGROUND_COLOR_PROPERTY") {
                @Override
                public Integer get(MutableForegroundColorSpan object) {
                    return object.getForegroundColor();
                }

                @Override
                public void set(MutableForegroundColorSpan object, Integer value) {
                    object.setForegroundColor(value);
                }
            };
        TextView animatedTextView = new TextView(this);
        addContentView(animatedTextView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT));

        SpannableString text = new SpannableString(getString(R.string.text));
        MutableForegroundColorSpan span = new MutableForegroundColorSpan(Color.BLACK);
        text.setSpan(span, 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        animatedTextView.setText(text);

        ObjectAnimator animator = ObjectAnimator.ofInt(span, MUTABLE_FOREGROUND_COLOR_PROPERTY, Color.BLACK, Color.CYAN, Color.DKGRAY);
        animator.setEvaluator(new ArgbEvaluator());
        animator.addUpdateListener(animation -> {
            animatedTextView.invalidate();
        });
        animator.setDuration(3500L);
        animator.start();

Вместо заключения

На мой взгляд, любое мобильное приложение на 90% состоит из текста и картинок, а подобные механизмы расширения имеющихся возможностей, почему-то не пользуются по-полной. Я постарался осветить аспекты работы со Spans, для того чтобы Вы смогли разнообразить свои приложения, имея в руках такой мощный инструмент.