Android如何实现类似iPhone体验的EditText/TextView

2015.11.26 11:36 Thu| 425 visits 如何实现类似iphone体验的edittext| Text

最近公司要在Android系统中,实现类似iPhone体验的EditText。这个效果魅族的同学们已经在他们的设备上实现了。下面分享一下我实现的思路。

开发要求? iPhone + 魅族

  • 自由滑动选取:随便在文字上滑动就可选择文字,改变之前的长按后才能选择文字。
  • 文字长按放大:长按文字区域后,弹出一个浮动window,来显示对触摸位置的放大内容
  • 不在窗口顶部ActionBar 位置弹出‘复制’等文本操作,改用浮动窗口方式,把’复制‘、’黏贴‘等操作像iPhone一样显示在一个pop window中。
  • 增加的’全部复制‘等新的文本操作功能

EditText/TextView 是什么?

EditText 本身只有100行代码,继承自TextView

我们看下 TextView 在源代码中的位置:

frameworks\base\core\java\android\widget

除此之外还有一个路径对TextView至关重要:

frameworks\base\core\java\android\text

widget目录下包含的其他文件:CheckBox.javaDateTimeView.javaGridLayout.javaLinearLayout.java 有很多我们熟悉的基础控件。

Google 的描述:A TextView is a complete text editor, however the basic class is configured to not allow editing; see EditText for a subclass that configures the text view for editing.

TextView大概有 9400 行代码,它自己独有 60 多个属性可以设置,再加上他的父类 View.java可以设置的 focusableclickable等等非常多的属性,共同为我们提供了很高的可定制灵活性。


为了实现Vision风格的编辑效果,需要关注什么

TextView 的作用基本上就是一个“文本编辑器”,他的大部分函数都属于set类方法或者get类方法。set方法设置的属性,最终在onDraw时用到。除了onDraw,还有一个重要函数就是onTouchEvent

这两个过程涉及到的操作,分别被TextView委托给了以下个重要的类:Layout.javaMovementMethod(TextView中的KeyEvent处理和onTouch逻辑)以及Editor.java(EditText协助处理touch事件及文字编辑相关逻辑的辅助类)。

他们与我们主要关注的问题密切相关:

  • TextView 的onDraw流程
  • TextView 的 TouchEvent 事件的处理流程
  • 光标怎么定位,ActionPopUpWindow怎么定位
  • TextView 怎么与 ActionBar 交互
  • 怎么实现TextView放大镜效果

TextView 的onDraw

@Override
    protected void onDraw(Canvas canvas) {
        restartMarqueeIfNeeded();
        // Draw the background for this view
        super.onDraw(canvas);

        ... ...

        if (mEditor != null) {
            mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical); // 其实Editor中的onDraw,最后也是委托给了layout处理。
        } else {
            layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);//Layout最终执行了Canvas.drawText方法。
        }

        ... ...
    }

Layout处理了很多文字布局和显示的工作,所以Layout保存了TextView行、列、高度等等与layout布局相关的信息。可以为接下来定位文字及触摸位置提供方法。

TextView 的 TouchEvent 事件

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();

        if (mEditor != null) mEditor.onTouchEvent(event);
        //委托给Editor处理触摸逻辑,我们主要关注这里,因为我们要更改的是EditText的效果。从Editor 的onTouchEvent函数可以找到Android TextView怎么处理对文字的长按、双击和拖动等触摸操作。
        ... ...

            if (mMovement != null) {
            //mMovement是MovementMethod对象,
                handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
            }

        ... ...
    }

光标怎么定位

下面是Editor中弹出一个 ActionPopUpWindow 时,计算其positionY的部分代码。

        @Override
        protected int clipVertically(int positionY) {
            if (positionY < 0) {
                final int offset = getTextOffset();//取得相对位置
                final Layout layout = mTextView.getLayout();
                //取得TextView的Layout对象
                final int line = layout.getLineForOffset(offset);
                positionY += layout.getLineBottom(line) - layout.getLineTop(line);
                positionY += mContentView.getMeasuredHeight();

                // Assumes insertion and selection handles share the same height
                final Drawable handle = mTextView.getContext().getDrawable(
                        mTextView.mTextSelectHandleRes);
                positionY += handle.getIntrinsicHeight();
            }

            return positionY;
        }

        @Override
        protected int getTextOffset() {
            return (mTextView.getSelectionStart() + mTextView.getSelectionEnd()) / 2;
        }
