How to Break Styled Text into Pages in Android

How to break styled text into pages in Android?

UPDATE: I created sample application, that shows how to use PageSplitter.

How it works? Example application (Russian) - Cleverum. You need only PageSplitter class. Other code shows you how to use this class.

import android.graphics.Typeface;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.style.StyleSpan;

import java.util.ArrayList;
import java.util.List;

public class PageSplitter {
private final int pageWidth;
private final int pageHeight;
private final float lineSpacingMultiplier;
private final int lineSpacingExtra;
private final List<CharSequence> pages = new ArrayList<CharSequence>();
private SpannableStringBuilder currentLine = new SpannableStringBuilder();
private SpannableStringBuilder currentPage = new SpannableStringBuilder();
private int currentLineHeight;
private int pageContentHeight;
private int currentLineWidth;
private int textLineHeight;

public PageSplitter(int pageWidth, int pageHeight, float lineSpacingMultiplier, int lineSpacingExtra) {
this.pageWidth = pageWidth;
this.pageHeight = pageHeight;
this.lineSpacingMultiplier = lineSpacingMultiplier;
this.lineSpacingExtra = lineSpacingExtra;
}

public void append(String text, TextPaint textPaint) {
textLineHeight = (int) Math.ceil(textPaint.getFontMetrics(null) * lineSpacingMultiplier + lineSpacingExtra);
String[] paragraphs = text.split("\n", -1);
int i;
for (i = 0; i < paragraphs.length - 1; i++) {
appendText(paragraphs[i], textPaint);
appendNewLine();
}
appendText(paragraphs[i], textPaint);
}

private void appendText(String text, TextPaint textPaint) {
String[] words = text.split(" ", -1);
int i;
for (i = 0; i < words.length - 1; i++) {
appendWord(words[i] + " ", textPaint);
}
appendWord(words[i], textPaint);
}

private void appendNewLine() {
currentLine.append("\n");
checkForPageEnd();
appendLineToPage(textLineHeight);
}

private void checkForPageEnd() {
if (pageContentHeight + currentLineHeight > pageHeight) {
pages.add(currentPage);
currentPage = new SpannableStringBuilder();
pageContentHeight = 0;
}
}

private void appendWord(String appendedText, TextPaint textPaint) {
int textWidth = (int) Math.ceil(textPaint.measureText(appendedText));
if (currentLineWidth + textWidth >= pageWidth) {
checkForPageEnd();
appendLineToPage(textLineHeight);
}
appendTextToLine(appendedText, textPaint, textWidth);
}

private void appendLineToPage(int textLineHeight) {
currentPage.append(currentLine);
pageContentHeight += currentLineHeight;

currentLine = new SpannableStringBuilder();
currentLineHeight = textLineHeight;
currentLineWidth = 0;
}

private void appendTextToLine(String appendedText, TextPaint textPaint, int textWidth) {
currentLineHeight = Math.max(currentLineHeight, textLineHeight);
currentLine.append(renderToSpannable(appendedText, textPaint));
currentLineWidth += textWidth;
}

public List<CharSequence> getPages() {
List<CharSequence> copyPages = new ArrayList<CharSequence>(pages);
SpannableStringBuilder lastPage = new SpannableStringBuilder(currentPage);
if (pageContentHeight + currentLineHeight > pageHeight) {
copyPages.add(lastPage);
lastPage = new SpannableStringBuilder();
}
lastPage.append(currentLine);
copyPages.add(lastPage);
return copyPages;
}

private SpannableString renderToSpannable(String text, TextPaint textPaint) {
SpannableString spannable = new SpannableString(text);

if (textPaint.isFakeBoldText()) {
spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, spannable.length(), 0);
}
return spannable;
}
}

First, you have to create PageSplitter object with pageWidth and pageHeight (in pixels) which you can get from View.getWidth() and View.getHeight():

ViewPager pagesView = (ViewPager) findViewById(R.id.pages);
PageSplitter pageSplitter = new PageSplitter(pagesView.getWidth(), pagesView.getHeight(), 1, 0);

lineSpacingMultiplier and lineSpacingExtra must have same values as lineSpacingMultiplier and lineSpacingExtra attributes of TextViews which will keep page texts.

Using PageSplitter.append() method you can append text which will be measured with textPaint:

TextPaint textPaint = new TextPaint();
textPaint.setTextSize(getResources().getDimension(R.dimen.text_size));
for (int i = 0; i < 1000; i++) {
pageSplitter.append("Hello, ", textPaint);
textPaint.setFakeBoldText(true);
pageSplitter.append("world", textPaint);
textPaint.setFakeBoldText(false);
pageSplitter.append("! ", textPaint);
if ((i + 1) % 200 == 0) {
pageSplitter.append("\n", textPaint);
}
}

Then by using PageSplitter.getPages() method you can get original text splitted to pages and put each of them into TextView:

pagesView.setAdapter(new TextPagerAdapter(getSupportFragmentManager(), pageSplitter.getPages()));

