1. setTextメソッドの前に呼んではダメ!!
概要
TextViewの中にあるURLのリンクを押したときの動きをMovementMethodを使って書き換えようとしたのだが、RecyclerViewにBindされるViewHolderが保持してるViewの中にあるTextViewに適用したところうまく動かずハマった。
基本はこちら通り実装したのだが、文中にurlリンクがないときはMovementMethod#onTouchEventメソッドが呼ばれるのだが、リンクがあると文中のどこを触っても呼ばれない。
丸一日頭を悩ませていたが、最終的にTextViewのソースをあたってみたところ、setMovementMethodメソッドの呼ぶタイミングについてドキュメントにない条件があったことが分かったので共有。
原因・結論
TextViewのsetMovementMethodメソッドをsetTextメソッドの前に呼んではダメ!!
詳細
TextViewのソース (https://android.googlesource.com/platform/frameworks/base/+/jb-release/core/java/android/widget/TextView.java) のsetTextメソッド(3412行目)からを見てみたところ、中ほどに。
/*
* We must go ahead and set the text before changing the
* movement method, because setMovementMethod() may call
* setText() again to try to upgrade the buffer type.
*/
【MovementMethodを置き換える前にTextは置き換える事】とのコメントと共に、リンクがあるときはMovementMethodをデフォルトの物にするようなコードが!!!
元々、itemのpositionやTextViewの中身で動きを変える必要がなかったので、RecyclerViewの中onCreateViewHolderでsetMovementMethodを呼んでその後onBindViewHolderでpositonに応じてTextViewの中身をセットしていた。のでまんまとこの罠にはまってしまっていた。setTextの後にsetMovementMethodを呼ぶようにしたら無事解決。
感想
コードのコメントに書いていてくれたおかげでソースを見た時にすぐに気づけたのはありがたかった。
けど、そもそもsetMovementMethodのドキュメントをみてもsetTextのドキュメントを見てもこんなこと一言も書いていなかったけど、そういう条件があるのがわかっているならドキュメントにも書いておいてほしかった…。
2. selectable == trueはダメ → android:textIsSelectable=true
の時は、LinkMovementMethod#onTouchEventにひと工夫必要(2022/2 追記)
概要
TextViewのselectableをtrueにしておくと、setしたMovementMethodの他にデフォルトのMovementMethodも動いてしまった。(Huawei nova3 / android 9)
falseにしたところ、setした方のみ動くように戻せた。
詳細
これに関してはソースやドキュメントにちゃんとした根拠は見つけられなかった。
2022/2 追記
基本は前述のリンク通りの実装でよいのだが、 android:textIsSelectable=true
の時はひと工夫必要なよう。
以下のように、セットするLinkMovementMethodのonTouchEventの中で、リンクありかつMotionEvent.ACTION_DOWN
の時に Selection.setSelection
を呼ばないように(何もしないように)変更する。すると、上記のような「setしたMovementMethodの他にデフォルトのMovementMethodも動く」ようなことは起きない。
なぜこれがあるとだめなのか、コードを見てもいまいちまだつかめていないけど…
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
// LinkMovementMethod#onTouchEventそのまんま
int action = event.getAction();
if (action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
Layout layout = widget.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
// リスナーがあればそちらを呼び出し
if(link[0] instanceof URLSpan && listener!=null){
Uri uri = Uri.parse( ((URLSpan)link[0]).getURL() );
listener.onUrlClick(widget, uri);
}else{
link[0].onClick(widget);
}
} else if (action == MotionEvent.ACTION_DOWN) {
// XXXX 追加
// ここはコメントアウト
/*
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
*/
}
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}
2022/3 追記
2022/2 追記の内容でも、リンクをタップしたときに結構な確率で「setしたMovementMethodで設定した動きに加えて、デフォルトのタップ時の動きも発生する」(つまりコールバックが2つ呼ばてれる)ということが起きていた。
以下のようにsetしたMovementMethodのonTouchEvent
が呼ばれるたびに【リンクタップだったら Selection#removeSelection
を呼ぶ】としてやれば起きないことが分かった。
例によってなぜそうなってしまっているのかコード的に読めていないけど。
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
// LinkMovementMethod#onTouchEventそのまんま
int action = event.getAction();
if (action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
Layout layout = widget.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
// リスナーがあればそちらを呼び出し
if(link[0] instanceof URLSpan && listener!=null){
Uri uri = Uri.parse( ((URLSpan)link[0]).getURL() );
listener.onUrlClick(widget, uri);
// ----------------------以下追加---------------------------------
Selection.removeSelection(buffer);
// ----------------------追加終わり---------------------------------
}else{
link[0].onClick(widget);
}
} else if (action == MotionEvent.ACTION_DOWN) {
// XXXX 追加
// ここはコメントアウト
/*
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
*/
}
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}