//getSelectionStart 实际上调用了Selection.getSelectionStart(),Selection是一个辅助类,操作当前文本被选择的区域。原理是利用Spannable类,通过设置特殊的'span'标识来确定选取区域。具体可以看下Spannable、SpannableStringInternel 类来学习它的实现原理。很多文字效果,如链接、彩色文字等,都可以利用span来实现。

//代码中可以看到,Layout类可以为我们确定触摸位置和pop window的显示位置提供很大的帮助。

EditText 怎么与 ActionBar 交互

    /**
     * @return true if the selection mode was actually started.
     */
    boolean startSelectionActionMode() {
        if (mSelectionActionMode != null) {
            // Selection action mode is already started
            return false;
        }

        ... ...

        // Do not start the action mode when extracted text will show up full screen, which would
        // immediately hide the newly created action bar and would be visually distracting.
        if (!willExtract) {
            ActionMode.Callback actionModeCallback = new SelectionActionModeCallback();
            mSelectionActionMode = mTextView.startActionMode(actionModeCallback);
            //这里的startActionMode方法是View.java中的方法,可以使View与ActionBar交互。我们不需要改变ActionBar,这里可以做相应处理。
        }

        ... ...
    }

怎么实现TextView放大镜效果

基本思路是把系统窗口截屏获取Bitmap对象,然后根据对TextView的触摸位置,放大相应的区域,显示在触摸位置附近。

  • 方案一:SurfaceControl.screenshot()

    Android中的系统截屏功能,以及我们自己项目中开发的单手操作缩小系统屏幕以及下拉悬停等功能都是基于SurfaceControl中的这个函数。但是调用这个函数需要具有系统权限,而我们知道,TextView是一个基础控件,使用TextView的大部分应用是没有也不可能具有系统权限的。

  • 方案二:View.getDrawingCache()

    下面是部分实现代码,代码很简单,关键是有一个坑差点让我放弃这个方案:在某些应用场景中,取不到正确的cache图像或者不能实时的取得cache图像。

private Bitmap getCaptureImg(int x, int y) {

       ... ...

        final int layerType = mTextView.getLayerType();
        mTextView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
        //在个别应用场景中无法取得正确的cache图像,后来强制设置LAYER_TYPE_SOFTWARE解决了这个问题,应该是某些场景启用了硬件加速,造成无法取得TextView的drawingCache。这里保存设置之前的layerType,后面再设置回去。
        mTextView.getRootView().setDrawingCacheEnabled(true);
        mTextView.getRootView().buildDrawingCache();
        Bitmap bp = mTextView.getRootView().getDrawingCache();
        if (bp == null) {
            Log.d(TAG, "getDrawingCache return a null bitmap");
            return null;
        }
        int width = bp.getWidth();
        int height = bp.getHeight();
        if (x + picWidth > width) {
            int temp = x + picWidth - width;
            x -= temp;
            x = x > 0 ? x : 0;
        }

        if (y + picHeight > height) {
            int temp = y + picHeight - height;
            y -= temp;
            y = y > 0 ? y : 0;
        }

        if (bp != null) {
            Matrix matrix = new Matrix();
            matrix.postScale(scaleSize, scaleSize);
            bp = Bitmap.createBitmap(bp, x, y, picWidth, picHeight, matrix, true);
        }
        mTextView.getRootView().setDrawingCacheEnabled(false);
        mTextView.getRootView().destroyDrawingCache();
        mTextView.setLayerType(layerType, null);
        return bp;

    }

我们搞明白了怎么显示pop window、怎么获取触摸位置、怎么取得实时图像放大、怎么改变文本选择状态等问题,剩下要做的事情就有头绪了。

首先要实现最终效果,我们需要调整EditText的触摸代码,改变原来的双击、滑动、长按的代码逻辑,同时还要考虑到EditText嵌套进ScollView等其他View中时,应该需要处理他们之间的触摸冲突,保证不影响container的同时,实现滑动长按等操作。

然后需要考虑怎么显示放大的图像,可以扩展原有的ActionPopUpWindow,增加自己的处理逻辑。显示window的位置可以按照上面说的方法,从layout中取得相应信息,按照UI要求计算获得。

TextView相关的类,代码量都不小,需要耐心的搞清楚它们的代码结构和职责。