Last active
October 14, 2025 17:04
-
-
Save sunmeat/80b600dfe44f2878b91ec45fad750328 to your computer and use it in GitHub Desktop.
justify text android (simple version)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| JTextView.java: | |
| package com.alex.controls; | |
| import android.content.Context; | |
| import android.content.res.ColorStateList; | |
| import android.content.res.Resources; | |
| import android.content.res.TypedArray; | |
| import android.graphics.Canvas; | |
| import android.graphics.Paint; | |
| import android.graphics.Paint.FontMetricsInt; | |
| import android.support.annotation.Nullable; | |
| import android.text.TextPaint; | |
| import android.util.AttributeSet; | |
| import android.view.View; | |
| import java.util.ArrayList; | |
| public class JTextView extends View { | |
| Paint mTextPaint; | |
| String mText; | |
| ArrayList<TextBlockDrawable> mTextBlocksDrawable; | |
| int mTextSize; | |
| ColorStateList mTextColor; | |
| int mCurTextColor; | |
| int w, h; | |
| float[] widths; | |
| float minSymWidth; | |
| int font_descent; | |
| int font_interline; | |
| int font_line_height; | |
| int mLinesCount; | |
| LineBreaker mLineBreaker; | |
| public JTextView(Context context) { | |
| this(context, null); | |
| } | |
| public JTextView(Context context, AttributeSet attrs) { | |
| this(context, attrs, 0); | |
| } | |
| public JTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { | |
| this(context, attrs, defStyleAttr, 0); | |
| } | |
| public JTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { | |
| super(context, attrs, defStyleAttr, defStyleRes); | |
| ColorStateList textColor = null; | |
| mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); | |
| final Resources.Theme theme = context.getTheme(); | |
| String text = ""; | |
| int textSize = 15; | |
| TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.JustifiedTextView, defStyleAttr, defStyleRes); | |
| int n = a.getIndexCount(); | |
| for (int i = 0; i < n; i++) { | |
| int attr = a.getIndex(i); | |
| switch (attr) { | |
| case R.styleable.JustifiedTextView_text: | |
| text = a.getString(attr); | |
| break; | |
| case R.styleable.JustifiedTextView_textColor: | |
| textColor = a.getColorStateList(attr); | |
| break; | |
| case R.styleable.JustifiedTextView_textSize: | |
| textSize = a.getDimensionPixelSize(attr, textSize); | |
| break; | |
| } | |
| } | |
| a.recycle(); | |
| setTextColor(textColor != null ? textColor : ColorStateList.valueOf(0xFF000000)); | |
| setRawTextSize(textSize); | |
| setText(text); | |
| mLineBreaker = new LineBreaker(); | |
| } | |
| @Override | |
| protected void onDraw(Canvas canvas) { | |
| super.onDraw(canvas); | |
| for (TextBlockDrawable textBlockDrawable : mTextBlocksDrawable) { | |
| canvas.drawText(mText, textBlockDrawable.start, textBlockDrawable.end, textBlockDrawable.x, textBlockDrawable.y, mTextPaint); | |
| } | |
| } | |
| @Override | |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { | |
| super.onMeasure(widthMeasureSpec, heightMeasureSpec); | |
| int heightMode = MeasureSpec.getMode(heightMeasureSpec); | |
| int widthSize = MeasureSpec.getSize(widthMeasureSpec); | |
| int heightSize = MeasureSpec.getSize(heightMeasureSpec); | |
| if (w != widthSize) { | |
| w = widthSize; | |
| mLineBreaker.buildTextBlocks(); | |
| } | |
| if (heightMode == MeasureSpec.EXACTLY) { | |
| h = heightSize; | |
| } else { | |
| if (mText.isEmpty()) { | |
| h = font_interline; | |
| } else { | |
| h = mLinesCount * font_interline + font_descent; | |
| } | |
| } | |
| setMeasuredDimension(w, h); | |
| } | |
| class LineBreaker { | |
| int x, y; | |
| int posLenStart; | |
| int spacesLen; | |
| int posEOL; | |
| int len; | |
| ArrayList<TextBlockDrawable> words; | |
| TextBlockDrawable textBlockDrawable; | |
| private void buildTextBlocks() { | |
| init(); | |
| if (len == 0) return; | |
| initNewLine(0, 0); | |
| for (int pos = 0; pos < len; pos++) { | |
| if (mText.charAt(pos) == ' ') { | |
| spacesLen += minSymWidth; | |
| finishLine(pos); | |
| words.add(textBlockDrawable); | |
| while (mText.charAt(++pos) == ' ') { | |
| spacesLen += minSymWidth; | |
| } | |
| initNewLine(0, pos); | |
| } | |
| if (x + widths[pos] + spacesLen > w) { | |
| //scan back for first space | |
| posEOL = pos; | |
| do { | |
| if (mText.charAt(pos) == ' ') { | |
| pos++; | |
| break; | |
| } | |
| } while (--pos > posLenStart); | |
| //single word does not fit in single line | |
| if (pos == posLenStart) { | |
| pos = posEOL; | |
| newLineResetValues(); | |
| finishLine(pos); | |
| initNewLine(0, pos); | |
| posLenStart = pos; | |
| x += widths[pos]; | |
| continue; | |
| } else if (pos < 0) { | |
| //cant fit even 1 char | |
| return; | |
| } | |
| redistributeSpaces(); | |
| newLineResetValues(); | |
| textBlockDrawable.x = x; | |
| textBlockDrawable.y = y + font_line_height; | |
| posLenStart = pos; | |
| } | |
| x += widths[pos]; | |
| } | |
| textBlockDrawable.end = len; | |
| mTextBlocksDrawable.add(textBlockDrawable); | |
| mLinesCount++; | |
| } | |
| private void init() { | |
| y = 0; | |
| x = 0; | |
| posLenStart = 0; | |
| spacesLen = 0; | |
| mLinesCount = 0; | |
| words = new ArrayList<>(); | |
| len = mText.length(); | |
| mTextBlocksDrawable.clear(); | |
| } | |
| private void initNewLine(int yOffset, int pos) { | |
| textBlockDrawable = new TextBlockDrawable(x, y + font_line_height + yOffset, pos); | |
| } | |
| private void finishLine(int pos) { | |
| textBlockDrawable.end = pos; | |
| mTextBlocksDrawable.add(textBlockDrawable); | |
| } | |
| private void newLineResetValues() { | |
| spacesLen = 0; | |
| x = 0; | |
| y += font_interline; | |
| mLinesCount++; | |
| } | |
| private void redistributeSpaces() { | |
| int widthTotal = w; | |
| if (words.size() <= 1) { | |
| words.clear(); | |
| return; | |
| } | |
| for (TextBlockDrawable wrd : words) { | |
| for (int i = wrd.start; i < wrd.end; i++) { | |
| widthTotal -= widths[i]; | |
| } | |
| } | |
| int wordsCount = words.size() - 1; | |
| int spaceLen = widthTotal / wordsCount; | |
| int spacesMod = widthTotal % wordsCount; | |
| int spacesShift = 0; | |
| for (TextBlockDrawable word : words) { | |
| word.x += spacesShift; | |
| spacesShift += spaceLen; | |
| if (spacesMod-- > 0) { | |
| spacesShift++; | |
| } | |
| } | |
| words.clear(); | |
| } | |
| } | |
| public void setTextColor(ColorStateList colors) { | |
| mTextColor = colors; | |
| int color = mTextColor.getColorForState(getDrawableState(), 0); | |
| if (color != mCurTextColor) { | |
| mCurTextColor = color; | |
| mTextPaint.setColor(mCurTextColor); | |
| } | |
| } | |
| private void setRawTextSize(int size) { | |
| if (size != mTextPaint.getTextSize()) { | |
| mTextSize = size; | |
| mTextPaint.setTextSize(size); | |
| FontMetricsInt fm = mTextPaint.getFontMetricsInt(); | |
| font_descent = fm.descent; | |
| font_interline = fm.descent - fm.ascent; | |
| font_line_height = -fm.top; | |
| float[] widths = new float[1]; | |
| mTextPaint.getTextWidths(" ", widths); | |
| minSymWidth = widths[0]; | |
| } | |
| } | |
| public void setText(String text) { | |
| mText = text; | |
| mTextBlocksDrawable = new ArrayList<>(); | |
| widths = new float[mText.length()]; | |
| mTextPaint.getTextWidths(mText, widths); | |
| if (w == 0) return; | |
| mLineBreaker.buildTextBlocks(); | |
| invalidate(); | |
| } | |
| static class TextBlockDrawable { | |
| int x; | |
| int y; | |
| int start; | |
| int end; | |
| TextBlockDrawable(int x, int y, int start) { | |
| this.start = start; | |
| this.x = x; | |
| this.y = y; | |
| } | |
| } | |
| } | |
| =========================================================================================== | |
| MAinActivity.java: | |
| package com.alex.controls; | |
| import android.os.Bundle; | |
| public class MainActivity extends AppCompatActivity { | |
| @Override | |
| protected void onCreate(Bundle savedInstanceState) { | |
| super.onCreate(savedInstanceState); | |
| setContentView(R.layout.activity_main); | |
| setTitle("Justify Text"); | |
| } | |
| } | |
| =========================================================================================== | |
| activity_main.xml: | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| xmlns:app="http://schemas.android.com/apk/res-auto" | |
| android:layout_width="match_parent" | |
| android:layout_height="wrap_content" | |
| android:orientation="vertical"> | |
| <com.alex.fragments.JTextView | |
| android:id="@+id/jtv" | |
| android:layout_width="wrap_content" | |
| android:layout_height="wrap_content" | |
| android:layout_margin="20dp" | |
| android:layout_marginTop="10dp" | |
| app:text="Мне всегда было не особо интересно как устроены View и отрисовка текста на низком уровне. Я знал примерно основы, что есть onLayout(), onMeasure(), onDraw() где и творится вся магия. Однако в последнее время все чаще стали поступать предложения о разработке собственных View-компонентов с особой логикой работы с текстом и, в результате, было принято решение разобраться во всех тонкостях внутренней кухни создания и управления отображением своих View. Для примера было взято создание компонента для отображения текста с автоматическим выравниванием по ширине: | |
| Текст с автоматическим выравниванием по ширине | |
| При помощи простого TextView нет возможности добиться отображения текста так как мы привыкли это видеть в книжной типографии. Да, начиная с API 23 появилась возможность добавлять расстановку переносов (breakStrategy и hyphenationFrequency но, во первых, это никак не влияет на выравнивание текста по ширине, во вторых, опции доступны только в API 23 и выше. | |
| Перед тем как приступать к реализации, разберемся с теорией. Итак, нам нужно чтобы правый край текста был выровнен вдоль одной вертикальной линии так же как сейчас мы видим выровнен левый (Типографский термин: Выключка по формату). Этого добиться можно следующими способами: увеличением размеров пробелов между словами и увеличением межсимвольного интервала, т.е. трекинга. | |
| Настройка трекинга появилась только в API 21 (setLetterSpacing) и реализовывать его вручную для предыдущих API не очень хочется, к тому же необходимо очень осторожно использовать изменение межсимвольного расстояния, поскольку в этом случае теряется однородность текста и он начинает выглядеть некрасиво. Поэтому пока не будем трогать трекинг и возьмемся за размеры пробелов. | |
| Прежде чем приступить к алгоритму, давайте разберемся с тем как в Android происходит отрисовка текста. Главная за отрисовку - функция onDraw(Canvas canvas), в которой собственно и должны происходить все действия по отображению. Самый простой способ нарисовать текст на canvas, это использовать функцию canvas.drawText(text, start, end, x, y, paint). Но для того чтобы понять, какие подставлять x и y, давайте посмотрим, как в Android выглядит координатная сетка:" | |
| app:textColor="@android:color/secondary_text_light" | |
| app:textSize="14sp" /> | |
| </LinearLayout> | |
| =============================================================================================================================== | |
| res \ values\ attrs.xml: | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <resources> | |
| <declare-styleable name="JustifiedTextView"> | |
| <attr name="text" format="string" localization="suggested" /> | |
| <attr name="textColor" format="reference|color" /> | |
| <attr name="textSize" format="dimension"/> | |
| </declare-styleable> | |
| </resources> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment