在Android中分页文本

43
我正在为Android编写一个简单的文本/电子书查看器,因此我使用了TextView来向用户展示HTML格式化的文本,这样他们就可以通过前后翻页来浏览文本。但我的问题是,我无法在Android中对文本进行分页。
我无法(或者说不知道如何)从TextView用于将文本分成行和页的断行和分页算法中获得适当的反馈。因此,我无法理解实际显示中内容结束的位置,以便我可以在下一页继续剩余的内容。我希望找到克服这个问题的方法。
如果我知道屏幕上最后一个绘制的字符,我可以轻松地放置足够的字符来填满屏幕,并知道实际绘制结束的位置,我可以在下一页继续。这可能吗?如何做到?

在StackOverflow上已经有几个类似的问题,但没有令人满意的答案。以下是其中的一些:

有一个单一的答案似乎可行,但速度很慢。它会添加字符和行直到填满页面。我认为这不是分页的好方法:

与这个问题不同,PageTurner电子书阅读器 基本上做得不错,尽管有点慢。

screenshot of PageTurner eBook reader


PS: 我并不限于使用TextView,我知道断行和分页算法可能非常复杂(如同TeX中的),因此我并不寻求最佳答案,而是要一个相对快速的解决方案,可供用户使用。


更新:这似乎是获得正确答案的良好开端:
有没有一种方法可以检索TextView的可见行数或范围?
答案:在完成文本布局后,可以找到可见文本:
ViewTreeObserver vto = txtViewEx.getViewTreeObserver();
        vto.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                ViewTreeObserver obs = txtViewEx.getViewTreeObserver();
                obs.removeOnGlobalLayoutListener(this);
                height = txtViewEx.getHeight();
                scrollY = txtViewEx.getScrollY();
                Layout layout = txtViewEx.getLayout();

                firstVisibleLineNumber = layout.getLineForVertical(scrollY);
                lastVisibleLineNumber = layout.getLineForVertical(height+scrollY);

            }
        });
3个回答

42

新答案

PagedTextView库(使用Kotlin编写)通过扩展Android TextView,概述了下面的算法。 示例应用程序演示了该库的用法。

设置

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

使用方法

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

旧答案

以下算法实现了文本分页,将TextView本身的属性和算法配置参数同时动态更改。

背景

我们知道在TextView中处理文本时,它会根据视图的宽度正确地将文本分行。查看TextView源代码,我们可以看到文本处理是由Layout类完成的。因此,我们可以利用Layout类为我们完成的工作,并利用其方法进行分页。

问题

TextView的问题在于,可见部分的文本可能会在最后一行的中间垂直切断。因此,当满足最后一行完全适合视图高度时,我们应该分隔一个新页面。

算法

  • 我们遍历文本的每一行,并检查行的底部是否超过视图的高度。
  • 如果是,则我们分隔一个新页面,并计算一个新值作为累积高度,以与以下行的bottom进行比较(请参见实现)。新值被定义为未适合前一页的行的top值(下图中的红线)+ TextView的高度。

enter image description here

实现

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;
    }
}

注1

该算法不仅适用于 TextViewPagination 类在上面的实现中使用了 TextView 的参数)。您可以传递任何 StaticLayout 接受的一组参数,并稍后使用分页布局在Canvas/Bitmap/PdfDocument 上绘制文本。

您还可以将 Spannable 作为 yourText 参数使用,以获取不同字体以及 Html 格式化的字符串(就像下面的示例一样)。

注2

当所有文本具有相同的字体大小时,所有行的高度都相等。在这种情况下,您可能希望通过计算适合单个页面的行数并在每个循环迭代中跳转到适当的行来进一步优化算法。


示例

下面的示例对包含 htmlSpanned 文本的字符串进行分页。

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的布局:

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

截图: 在此输入图片描述


7

请看我的演示项目

"魔法"就在这段代码中:

    mTextView.setText(mText);

    int height = mTextView.getHeight();
    int scrollY = mTextView.getScrollY();
    Layout layout = mTextView.getLayout();
    int firstVisibleLineNumber = layout.getLineForVertical(scrollY);
    int lastVisibleLineNumber = layout.getLineForVertical(height + scrollY);

    //check is latest line fully visible
    if (mTextView.getHeight() < layout.getLineBottom(lastVisibleLineNumber)) {
        lastVisibleLineNumber--;
    }

    int start = pageStartSymbol + mTextView.getLayout().getLineStart(firstVisibleLineNumber);
    int end = pageStartSymbol + mTextView.getLayout().getLineEnd(lastVisibleLineNumber);

    String displayedText = mText.substring(start, end);
    //correct visible text
    mTextView.setText(displayedText);

如果最后一个单词不完整,您也可以将其排除在外,以便不会切断单词;此外,如果TextView不可滚动,则可以使用getLineCount()来获取lastVisibleLineNumber。这需要使用getViewTreeObserver()将其包装在GlobalLayoutListener中,以便在计算之前TextView具有高度。 - Andrei Tudor Diaconu

4

令人惊讶的是,很难找到适用于分页的库。我认为最好使用除TextView之外的其他Android UI元素。如何考虑使用WebView呢? 示例请参见android-webview-example。 代码片段:

webView = (WebView) findViewById(R.id.webView1);

String customHtml = "<html><body><h1>Hello, WebView</h1></body></html>";
webView.loadData(customHtml, "text/html", "UTF-8");

注意:这只是将数据加载到WebView中,类似于Web浏览器。但是让我们不仅止于这个想法。通过WebViewClient onPageFinished 使用分页来添加此UI。请查看SO链接@html-book-like-pagination。 代码片段来自Dan的最佳答案之一:
mWebView.setWebViewClient(new WebViewClient() {
   public void onPageFinished(WebView view, String url) {
   ...
      mWebView.loadUrl("...");
   }
});

注意:

  • 该代码在页面滚动时会加载更多的数据。
  • 在同一个网页上,Engin Kurutepe发布了一篇回答以设置WebView的测量值。这对于指定分页中的页面是必要的。

我还没有实现分页,但我认为这是一个良好的开始并显示出了潜力,应该很快。正如您所看到的,有开发人员已经实现了此功能。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接