TextPagerAdapter:

public class TextPagerAdapter extends FragmentPagerAdapter {
private final List<CharSequence> pageTexts;

public TextPagerAdapter(FragmentManager fm, List<CharSequence> pageTexts) {
super(fm);
this.pageTexts = pageTexts;
}

@Override
public Fragment getItem(int i) {
return PageFragment.newInstance(pageTexts.get(i));
}

@Override
public int getCount() {
return pageTexts.size();
}
}

PageFragment:

public class PageFragment extends Fragment {
private final static String PAGE_TEXT = "PAGE_TEXT";

public static PageFragment newInstance(CharSequence pageText) {
PageFragment frag = new PageFragment();
Bundle args = new Bundle();
args.putCharSequence(PAGE_TEXT, pageText);
frag.setArguments(args);
return frag;
}

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
CharSequence text = getArguments().getCharSequence(PAGE_TEXT);
TextView pageView = (TextView) inflater.inflate(R.layout.page, container, false);
pageView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.text_size));
pageView.setText(text);
return pageView;
}
}

where R.layout.page is

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textSize="@dimen/text_size"
android:lineSpacingMultiplier="1"
android:lineSpacingExtra="0sp">
</TextView>

PageSplitter.renderToSpannable() method wraps text to SpannableString according to textPaint settings. In current method implementation I consider only TextPaint.isFakeBoldText() property, but you can also apply other properties. For example, you can apply TextPaint.getTextSize() property with AbsoluteSizeSpan.

Separate long text into pages for viewpager

StaticLayout or DynamicLayout could do this.
Android use (Boring|Static|Dynamic)Layout classes to measure and wrap text, these classes constructor take CharSequence as input param so styled text(contains spans, even ImageSpan) is acceptable.
You can calculate the pageWidth and pageHeight according to your View or Screen, and the TextPaint and two lineSpacing param must equals to your target TextView, here is my code:

import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.StaticLayout;
import android.text.TextPaint;

import java.util.ArrayList;
import java.util.List;

public class PageSplitter {
private final int pageWidth;
private final int pageHeight;
private final float lineSpacingMultiplier;
private final float lineSpacingExtra;
private final List<CharSequence> pages = new ArrayList<CharSequence>();
private SpannableStringBuilder mSpannableStringBuilder = new SpannableStringBuilder();

public PageSplitter(int pageWidth, int pageHeight, float lineSpacingMultiplier, float lineSpacingExtra) {
this.pageWidth = pageWidth;
this.pageHeight = pageHeight;
this.lineSpacingMultiplier = lineSpacingMultiplier;
this.lineSpacingExtra = lineSpacingExtra;
}

public void append(CharSequence charSequence) {
mSpannableStringBuilder.append(charSequence);
}

public void split(TextPaint textPaint) {
StaticLayout staticLayout = new StaticLayout(
mSpannableStringBuilder,
textPaint,
pageWidth,
Layout.Alignment.ALIGN_NORMAL,
lineSpacingMultiplier,
lineSpacingExtra,
false
);
int startLine = 0;
while(startLine < staticLayout.getLineCount()) {
int startLineTop = staticLayout.getLineTop(startLine);
int endLine = staticLayout.getLineForVertical(startLineTop + pageHeight);
int endLineBottom = staticLayout.getLineBottom(endLine);
int lastFullyVisibleLine;
if(endLineBottom > startLineTop + pageHeight)
lastFullyVisibleLine = endLine - 1;
else
lastFullyVisibleLine = endLine;
int startOffset = staticLayout.getLineStart(startLine);
int endOffset = staticLayout.getLineEnd(lastFullyVisibleLine);
pages.add(mSpannableStringBuilder.subSequence(startOffset, endOffset));
startLine = lastFullyVisibleLine + 1;
}
}

public List<CharSequence> getPages() {
return pages;
}
}

break long string into multiple pages

I will not provide you full solution but I've found answers that can help you.

  1. First helps to find how many characters can feet in TextView width.
  2. Second helps to get how many lines TextView exactly shows.

But there is problem with line wrapping. You need to apply some algorithm to find out how text is wrapped by android framework or do this for it. Here is good answer for text wrapping

Paginating text in Android

NEW ANSWER

PagedTextView library (in Kotlin) summarises the below lying algorithm by extending Android TextView. The sample app demonstrates the usage of the library.

Setup

dependencies {
implementation 'com.github.onikx:pagedtextview:0.1.3'
}

Usage

<com.onik.pagedtextview.PagedTextView
android:layout_width="match_parent"
android:layout_height="match_parent" />

OLD ANSWER

The algorithm below implements text pagination in separation of TextView itself lacking simultaneous dynamic change of both the TextView attributes and algorithm configuration parameters.

Background

What we know about text processing within TextView is that it properly breaks a text by lines according to the width of a view. Looking at the TextView's sources we can see that the text processing is done by the Layout class. So we can make use of the work the Layout class does for us and utilizing its methods do pagination.

Problem

