Slide 1

Slide 1 text

助けて Spannable 〜俺達は雰囲気でテキスト内リンクをつけている〜 Yoshihiro Wada a.k.a. @e10dokup 2017/09/15 @ shibuya.apk #18

Slide 2

Slide 2 text

1 Yoshihiro Wada  CyberAgent, Inc. / Adtech Studio (新卒) @e10dokup

Slide 3

Slide 3 text

1 Yoshihiro Wada  CyberAgent, Inc. / Adtech Studio @e10dokup 最近 iOS を始めて血反吐を吐いているっぽい

Slide 4

Slide 4 text

2 今日の話題

Slide 5

Slide 5 text

2 今日の話題

Slide 6

Slide 6 text

3 今日の話題 テキスト内リンクをどう実現するって話

Slide 7

Slide 7 text

4 考えられる解答 Linkify.addLink(textView, Linkify.ALL) 等 Html.formHtml(string) TextView に autolink 属性を付ける API の返り値が HTML になってるわけじゃないし… リンクにはなるけどクリックイベントを内部で消化してしまう… ALL にしても拾えないものが… (@-mention とか # とか)

Slide 8

Slide 8 text

5 結局のところ SpannableString/ClickableSpan に頼る 正規表現さえ書けば好きな文字列に対してリンクを適用可能 Listener でクリックイベントを Activity/Fragment まで落とせる オマケにリンク部分の表示をいじれる

Slide 9

Slide 9 text

6 テキスト中のリンクをリンクとして表示する ClickableSpan のサブクラスを実装する class LinkSpan( private val url: String, private val listener: ((String) -> Unit)? ) : ClickableSpan() { // クリック時にlistenerをコールする override fun onClick(widget: View?) { listener?.invoke(url) } // リンク化される部分のテキストの表示をいじる override fun updateDrawState(ds: TextPaint?) { super.updateDrawState(ds) } }

Slide 10

Slide 10 text

7 テキスト中のリンクをリンクとして表示する 表示する文字列を SpannableString 化する fun String.toSpannable( linkListener: ((String) -> Unit)? ): SpannableString { val spannableString = SpannableString(this) val urlMatcher = URL_MATCH_PATTERN.matcher(this) while (urlMatcher.find()) { // Spanを生成して val span = LinkSpan(urlMatcher.group(), linkListener) // マッチした文字列にセットする spannableString.setSpan(span, urlMatcher.start(), urlMatcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } return spannableString }

Slide 11

Slide 11 text

8 TextView にリンクとして反映させる SpannableString 化した文字列を TextView にセット textView.text = string.toSpannable { link -> // 渡されたリンク文字列linkで何かする。Intentさせるとか。 } このままだとリンクにはなったけどクリックしても何も起きない 「Span をクリックした」 と判別する処理が必要 textView.movementMethod = LinkMovementMethod.getIntstance() 大体 LinkMovementMethod で ok

Slide 12

Slide 12 text

9 もうひと工夫させたい OnLongClickListener 等と組み合わせると事故る 長押しでコピー等のメニューを出したい時、 リンクを長押しすると メニュー表示と同時にブラウザが立ち上がる… こっちがセットしたリスナと LinkMovementMethod の onClick の発火が排他的に動けば解決できそう 独自の LinkMovementMethod を作るしかない

Slide 13

Slide 13 text

9 LinkMovementMethod のカスタム 必要なこと インスタンスの提供 companion object { private var _instance: MyMovementMethod? = null var instance: MyMovementMethod? = null get() { if (_instance == null) { _instance = MyMovementMethod() } return _instance } } onTouchEvent での onClick 発火制御 タッチされている座標が Span かどうか判別する処理

Slide 14

Slide 14 text

10 LinkMovementMethod のカスタム onTouchEvent での onClick 発火制御 ACTION_DOWN/UP/CANCEL で操作 ACTION_DOWN で現在のクリック位置から Span を検出 ACTION_UP + 時間内でクリック中 Span の onClick を呼ぶ ACTION_CANCEL でクリック中 Span をなかったことにする

Slide 15

Slide 15 text

override fun onTouchEvent( widget: TextView, buffer: Spannable, event: MotionEvent ): Boolean { val action = event.action // タッチ位置からSpanを取得する、nullならreturn val currentSpan = findSpan(widget, buffer, event.x.toInt(), event.y.toInt()) currentSpan ?: return false when (action) { MotionEvent.ACTION_DOWN -> { keepingSpan = currentSpan // クリック無効化のための遅延処理 invalidationHandler.postDelayed(DELAY_TIME) { keepingSpan = null } return true } MotionEvent.ACTION_UP -> { // クリックが有効かつDOWN-UPまで同じSpanが維持されていたらonClickを呼ぶ if (currentSpan == keepingSpan) { keepingSpan?.onClick(widget) } keepingSpan = null invalidationHandler.removeCallbacksAndMessages(null) return true } MotionEvent.ACTION_CANCEL -> { keepingSpan = null return true } else -> return false } }

Slide 16

Slide 16 text

12 LinkMovementMethod のカスタム タッチされている座標が Span か判別する処理 Padding,Scroll からタッチ座標を補正する 補正したタッチ座標から何文字目をタッチしたか導出する タッチした文字が Span の中なら ClickableSpan を、 そうでなければ null を返す

Slide 17

Slide 17 text

private fun findSpan( widget: TextView, buffer: Spannable, x: Int, y: Int ): ClickableSpan? { var x = x var y = y // タッチ座標の補正 x -= widget.totalPaddingLeft y -= widget.totalPaddingTop x += widget.scrollX y += widget.scrollY // タッチ文字の導出 val layout = widget.layout val line = layout.getLineForVertical(y) val off = layout.getOffsetForHorizontal(line, x.toFloat()) // spanを含んているか確認して結果を返す val link = buffer.getSpans(off, off, ClickableSpan::class.java) return if (link.isNotEmpty()) { link[0] } else { null } }

Slide 18

Slide 18 text

14 まとめ テキスト内リンクをどう扱うか ただリンク飛ばすだけなら Linkify とかで十分 テキスト内リンクだけイベントを拾いたいなら Span だけ実装して LinkMovementMethod を実装すれば OK テキスト全体のイベントも扱うなら LinkMovementMethod を 拡張してリンクのイベントと排他的に処理しよう ただここまでやる必要があるのはチャットや SNS のタイムライン くらいでは…? https://github.com/e10dokup/linksample にサンプルあります