├── .gitignore ├── AndroidManifest.xml ├── README.md ├── project.properties ├── res ├── drawable-mdpi │ └── cursor.png ├── layout │ └── main.xml └── values │ └── strings.xml └── src └── com └── zyz └── mobile └── example ├── MainActivity.java ├── ObservableScrollView.java ├── OnScrollChangedListener.java ├── SelectableTextView.java └── SelectionInfo.java /.gitignore: -------------------------------------------------------------------------------- 1 | # built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # files for the dex VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # generated files 12 | bin/ 13 | gen/ 14 | 15 | # Local configuration file (sdk path, etc) 16 | local.properties 17 | 18 | # Eclipse project files 19 | .classpath 20 | .project 21 | -------------------------------------------------------------------------------- /AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SelectableTextView 2 | ================== 3 | 4 | Android - My implementation of a selectable TextView for Android 2.3.3 and up. 5 | 6 | This is NOT an EditText with selection enabled pretending to be a TextView. 7 | I've implemented cursor controller on top of the TextView. (The source code of Android was a great help for me in making my own implementation.) 8 | 9 | You should be able to build the source and run the example on your Android device without any modifications. 10 | Long click to start the text selection. 11 | -------------------------------------------------------------------------------- /project.properties: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by Android Tools. 2 | # Do not modify this file -- YOUR CHANGES WILL BE ERASED! 3 | # 4 | # This file must be checked in Version Control Systems. 5 | # 6 | # To customize properties used by the Ant build system edit 7 | # "ant.properties", and override values to adapt the script to your 8 | # project structure. 9 | # 10 | # To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): 11 | #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt 12 | 13 | # Project target. 14 | target=android-10 15 | -------------------------------------------------------------------------------- /res/drawable-mdpi/cursor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhouray/SelectableTextView/cba2aac6327c6fdd1626062dd099708c43f2dce5/res/drawable-mdpi/cursor.png -------------------------------------------------------------------------------- /res/layout/main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Selectable TextView 4 | 木村項(きむらこう)の発見者木村(きむら)博士の名は驚くべき速力を以て旬日(じゅんじつ)を出ないうちに日本全国に広がった。博士の功績を表彰(ひょうしょう)した学士会院(がくしかいいん)とその表彰をあくまで緊張して報道する事を忘れなかった都下の各新聞は、久しぶりにといわんよりはむしろ初めて、純粋の科学者に対して、政客、軍人、及び実業家に譲らぬ注意を一般社会から要求した。学問のためにも賀すべき事で、博士のためにも喜ばしき事に違(ちがい)ない。 5 |  けれども今より一カ月前に、この木村博士が何処に何をしているかを知っていたものは、全国を通じて僅か百人を出ぬ位であったろう。博士が忽然(こつぜん)と著名になったのは、今までまるで人の眼に触れないで経過した科学界という暗黒な人世(じんせい)の象面(しょうめん)に、一点急に輝やく場所が出来たと同じ事である。其所(そこ)が明るくなったのは仕合せである。しかし其所だけが明るくなったのは不都合である。 6 |  一般の社会はつい二、三週間前まで博士の存在について全く神経を使わなかった。一般の社会は今日といえども科学という世界の存在については殆んど不関心(ふかんしん)に打ち過ぎつつある。彼らから見て闇(やみ)に等しい科学界が、一様の程度で彼らの眼に暗く映る間は、彼らが根柢(こんてい)ある人生の活力の或物に対して公平に無感覚であったと非難されるだけで済むが、いやしくもこの暗い中の一点が木村項の名で輝やき渡る以上、また他が依然として暗がりに静まり返る以上、彼らが今まで所有していた公平の無感覚は、俄然(がぜん)として不公平な感覚と変性(へんせい)しなければならない。これまではただ無知で済んでいたのである。それが急に不徳義に転換するのである。問題は単(ひとえ)に智愚を界(さかい)する理性一遍の墻(かき)を乗り超えて、道義の圏内(けんない)に落ち込んで来るのである。 7 |  木村項だけが炳(へい)として俗人の眸(ひとみ)を焼くに至った変化につれて、木村項の周囲にある暗黒面は依然として、木村項の知られざる前と同じように人からその存在を忘れられるならば、日本の科学は木村博士一人の科学で、他の物理学者、数学者、化学者、乃至(ないし)動植物学者に至っては、単位をすら充たす事の出来ない出来損(できそこ)ないでなければならない。貧弱なる日本ではあるが、余(よ)にはこれほどまでに愚図(ぐず)が揃(そろ)って科学を研究しているとは思えない。その方面の知識に疎(うと)い寡聞(かぶん)なる余の頭にさえ、この断見(だんけん)を否定すべき材料は充分あると思う。 8 |  社会は今まで科学界をただ漫然と暗く眺めていた。そうしてその科学界を組織する学者の研究と発見とに対しては、その比較的価値所(どころ)か、全く自家の着衣喫飯(ちゃくいきっぱん)と交渉のない、徒事(いたずらごと)の如く見傚(みな)して来た。そうして学士会院の表彰に驚ろいて、急に木村氏をえらく吹聴(ふいちょう)し始めた。吹聴の程度が木村氏の偉さと比例するとしても、木村氏と他の学者とを合せて、一様に坑中(こうちゅう)に葬り去った一カ月前の無知なる公平は、全然破れてしまった訳になる。一旦(いったん)木村博士を賞揚(しょうよう)するならば、木村博士の功績に応じて、他の学者もまた適当の名誉を荷(にな)うのが正当であるのに、他の学者は木村博士の表彰前と同じ暗黒な平面に取り残されて、ただ一の木村博士のみが、今日まで学者間に維持せられた比較的位地を飛び離れて、衆目の前に独り偉大に見えるようになったのは少なくとも道義的の不公平を敢てして、一般の社会に妙な誤解を与うる好意的な悪結果である。 9 |  社会はただ新聞紙の記事を信じている。新聞紙はただ学士会院の所置(しょち)を信じている。学士会院は固(もと)より己(おの)れを信じているのだろう。余といえども木村項の名誉ある発見たるを疑うものではない。けれども学士会院がその発見者に比較的の位置を与える工夫(くふう)を講じないで、徒(いたず)らに表彰の儀式を祭典の如く見せしむるため被賞者に絶対の優越権を与えるかの如き挙に出でたのは、思慮の周密(しゅうみつ)と弁別(べんべつ)の細緻(さいち)を標榜(ひょうぼう)する学者の所置としては、余の提供にかかる不公平の非難を甘んじて受ける資格があると思う。 10 |  学士会院が栄誉ある多数の学者中より今年はまず木村氏だけを選んで、他は年々順次に表彰するという意を当初から持っているのだと弁解するならば、木村氏を表彰すると同時に、その主意が一般に知れ渡るように取り計(はから)うのが学者の用意というものであろう。木村氏が五百円の賞金と直径三寸大の賞牌(しょうはい)に相当するのに、他の学者はただの一銭の賞金にも直径一分の賞牌にも値せぬように俗衆に思わせるのは、木村氏の功績を表するがために、他の学者に屈辱を与えたと同じ事に帰着する。\n\n 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/com/zyz/mobile/example/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.zyz.mobile.example; 2 | 3 | import android.app.Activity; 4 | import android.os.Bundle; 5 | import android.util.Log; 6 | import android.view.MotionEvent; 7 | import android.view.View; 8 | 9 | public class MainActivity extends Activity { 10 | 11 | private SelectableTextView mTextView; 12 | private int mTouchX; 13 | private int mTouchY; 14 | private final static int DEFAULT_SELECTION_LEN = 5; 15 | 16 | @Override 17 | public void onCreate(Bundle savedInstanceState) { 18 | super.onCreate(savedInstanceState); 19 | setContentView(R.layout.main); 20 | 21 | // make sure the TextView's BufferType is Spannable, see the main.xml 22 | mTextView = (SelectableTextView) findViewById(R.id.main_text); 23 | mTextView.setDefaultSelectionColor(0x40FF00FF); 24 | 25 | 26 | mTextView.setOnLongClickListener(new View.OnLongClickListener() { 27 | @Override 28 | public boolean onLongClick(View v) { 29 | showSelectionCursors(mTouchX, mTouchY); 30 | return true; 31 | } 32 | }); 33 | mTextView.setOnClickListener(new View.OnClickListener() { 34 | @Override 35 | public void onClick(View v) { 36 | mTextView.hideCursor(); 37 | } 38 | }); 39 | mTextView.setOnTouchListener(new View.OnTouchListener() { 40 | @Override 41 | public boolean onTouch(View v, MotionEvent event) { 42 | mTouchX = (int) event.getX(); 43 | mTouchY = (int) event.getY(); 44 | return false; 45 | } 46 | }); 47 | } 48 | 49 | private void showSelectionCursors(int x, int y) { 50 | int start = mTextView.getPreciseOffset(x, y); 51 | 52 | if (start > -1) { 53 | int end = start + DEFAULT_SELECTION_LEN; 54 | if (end >= mTextView.getText().length()) { 55 | end = mTextView.getText().length() - 1; 56 | } 57 | mTextView.showSelectionControls(start, end); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/com/zyz/mobile/example/ObservableScrollView.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Ray Zhou 3 | 4 | JadeRead is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | JadeRead is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with JadeRead. If not, see 16 | 17 | Author: Ray Zhou 18 | Date: 2013 04 26 19 | 20 | */ 21 | package com.zyz.mobile.example; 22 | 23 | import android.content.Context; 24 | import android.util.AttributeSet; 25 | import android.widget.ScrollView; 26 | 27 | import java.util.ArrayList; 28 | 29 | /** 30 | * ScrollView with a onScrollChangedListener 31 | * 32 | * http://stackoverflow.com/questions/3948934/synchronise-scrollview-scroll-positions-android 33 | * 34 | */ 35 | public class ObservableScrollView extends ScrollView{ 36 | 37 | private ArrayList mOnScrollChangedListeners; 38 | 39 | @SuppressWarnings("unused") 40 | public ObservableScrollView(Context context) { 41 | super(context); 42 | init(); 43 | } 44 | 45 | @SuppressWarnings("unused") 46 | public ObservableScrollView(Context context, AttributeSet attrs, int defStyle) { 47 | super(context, attrs, defStyle); 48 | init(); 49 | } 50 | 51 | @SuppressWarnings("unused") 52 | public ObservableScrollView(Context context, AttributeSet attrs) { 53 | super(context, attrs); 54 | init(); 55 | } 56 | 57 | private void init() { 58 | mOnScrollChangedListeners = new ArrayList(2); 59 | } 60 | 61 | public void addOnScrollChangedListener(OnScrollChangedListener onScrollChangedListener) { 62 | mOnScrollChangedListeners.add(onScrollChangedListener); 63 | } 64 | 65 | @SuppressWarnings("unused") 66 | public void removeOnScrollChangedListener(OnScrollChangedListener onScrollChangedListener) { 67 | mOnScrollChangedListeners.remove(onScrollChangedListener); 68 | } 69 | 70 | /** 71 | * google should make this method public and add a setOnScrollChangedListener 72 | * override to allow listener 73 | */ 74 | @Override 75 | protected void onScrollChanged(int x, int y, int oldx, int oldy) { 76 | super.onScrollChanged(x, y, oldx, oldy); 77 | for (OnScrollChangedListener listener : mOnScrollChangedListeners) { 78 | listener.onScrollChanged(this, x, y, oldx, oldy); 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/com/zyz/mobile/example/OnScrollChangedListener.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Ray Zhou 3 | 4 | JadeRead is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | JadeRead is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with JadeRead. If not, see 16 | 17 | Author: Ray Zhou 18 | Date: 2013 04 26 19 | 20 | */ 21 | package com.zyz.mobile.example; 22 | 23 | 24 | public interface OnScrollChangedListener { 25 | public void onScrollChanged(ObservableScrollView scrollView, int x, int y, int oldx, int oldy); 26 | } 27 | -------------------------------------------------------------------------------- /src/com/zyz/mobile/example/SelectableTextView.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Ray Zhou 3 | 4 | JadeRead is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | JadeRead is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with JadeRead. If not, see 16 | 17 | Author: Ray Zhou 18 | Date: 2013 04 26 19 | 20 | */ 21 | 22 | package com.zyz.mobile.example; 23 | 24 | import android.content.Context; 25 | import android.graphics.Canvas; 26 | import android.graphics.drawable.Drawable; 27 | import android.text.Layout; 28 | import android.text.style.BackgroundColorSpan; 29 | import android.util.AttributeSet; 30 | import android.view.Gravity; 31 | import android.view.MotionEvent; 32 | import android.view.View; 33 | import android.view.ViewTreeObserver; 34 | import android.widget.PopupWindow; 35 | import android.widget.ScrollView; 36 | import android.widget.TextView; 37 | 38 | /** 39 | * User: ray Date: 2013-02-01 40 | *