The problem with TextView is that the visible part of text might be cut vertically somewhere at the middle of the last visible line. Regarding said, we should break a new page when the last line that fully fits into a view's height is met.

Algorithm

  • We iterate through the lines of text and check if the line's bottom exceeds the view's height;
  • If so, we break a new page and calculate a new value for the cumulative height to compare the following lines' bottom with (see the implementation). The new value is defined as top value (red line in the picture below) of the line that hasn't fit into the previous page + TextView's height.

Sample Image

Implementation

public class Pagination {
private final boolean mIncludePad;
private final int mWidth;
private final int mHeight;
private final float mSpacingMult;
private final float mSpacingAdd;
private final CharSequence mText;
private final TextPaint mPaint;
private final List<CharSequence> mPages;

public Pagination(CharSequence text, int pageW, int pageH, TextPaint paint, float spacingMult, float spacingAdd, boolean inclidePad) {
this.mText = text;
this.mWidth = pageW;
this.mHeight = pageH;
this.mPaint = paint;
this.mSpacingMult = spacingMult;
this.mSpacingAdd = spacingAdd;
this.mIncludePad = inclidePad;
this.mPages = new ArrayList<>();

layout();
}

private void layout() {
final StaticLayout layout = new StaticLayout(mText, mPaint, mWidth, Layout.Alignment.ALIGN_NORMAL, mSpacingMult, mSpacingAdd, mIncludePad);

final int lines = layout.getLineCount();
final CharSequence text = layout.getText();
int startOffset = 0;
int height = mHeight;

for (int i = 0; i < lines; i++) {
if (height < layout.getLineBottom(i)) {
// When the layout height has been exceeded
addPage(text.subSequence(startOffset, layout.getLineStart(i)));
startOffset = layout.getLineStart(i);
height = layout.getLineTop(i) + mHeight;
}

if (i == lines - 1) {
// Put the rest of the text into the last page
addPage(text.subSequence(startOffset, layout.getLineEnd(i)));
return;
}
}
}

private void addPage(CharSequence text) {
mPages.add(text);
}

public int size() {
return mPages.size();
}

public CharSequence get(int index) {
return (index >= 0 && index < mPages.size()) ? mPages.get(index) : null;
}
}

Note 1

The algorithm works not just for TextView (Pagination class uses TextView's parameters in the implementation above). You may pass any set of parameters StaticLayout accepts and later use the paginated layouts to draw text on Canvas/Bitmap/PdfDocument.

You can also use Spannable as yourText parameter for different fonts as well as Html-formatted strings (like in the sample below).

Note 2

When all text has the same font size, all lines have equal height. In that case you might want to consider further optimization of the algorithm by calculating an amount of lines that fits into a single page and jumping to the proper line at each loop iteration.


Sample

The sample below paginates a string containing both html and Spanned text.

public class PaginationActivity extends Activity {
private TextView mTextView;
private Pagination mPagination;
private CharSequence mText;
private int mCurrentIndex = 0;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_pagination);

mTextView = (TextView) findViewById(R.id.tv);

Spanned htmlString = Html.fromHtml(getString(R.string.html_string));

Spannable spanString = new SpannableString(getString(R.string.long_string));
spanString.setSpan(new ForegroundColorSpan(Color.BLUE), 0, 24, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanString.setSpan(new RelativeSizeSpan(2f), 0, 24, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanString.setSpan(new StyleSpan(Typeface.MONOSPACE.getStyle()), 0, 24, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanString.setSpan(new ForegroundColorSpan(Color.BLUE), 700, spanString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanString.setSpan(new RelativeSizeSpan(2f), 700, spanString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanString.setSpan(new StyleSpan(Typeface.MONOSPACE.getStyle()), 700, spanString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

mText = TextUtils.concat(htmlString, spanString);

mTextView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// Removing layout listener to avoid multiple calls
mTextView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
mPagination = new Pagination(mText,
mTextView.getWidth(),
mTextView.getHeight(),
mTextView.getPaint(),
mTextView.getLineSpacingMultiplier(),
mTextView.getLineSpacingExtra(),
mTextView.getIncludeFontPadding());
update();
}
});

findViewById(R.id.btn_back).setOnClickListener(v -> {
mCurrentIndex = (mCurrentIndex > 0) ? mCurrentIndex - 1 : 0;
update();
});
findViewById(R.id.btn_forward).setOnClickListener(v -> {
mCurrentIndex = (mCurrentIndex < mPagination.size() - 1) ? mCurrentIndex + 1 : mPagination.size() - 1;
update();
});
}

private void update() {
final CharSequence text = mPagination.get(mCurrentIndex);
if(text != null) mTextView.setText(text);
}
}

Activity's layout:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin" >

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<Button
android:id="@+id/btn_back"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@android:color/transparent"/>

<Button
android:id="@+id/btn_forward"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@android:color/transparent"/>

</LinearLayout>

<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

</RelativeLayout>

Screenshot:
Sample Image



Related Topics



Leave a reply



Submit