androidtextpage-breakspannablestringstyledtext

How to break styled text into pages in Android?


I have a long text which I want to break into multiple pages. Also I need a way to style this text.


Solution

  • 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.