41 | * WARNING: SelectableTextView is designed to be used inside a ScrollView. The selection 42 | * functionality have only been tested under the assumption that the parent of the view is a 43 | * ScrollView. 44 | *

45 | * The functionality WILL PROBABLY breaks if the TextView is not inside a ScrollView. 46 | */ 47 | public class SelectableTextView extends TextView { 48 | 49 | private int mDefaultSelectionColor; 50 | 51 | /** 52 | * the selection information used by the cursor 53 | */ 54 | private SelectionInfo mCursorSelection; 55 | 56 | 57 | private final int[] mTempCoords = new int[2]; 58 | 59 | 60 | private OnCursorStateChangedListener mOnCursorStateChangedListener; 61 | 62 | /** 63 | * DONT ACCESS DIRECTLY, use getSelectionController() instead 64 | */ 65 | private SelectionCursorController mSelectionController; 66 | 67 | @SuppressWarnings("unused") 68 | public SelectableTextView(Context context) { 69 | super(context); 70 | init(); 71 | } 72 | 73 | @SuppressWarnings("unused") 74 | public SelectableTextView(Context context, AttributeSet attrs) { 75 | super(context, attrs); 76 | init(); 77 | } 78 | 79 | 80 | @SuppressWarnings("unused") 81 | public SelectableTextView(Context context, AttributeSet attrs, int defStyle) { 82 | super(context, attrs, defStyle); 83 | init(); 84 | } 85 | 86 | private void init() { 87 | mCursorSelection = new SelectionInfo(); 88 | 89 | 90 | mSelectionController = new SelectionCursorController(); 91 | 92 | final ViewTreeObserver observer = getViewTreeObserver(); 93 | if (observer != null) { 94 | observer.addOnTouchModeChangeListener(mSelectionController); 95 | } 96 | 97 | } 98 | 99 | @Override 100 | protected void onAttachedToWindow() { 101 | super.onAttachedToWindow(); 102 | 103 | // TextView will take care of the color span of the text when it's being scroll by 104 | // its parent ScrollView. But the cursors position is not handled. Calling snapToSelection 105 | // will move the cursors along with the selection. 106 | if (getParent() instanceof ObservableScrollView) { 107 | ((ObservableScrollView) getParent()).addOnScrollChangedListener(new OnScrollChangedListener() { 108 | @Override 109 | public void onScrollChanged(ObservableScrollView scrollView, int x, int y, int oldx, int oldy) { 110 | mSelectionController.snapToSelection(); 111 | } 112 | }); 113 | } 114 | } 115 | 116 | public void setDefaultSelectionColor(int color) { 117 | mDefaultSelectionColor = color; 118 | } 119 | 120 | /** 121 | * set the selection beginning at {@code start} with the specified {@code length} for the given 122 | * {@code duration} 123 | * 124 | * @param color the color of the selection 125 | * @param start the start offset 126 | * @param length the length of the selection 127 | * @param duration the duration the selection, in second, to be last, -1 to indicate forever 128 | */ 129 | public void setSelection(int color, int start, int length, int duration) { 130 | 131 | // make sure we are not selecting beyond the text 132 | int end = start + length >= getText().length() ? getText().length() - 1 : start + length; 133 | 134 | if (start + length > getText().length() || end <= start) { 135 | return ; 136 | } 137 | 138 | /* My Note 139 | have to create a new one each time instead of using the {@code set} method. 140 | if I use set, will mess up the removeSelection function as the content of the 141 | reference has changed 142 | */ 143 | mCursorSelection = new SelectionInfo(getText(), new BackgroundColorSpan(color), start, end); 144 | mCursorSelection.select(); 145 | removeSelection(duration); 146 | } 147 | 148 | /** 149 | * set the selection beginning at {@code start} with the specified {@code length} for the given 150 | * {@code duration} with the default color 151 | * 152 | * @param start the start offset 153 | * @param length the length of the selection 154 | * @param duration the duration of the selection to be last, -1 to indicate forever 155 | */ 156 | public void setSelection(int start, int length, int duration) { 157 | setSelection(mDefaultSelectionColor, start, length, duration); 158 | } 159 | 160 | /** 161 | * set the selection beginning at {@code start} with the spcified {@code length} 162 | * 163 | * @param start the starting offset 164 | * @param length the length of the selection 165 | */ 166 | public void setSelection(int start, int length) { 167 | setSelection(start, length, -1); 168 | } 169 | 170 | /** 171 | * removes the current selection immediately 172 | */ 173 | public void removeSelection() { 174 | mCursorSelection.remove(); 175 | } 176 | 177 | public void removeSelection(int delay) { 178 | removeSelection(mCursorSelection, delay); 179 | } 180 | 181 | /** 182 | * remove the given span style from the main text after the specified time 183 | * 184 | * @param selection the span style to be removed 185 | * @param delay the milliseconds to wait before remove the style 186 | */ 187 | public void removeSelection(final SelectionInfo selection, int delay) { 188 | if (delay >= 0) { 189 | Runnable runnable = new Runnable() { 190 | public void run() { 191 | selection.remove(); 192 | } 193 | }; 194 | postDelayed(runnable, delay); 195 | } 196 | } 197 | 198 | 199 | /** 200 | * show the selection cursors and select the text between the specified offset 201 | * 202 | * @param start the start offset 203 | * @param end the end offset 204 | */ 205 | public void showSelectionControls(int start, int end) { 206 | assert (start >= 0); 207 | assert (end >= 0); 208 | assert (start < getText().length()); 209 | assert (end < getText().length()); 210 | 211 | mSelectionController.show(start, end); 212 | } 213 | 214 | /** 215 | * @return the current selection information 216 | */ 217 | public SelectionInfo getCursorSelection() { 218 | return mCursorSelection; 219 | } 220 | 221 | 222 | /** 223 | * set the OnCursorHiddenListner when then the cursors hide 224 | * 225 | * @param onCursorStateChangedListener the OnCursorVisibilityChangedListener 226 | */ 227 | public void setOnCursorStateChangedListener(OnCursorStateChangedListener onCursorStateChangedListener) { 228 | mOnCursorStateChangedListener = onCursorStateChangedListener; 229 | } 230 | 231 | /** 232 | * @return the y position 233 | */ 234 | private int getScrollYInternal() { 235 | int y = this.getScrollY(); 236 | 237 | // a TextView inside a ScrollView is not scrolled, so getScrollY() returns 0. 238 | // We must use getScrollY() from the ScrollView instead 239 | if (this.getParent() instanceof ScrollView) { 240 | y += ((ScrollView) this.getParent()).getScrollY(); 241 | 242 | // do this to compensate for the height of the status bar 243 | final int[] coords = mTempCoords; 244 | ((ScrollView) this.getParent()).getLocationInWindow(coords); 245 | y -= coords[1]; 246 | 247 | /* My Note 248 | getLocationInWindow(coords) for the TextView will return a negative 249 | value if we scroll down as the top the TextView is beyong the top 250 | of the screen. 251 | 252 | So maybe we could replace SrollView.getScrollY() with getWindowsInLocation(coords) 253 | */ 254 | } 255 | 256 | return y; 257 | } 258 | 259 | /** 260 | * the current x position of the TextView taking into account the scroll (if it's inside a 261 | * ScrollView) and the padding of the scrollview 262 | * 263 | * @return the x position 264 | */ 265 | private int getScrollXInternal() { 266 | int x = this.getScrollX(); 267 | 268 | // a TextView inside a ScrollView is not scrolled, so getScrollX() returns 0. 269 | // We must use getScrollX() from the ScrollView instead 270 | if (this.getParent() instanceof ScrollView) { 271 | ScrollView scrollView = (ScrollView) this.getParent(); 272 | 273 | x += scrollView.getScrollX(); 274 | 275 | final int[] coords = mTempCoords; 276 | scrollView.getLocationInWindow(coords); 277 | x -= coords[0]; 278 | x -= scrollView.getPaddingLeft(); 279 | } 280 | return x; 281 | } 282 | 283 | /** 284 | * Gets the character offset of (x, y). If (x, y) lies on the right half of the character, it 285 | * returns the offset of the next character. If (x, y) lies on the left half of the character, it 286 | * returns the offset of this character. 287 | * 288 | * @param x x coordinate relative to this TextView 289 | * @param y y coordinate relative to this TextView 290 | * @return the offset at (x,y), -1 if error occurs 291 | */ 292 | public int getOffset(int x, int y) { 293 | Layout layout = getLayout(); 294 | int offset = -1; 295 | 296 | if (layout != null) { 297 | int topVisibleLine = layout.getLineForVertical(y); 298 | offset = layout.getOffsetForHorizontal(topVisibleLine, x); 299 | } 300 | 301 | return offset; 302 | } 303 | 304 | /** 305 | * Gets the character offset where (x, y) is pointing to. 306 | * 307 | * @param x x coordinate relative to this TextView 308 | * @param y y coordinate relative to this TextView 309 | * @return the offset at (x, y), -1 if error occurs 310 | * 311 | * @see {@link #getOffset(int, int)} 312 | */ 313 | public int getPreciseOffset(int x, int y) { 314 | Layout layout = getLayout(); 315 | 316 | if (layout != null) { 317 | int topVisibleLine = layout.getLineForVertical(y); 318 | int offset = layout.getOffsetForHorizontal(topVisibleLine, x); 319 | 320 | int offset_x = (int) layout.getPrimaryHorizontal(offset); 321 | if (offset_x > x) { 322 | return layout.getOffsetToLeftOf(offset); 323 | } 324 | } 325 | return getOffset(x, y); 326 | } 327 | 328 | //////////////////////////////////////////////// 329 | // copied & modified from Android source code // 330 | //////////////////////////////////////////////// 331 | 332 | /** 333 | * Get the offset character closest to the specified absolute position. 334 | * 335 | * @param x The horizontal absolute position of a point on screen 336 | * @param y The vertical absolute position of a point on screen 337 | * @return the character offset for the character whose position is closest to the specified 338 | * position. Returns -1 if there is no layout. 339 | */ 340 | // @SuppressWarnings("unused") 341 | // public int getOffsetFromRaw(int x, int y) { 342 | // if (getLayout() == null) return -1; 343 | // 344 | // y -= getTotalPaddingTop(); 345 | // // Clamp the position to inside of the view. 346 | // y = Math.max(0, y); 347 | // y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y); 348 | // y += getScrollY(); 349 | // 350 | // // if the textview is inside a scrollview, the above getScrollY() will return 0 351 | // // even if it's scrolled. Must do getScrollY() from the ScrollView instead. 352 | // if (this.getParent() instanceof ScrollView) { 353 | // ScrollView scrollView = (ScrollView) this.getParent(); 354 | // y += scrollView.getScrollY(); 355 | // 356 | // // do this to compensate for the height of the status bar 357 | // final int[] coords = mTempCoords; 358 | // scrollView.getLocationInWindow(coords); 359 | // y -= coords[1]; 360 | // } 361 | // 362 | // final int line = getLayout().getLineForVertical(y); 363 | // final int offset = getOffsetForHorizontal(line, x); 364 | // return offset; 365 | // } 366 | 367 | //////////////////////////////////////////////// 368 | // copied & modified from Android source code // 369 | //////////////////////////////////////////////// 370 | 371 | /** 372 | * Get the offset character closest to the specified absolute position. If the resulting offset is 373 | * too close to the previous offset, return the previous offset instead. 374 | * 375 | * @param x raw x 376 | * @param y raw y 377 | * @param previousOffset previous offset 378 | * @return offset of the specified (x,y) 379 | */ 380 | private int getHysteresisOffset(int x, int y, int previousOffset) { 381 | final Layout layout = getLayout(); 382 | if (layout == null) return -1; 383 | 384 | 385 | y += getScrollYInternal(); 386 | x += getScrollXInternal(); 387 | 388 | int line = getLayout().getLineForVertical(y); 389 | 390 | // The "HACK BLOCK"S in this function is required because of how Android Layout for 391 | // TextView works - if 'offset' equals to the last character of a line, then 392 | // 393 | // * getLineForOffset(offset) will result the NEXT line 394 | // * getPrimaryHorizontal(offset) will return 0 because the next insertion point is on the next line 395 | // * getOffsetForHorizontal(line, x) will not return the last offset of a line no matter where x is 396 | // These are highly undesired and is worked around with the HACK BLOCK 397 | // 398 | // @see Moon+ Reader/Color Note - see how it can't select the last character of a line unless you move 399 | // the cursor to the beginning of the next line. 400 | // 401 | ////////////////////HACK BLOCK//////////////////////////////////////////////////// 402 | if (isEndOfLineOffset(previousOffset)) { 403 | // we have to minus one from the offset so that the code below to find 404 | // the previous line can work correctly. 405 | int left = (int) layout.getPrimaryHorizontal(previousOffset - 1); 406 | int right = (int) layout.getLineRight(line); 407 | int threshold = (right - left) / 2; // half the width of the last character 408 | if (x > right - threshold) { 409 | previousOffset -= 1; 410 | } 411 | } 412 | /////////////////////////////////////////////////////////////////////////////////// 413 | 414 | final int previousLine = layout.getLineForOffset(previousOffset); 415 | final int previousLineTop = layout.getLineTop(previousLine); 416 | final int previousLineBottom = layout.getLineBottom(previousLine); 417 | final int hysteresisThreshold = (previousLineBottom - previousLineTop) / 2; 418 | 419 | // If new line is just before or after previous line and y position is less than 420 | // hysteresisThreshold away from previous line, keep cursor on previous line. 421 | if (((line == previousLine + 1) && ((y - previousLineBottom) < hysteresisThreshold)) || 422 | ((line == previousLine - 1) && ((previousLineTop - y) < hysteresisThreshold))) 423 | { 424 | line = previousLine; 425 | } 426 | 427 | int offset = layout.getOffsetForHorizontal(line, x); 428 | 429 | 430 | // This allow the user to select the last character of a line without moving the 431 | // cursor to the next line. (As Layout.getOffsetForHorizontal does not return the 432 | // offset of the last character of the specified line) 433 | // 434 | // But this function will probably get called again immediately, must decrement the offset 435 | // by 1 to compensate for the change made below. (see previous HACK BLOCK) 436 | /////////////////////HACK BLOCK/////////////////////////////////////////////////// 437 | if (offset < getText().length() - 1) { 438 | if (isEndOfLineOffset(offset + 1)) { 439 | int left = (int) layout.getPrimaryHorizontal(offset); 440 | int right = (int) layout.getLineRight(line); 441 | int threshold = (right - left) / 2; // half the width of the last character 442 | if (x > right - threshold) { 443 | offset += 1; 444 | } 445 | } 446 | } 447 | ////////////////////////////////////////////////////////////////////////////////// 448 | 449 | return offset; 450 | } 451 | 452 | 453 | /** 454 | * Checks whether the specified offset is at the end of a line. 455 | *

456 | * PRECONDITION: assumes layout exists and is valid 457 | * 458 | * @param offset the offset to check 459 | * @return true if the offset is at the end of a line, false otherwise. 460 | */ 461 | private boolean isEndOfLineOffset(int offset) { 462 | if (offset > 0) { 463 | return getLayout().getLineForOffset(offset) == 464 | getLayout().getLineForOffset(offset - 1) + 1; 465 | } 466 | return false; 467 | } 468 | 469 | //////////////////////////////////////////////// 470 | // copied & modified from Android source code // 471 | //////////////////////////////////////////////// 472 | 473 | /** 474 | * get the offset given the line and the raw x 475 | * 476 | * @param line the line 477 | * @param x raw x 478 | * @return the offset 479 | */ 480 | private int getOffsetForHorizontal(int line, int x) { 481 | x -= getTotalPaddingLeft(); 482 | // Clamp the position to inside of the view. 483 | x = Math.max(0, x); 484 | x = Math.min(getWidth() - getTotalPaddingRight() - 1, x); 485 | x += getScrollXInternal(); 486 | 487 | return getLayout().getOffsetForHorizontal(line, x); 488 | } 489 | 490 | /** 491 | * get the (x,y) screen coordinates from the specified offset. 492 | * 493 | * @param offset the offset 494 | * @param scroll_x the horizontal scroll distance to take away 495 | * @param scroll_y the horizontal scroll distance to take away 496 | * @param coords the returned x, y coordinate array, must have a length of 2 497 | */ 498 | private void getXY(int offset, int scroll_x, int scroll_y, int[] coords) { 499 | assert (coords.length >= 2); 500 | 501 | coords[0] = coords[1] = -1; 502 | Layout layout = getLayout(); 503 | 504 | if (layout != null) { 505 | int line = layout.getLineForOffset(offset); 506 | int base = layout.getLineBottom(line); 507 | 508 | coords[0] = (int) layout.getPrimaryHorizontal(offset) - scroll_x; // x 509 | coords[1] = base - scroll_y; // y 510 | } 511 | } 512 | 513 | /** 514 | * Get the (x,y) screen coordinate from the specified offset. If the specified offset is beyond the 515 | * end of the line, move the offset to the beginning of the next line. 516 | * 517 | * @param offset the offset 518 | * @param scroll_x the horizontal scroll distance to take away 519 | * @param scroll_y the horizontal scroll distance to take away 520 | * @param coords the returned x, y coordinate array, muust have a length of 2 521 | */ 522 | private void getAdjusteStartXY(int offset, int scroll_x, int scroll_y, int[] coords) { 523 | if (offset < getText().length()) { 524 | final Layout layout = getLayout(); 525 | if (layout != null) { 526 | if (isEndOfLineOffset(offset + 1)) { 527 | float a = layout.getPrimaryHorizontal(offset); 528 | float b = layout.getLineRight(layout.getLineForOffset(offset)); 529 | if (a == b) { 530 | // this means the we encounter a new line character, i think. 531 | offset += 1; 532 | } 533 | } 534 | } 535 | } 536 | getXY(offset, scroll_x, scroll_y, coords); 537 | } 538 | 539 | /** 540 | * Get the (x,y) screen coordinate from the specified offset. If the offset is the at the end of a 541 | * wrapped line, return the (x,y) at the end of that line instead of the (x, y) at the beginning of 542 | * the next line (which is the default behaviour for Android) 543 | * 544 | * @param offset the offset 545 | * @param scroll_x the horizontal scroll distance to take away 546 | * @param scroll_y the horizontal scroll distance to take away 547 | * @param coords the returned x, y coordinate array, must have a length of 2 548 | */ 549 | private void getAdjustedEndXY(int offset, int scroll_x, int scroll_y, int[] coords) { 550 | if (offset > 0) { 551 | final Layout layout = getLayout(); 552 | if (layout != null) { 553 | //if (this_line > prev_line) { 554 | if (isEndOfLineOffset(offset)) { 555 | // if we are at the end of a line, calculate the X using getLineRight instead of 556 | // getPrimaryHorizontal. 557 | // (Because getPrimaryHorizontal returns 0 for offset sitting at the end of a line. 558 | // getPrimaryHorizontal returns the next insertion point, which will be the next line) 559 | int prev_line = layout.getLineForOffset(offset - 1); 560 | float right = layout.getLineRight(prev_line); 561 | int y = layout.getLineBottom(prev_line); 562 | coords[0] = (int) right - scroll_x; 563 | coords[1] = y - scroll_y; 564 | return; 565 | } 566 | } 567 | } 568 | getXY(offset, scroll_x, scroll_y, coords); 569 | } 570 | 571 | public void hideCursor() { 572 | mSelectionController.hide(); 573 | } 574 | 575 | 576 | //////////////////////////////////////////////////////////////////////////// 577 | //////////////////////////////////////////////////////////////////////////// 578 | /////////////////////////////INNER CLASSES////////////////////////////////// 579 | ///////// copied, heavily modified and simplified from TextView //////////// 580 | //////////////////////////////////////////////////////////////////////////// 581 | 582 | /** 583 | * Manages the two cursors that control the selection. Internal used only. For outsider, please use 584 | * SelectionModifier 585 | */ 586 | private class SelectionCursorController implements ViewTreeObserver.OnTouchModeChangeListener { 587 | 588 | /** 589 | * The two cursor handle that controls the selection. Note that the two cursors are allowed to 590 | * swap positions and thus the name of the handle has no bearing on the relative position of the 591 | * handle to each other. (e.g. mStartHandle can be positioned leagally at an offset greater than 592 | * the offset mEndHandle is resting on) 593 | */ 594 | private CursorHandle mStartHandle; 595 | private CursorHandle mEndHandle; 596 | 597 | 598 | /** 599 | * whether the selection controller is displaying on the screen 600 | */ 601 | private boolean mIsShowing; 602 | 603 | 604 | public SelectionCursorController() { 605 | mStartHandle = new CursorHandle(this); 606 | mEndHandle = new CursorHandle(this); 607 | } 608 | 609 | 610 | /** 611 | * snap the cursors to the current selection 612 | */ 613 | public void snapToSelection() { 614 | 615 | if (mIsShowing) { 616 | int a = SelectableTextView.this.getCursorSelection().getStart(); 617 | int b = SelectableTextView.this.getCursorSelection().getEnd(); 618 | 619 | int start = Math.min(a, b); 620 | int end = Math.max(a, b); 621 | 622 | // find the corresponding handle for the start/end calculated above 623 | CursorHandle startHandle = start == a ? mStartHandle : mEndHandle; 624 | CursorHandle endHandle = end == b ? mEndHandle : mStartHandle; 625 | 626 | final int[] coords = mTempCoords; 627 | int scroll_y = SelectableTextView.this.getScrollYInternal(); 628 | int scroll_x = SelectableTextView.this.getScrollXInternal(); 629 | 630 | SelectableTextView.this.getAdjusteStartXY(start, scroll_x, scroll_y, coords); 631 | startHandle.pointTo(coords[0], coords[1]); 632 | 633 | SelectableTextView.this.getAdjustedEndXY(end, scroll_x, scroll_y, coords); 634 | endHandle.pointTo(coords[0], coords[1]); 635 | } 636 | } 637 | 638 | /** 639 | * show the selection cursor at the specified offsets and select the text between the specified 640 | * offsets. 641 | * 642 | * @param start the offset of the first cursor 643 | * @param end the offset of the second cursor 644 | */ 645 | public void show(int start, int end) { 646 | 647 | int a = Math.min(start, end); 648 | int b = Math.max(start, end); 649 | 650 | final int[] coords = mTempCoords; 651 | int scroll_y = SelectableTextView.this.getScrollY(); 652 | int scroll_x = SelectableTextView.this.getScrollX(); 653 | 654 | SelectableTextView.this.getAdjusteStartXY(a, scroll_x, scroll_y, coords); 655 | mStartHandle.show(coords[0], coords[1]); 656 | 657 | SelectableTextView.this.getAdjustedEndXY(b, scroll_x, scroll_y, coords); 658 | mEndHandle.show(coords[0], coords[1]); 659 | 660 | mIsShowing = true; 661 | select(a, b); 662 | 663 | if (mOnCursorStateChangedListener != null) { 664 | mOnCursorStateChangedListener.onShowCursors(SelectableTextView.this); 665 | } 666 | } 667 | 668 | 669 | /** 670 | * hide the cursor and selection 671 | */ 672 | public void hide() { 673 | if (mIsShowing) { 674 | SelectableTextView.this.removeSelection(); 675 | mStartHandle.hide(); 676 | mEndHandle.hide(); 677 | mIsShowing = false; 678 | 679 | if (mOnCursorStateChangedListener != null) { 680 | mOnCursorStateChangedListener.onHideCursors(SelectableTextView.this); 681 | } 682 | } 683 | } 684 | 685 | 686 | /** 687 | * redraw the moving cursor and update the selection if required 688 | * 689 | * @param cursorHandle the CursorHandle (LEFT or RIGHT) 690 | * @param x the x coordinate the cursor is pointing to on the screen (raw) 691 | * @param y the y coordinate the cursor is pointing to on the screen (raw) 692 | * @param oldx the previous x position the cursor pointed to 693 | * @param oldy the previous y position the cursor pointed to 694 | */ 695 | public void updatePosition(CursorHandle cursorHandle, int x, int y, int oldx, int oldy) { 696 | if (!mIsShowing) { 697 | return; 698 | } 699 | 700 | int old_offset = 701 | cursorHandle == mStartHandle ? 702 | SelectableTextView.this.getCursorSelection().getStart() : 703 | SelectableTextView.this.getCursorSelection().getEnd(); 704 | 705 | int offset = SelectableTextView.this.getHysteresisOffset(x, y, old_offset); 706 | 707 | if (offset != old_offset) { 708 | 709 | if (cursorHandle == mStartHandle) { 710 | SelectableTextView.this.getCursorSelection().setStart(offset); 711 | } 712 | else { 713 | SelectableTextView.this.getCursorSelection().setEnd(offset); 714 | } 715 | SelectableTextView.this.getCursorSelection().select(); 716 | } 717 | 718 | cursorHandle.pointTo(x, y); 719 | 720 | if (mOnCursorStateChangedListener != null) { 721 | mOnCursorStateChangedListener.onPositionChanged(SelectableTextView.this, x, y, oldx, oldy); 722 | } 723 | } 724 | 725 | /** 726 | * Select the textview between start and end. 727 | *

728 | * Precondition: start, end must be valid 729 | * 730 | * @param start the starting offset 731 | * @param end the ending offset 732 | */ 733 | private void select(int start, int end) { 734 | SelectableTextView.this.setSelection(Math.min(start, end), Math.abs(end - start)); 735 | } 736 | 737 | @SuppressWarnings("unused") 738 | public boolean isShowing() { 739 | return mIsShowing; 740 | } 741 | 742 | /** 743 | * Called when the view is detached from window. Perform house keeping task, such as stopping 744 | * Runnable thread that would otherwise keep a reference on the context, thus preventing the 745 | * activity from being recycled. 746 | */ 747 | @SuppressWarnings("unused") 748 | public void onDetached() { 749 | // don't know what to do here 750 | } 751 | 752 | 753 | @Override 754 | public void onTouchModeChanged(boolean isInTouchMode) { 755 | if (!isInTouchMode) { 756 | hide(); 757 | } 758 | } 759 | } 760 | 761 | 762 | /** 763 | * represents a single cursor 764 | */ 765 | private class CursorHandle extends View { 766 | 767 | /** 768 | * the {@link PopupWindow} containing the cursor drawable 769 | */ 770 | private final PopupWindow mContainer; 771 | 772 | /** 773 | * the drawble of the cursor 774 | */ 775 | private Drawable mDrawable; 776 | 777 | /** 778 | * whether the user is dragging the cursor 779 | */ 780 | @SuppressWarnings("unused") 781 | private boolean mIsDragging; 782 | 783 | /** 784 | * the controller that's controlling the cursor 785 | */ 786 | private SelectionCursorController mController; 787 | 788 | /** 789 | * the height of the cursor 790 | */ 791 | private int mHeight; 792 | 793 | /** 794 | * the width of the cursor 795 | */ 796 | private int mWidth; 797 | 798 | /** 799 | * the x coordinate of the "pointer" of the cursor 800 | */ 801 | private int mHotspotX; 802 | 803 | /** 804 | * the y coordinate of the "pointer" of the cursor which is usually the top, so it's zero. 805 | */ 806 | private int mHotspotY; 807 | 808 | 809 | /** 810 | * Adjustment to add to the Raw x, y coordinate of the touch position to get the location of where 811 | * the cursor is pointing to 812 | */ 813 | private int mAdjustX; 814 | private int mAdjustY; 815 | 816 | private int mOldX; 817 | private int mOldY; 818 | 819 | public CursorHandle(SelectionCursorController controller) { 820 | super(SelectableTextView.this.getContext()); 821 | 822 | mController = controller; 823 | 824 | mDrawable = getResources().getDrawable(R.drawable.cursor); 825 | 826 | /* My Note 827 | At first I tried using mContainer = new PopupWindow(SelectableTextView.this.getContext()) 828 | and mContainer.setContentView(this) in the show() method AND FAILED to draw the 829 | PopupWindow properly (e.g. PopupWindow can't contain the whole drawable, background 830 | of the PopupWindow is not transparent. I think it's because calling 831 | new PopupWindow(context) uses the internal's default style which messes up 832 | */ 833 | mContainer = new PopupWindow(this); 834 | // mContainer.setSplitTouchEnabled(true); 835 | mContainer.setClippingEnabled(false); 836 | 837 | /* My Note 838 | getIntrinsicWidth() returns the width of the drawable after it has been 839 | scaled to the current device's density 840 | e.g. if the drawable is a 15 x 20 image and we load the image on a Nexus 4 (which 841 | has a density of 2.0), getIntrinsicWidth() shall return 15 * 2 = 30 842 | */ 843 | mHeight = mDrawable.getIntrinsicHeight(); 844 | mWidth = mDrawable.getIntrinsicWidth(); 845 | 846 | // the PopupWindow has an initial dimension of (0, 0) 847 | // must set the width/height of the popupwindow in order for it to be drawn 848 | mContainer.setWidth(mWidth); 849 | mContainer.setHeight(mHeight); 850 | 851 | // this is the location of where the pointer is relative to the cursor itself 852 | // if the left and right cursor are different, mHotspotX will need to be calculated 853 | // differently for each cursor. Currently, I'm using the same left and right cursor 854 | mHotspotX = mWidth / 2; 855 | mHotspotY = 0; 856 | 857 | invalidate(); 858 | } 859 | 860 | 861 | @Override 862 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 863 | setMeasuredDimension(mWidth, mHeight); 864 | } 865 | 866 | @Override 867 | protected void onDraw(Canvas canvas) { 868 | mDrawable.setBounds(0, 0, mWidth, mHeight); 869 | mDrawable.draw(canvas); 870 | } 871 | 872 | @Override 873 | public boolean /*CursorHandle::*/onTouchEvent(MotionEvent event) { 874 | 875 | int rawX = (int) event.getRawX(); 876 | int rawY = (int) event.getRawY(); 877 | 878 | switch (event.getAction()) { 879 | case MotionEvent.ACTION_DOWN: { 880 | // calculate distance from the (x,y) of the finger to where the cursor 881 | // points to 882 | mAdjustX = mHotspotX - (int) event.getX(); 883 | mAdjustY = mHotspotY - (int) event.getY(); 884 | mOldX = mAdjustX + rawX; 885 | mOldY = mAdjustY + rawY; 886 | 887 | mIsDragging = true; 888 | if (SelectableTextView.this.mOnCursorStateChangedListener != null) { 889 | SelectableTextView.this.mOnCursorStateChangedListener.onDragStarts(SelectableTextView.this); 890 | } 891 | break; 892 | } 893 | case MotionEvent.ACTION_UP: 894 | case MotionEvent.ACTION_CANCEL: { 895 | mIsDragging = false; 896 | mController.snapToSelection(); 897 | break; 898 | } 899 | case MotionEvent.ACTION_MOVE: { 900 | // calculate the raw (x, y) the cursor is POINTING TO 901 | int x = mAdjustX + rawX; 902 | int y = mAdjustY + rawY; 903 | 904 | mController.updatePosition(this, x, y, mOldX, mOldY); 905 | 906 | mOldX = x; 907 | mOldY = y; 908 | break; 909 | } 910 | } 911 | return true; // consume the event 912 | } 913 | 914 | 915 | public boolean isShowing() { 916 | return mContainer.isShowing(); 917 | } 918 | 919 | /** 920 | * Show the cursor pointing to the specified point. 921 | * 922 | * @param x the x coordinate of the point relative to the TextView 923 | * @param y the y coordinate of the point relative to the TextView 924 | */ 925 | public void show(int x, int y) { 926 | final int[] coords = mTempCoords; 927 | SelectableTextView.this.getLocationInWindow(coords); 928 | 929 | coords[0] += x - mHotspotX; 930 | coords[1] += y - mHotspotY; 931 | mContainer.showAtLocation(SelectableTextView.this, Gravity.NO_GRAVITY, coords[0], coords[1]); 932 | } 933 | 934 | /** 935 | * move the cursor to point the (x,y) location on the screen. 936 | * 937 | * @param x the x coordinate on the screen 938 | * @param y the y coordinate on the screen 939 | */ 940 | private void pointTo(int x, int y) { 941 | if (isShowing()) { 942 | mContainer.update(x - mHotspotX, y - mHotspotY, -1, -1); 943 | } 944 | } 945 | 946 | 947 | /** 948 | * hide this cursor 949 | */ 950 | public void hide() { 951 | mIsDragging = false; 952 | mContainer.dismiss(); 953 | } 954 | 955 | @SuppressWarnings("unused") 956 | public void dismiss() { 957 | mContainer.dismiss(); 958 | onDetached(); 959 | } 960 | 961 | private void onDetached() { 962 | // hide action windows 963 | 964 | // This is not used but kept for future reference. 965 | // Since no action windows is associate with the cursor, nothing is removed. 966 | // I've made the action window a task of the View using SelectableTextView instead of 967 | // embedding it in here. 968 | } 969 | 970 | } 971 | 972 | public interface OnCursorStateChangedListener { 973 | /** 974 | * What to do when the cursors is hidden from the view 975 | * 976 | * @param v the view the cursor belongs to 977 | */ 978 | public void onHideCursors(View v); 979 | 980 | /** 981 | * What to do when the cursors show 982 | * 983 | * @param v the view the cursor belongs to 984 | */ 985 | public void onShowCursors(View v); 986 | 987 | /** 988 | * What to do when the drag begins 989 | * 990 | * @param v the view the cursor belongs to 991 | */ 992 | public void onDragStarts(View v); 993 | 994 | public void onPositionChanged(View v, int x, int y, int oldx, int oldy); 995 | } 996 | 997 | } 998 | 999 | /* 1000 | Note for future me: 1001 | The "LINE" for layout uses 0 based index e.g. getLineForVertical 1002 | */ 1003 | -------------------------------------------------------------------------------- /src/com/zyz/mobile/example/SelectionInfo.java: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2013 Ray Zhou 3 | 4 | JadeRead is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | JadeRead is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with JadeRead. If not, see 16 | 17 | Author: Ray Zhou 18 | Date: 2013 06 03 19 | 20 | */ 21 | package com.zyz.mobile.example; 22 | 23 | 24 | import android.text.Spannable; 25 | import android.text.Spanned; 26 | import android.text.style.CharacterStyle; 27 | 28 | /** 29 | * class to hold the selection information 30 | */ 31 | public class SelectionInfo { 32 | 33 | private Object mSpan; 34 | private int mStart; 35 | private int mEnd; 36 | private Spannable mSpannable; 37 | 38 | public SelectionInfo() { 39 | clear(); 40 | } 41 | 42 | public SelectionInfo(CharSequence text, Object span, int start, int end) { 43 | set(text, span, start, end); 44 | } 45 | 46 | /** 47 | * select the {@link #getSpannable()} between the offsets {@link this.getStart()} and 48 | * {@link #getEnd()} 49 | */ 50 | public void select() { 51 | select(mSpannable); 52 | } 53 | 54 | public void select(Spannable text) { 55 | if (text != null) { 56 | text.removeSpan(mSpan); 57 | text.setSpan(mSpan, Math.min(mStart, mEnd), Math.max(mStart, mEnd), Spanned.SPAN_INCLUSIVE_INCLUSIVE); 58 | } 59 | } 60 | 61 | /** 62 | * remove the the selection 63 | */ 64 | public void remove() { 65 | remove(mSpannable); 66 | } 67 | 68 | public void remove(Spannable text) { 69 | if (text != null) { 70 | text.removeSpan(mSpan); 71 | } 72 | } 73 | 74 | public void clear() { 75 | mSpan = null; 76 | mSpannable = null; 77 | mStart = 0; 78 | mEnd = 0; 79 | } 80 | 81 | public void set(Object span, int start, int end) { 82 | mSpan = span; 83 | mStart = start; 84 | mEnd = end; 85 | } 86 | 87 | public void set(CharSequence text, Object span, int start, int end) { 88 | if (text instanceof Spannable) { 89 | mSpannable = (Spannable) text; 90 | } 91 | set(span, start, end); 92 | } 93 | 94 | public CharSequence getSelectedText() { 95 | if (mSpannable != null) { 96 | int start = Math.min(mStart, mEnd); 97 | int end = Math.max(mStart, mEnd); 98 | 99 | if (start >= 0 && end < mSpannable.length()) { 100 | return mSpannable.subSequence(start, end); 101 | } 102 | } 103 | return ""; 104 | } 105 | 106 | public Object getSpan() { 107 | return mSpan; 108 | } 109 | 110 | public void setSpan(CharacterStyle span) { 111 | mSpan = span; 112 | } 113 | 114 | 115 | /** 116 | * get the starting offset of the selection. Note the the starting offset is 117 | * not necessarily smaller than the ending offset 118 | * @return the starting offset of the selection 119 | */ 120 | public int getStart() { 121 | return mStart; 122 | } 123 | 124 | /** 125 | * set the starting offset of the selection (inclusive) 126 | * @param start the starting offset. It can be larger than {@link #getEnd()} 127 | */ 128 | public void setStart(int start) { 129 | assert (start >= 0); 130 | mStart = start; 131 | } 132 | 133 | /** 134 | * get the ending offset of the selection. Note the the ending offset is 135 | * not necessarily larger than the starting offset 136 | * @return the ending offset of the selection 137 | */ 138 | public int getEnd() { 139 | return mEnd; 140 | } 141 | 142 | /** 143 | * set the ending offset of the selection (exclusive) 144 | * @param end the ending offset. It can be smaller than {@link #getStart()} 145 | */ 146 | public void setEnd(int end) { 147 | assert (end >= 0); 148 | mEnd = end; 149 | } 150 | 151 | public Spannable getSpannable() { 152 | return mSpannable; 153 | } 154 | 155 | public void setSpannable(Spannable spannable) { 156 | mSpannable = spannable; 157 | } 158 | 159 | /** 160 | * Checks the weather the specified offset is within the range of the selection 161 | * @param offset the offset to check 162 | * @return true if the offset is within the range of the selection, false otherwise. 163 | */ 164 | public boolean offsetInSelection(int offset) { 165 | return (offset >= mStart && offset <= mEnd) || 166 | (offset >= mEnd && offset <= mStart); 167 | } 168 | } --------------------------------------------------------------------------------