【Kotlin】SpanでTextの一部装飾

こんにちは、Android開発してるめっしーです。
[Twitter](https://twitter.com/messi_0601)はこちらです

# 始めに
Android(kotlin)で文字列を装飾する際に利用する**スパン**について紹介します。
下の方にktx使った簡単なものも書きました。

# スパンとは
スパンは強力なマークアップオブジェクトで、文字単位や段落単位でテキストのスタイルを設定できます。
スパンをテキストオブジェクトにアタッチすると、色、テキストのクリック可視化、文字サイズの拡大縮小、など様々な方法でテキストを変更できます。

# 種類
スパンを作成するには、以下のリスト表示されているクラスを用途に応じて利用する。

|クラス  |テキストは変更可 |マークアップは変更可  | データ構造|
|---|---|---|---|
|SpannedString  |×  |×  |リニア配列|
|SpannableString  |×  |○  |リニア配列|
|SpannableStringBuilder  |○  |○  |区間ツリー|

### SpannedString
テキストやマークアップを作成後に変更しない場合に利用

### SpannableString
単一のテキストオブジェクトに少数のスパンをアタッチした、テキスト自体は読み取り専用にする場合に利用。

### SpannableStringBuilder
作成後にテキストを変更する必要があり、テキストにスパンをアタッチする必要がある場合
テキストオブジェクトに多数のスパンをアタッチする必要がある場合は、テキスト自体を読み取り専用にするかどうかにかかわらず利用

# 実装
## 基本形
スパン適用するには

`setSpan(Object what, int start, int end, int flags)`を呼び出す

```kotlin
val spannable = SpannableStringBuilder("Sample Text")
spannable.setSpan(
    ForegroundColorSpan(Color.RED),
    6, // start
    10, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
)
```

|flag  |説明 |
|---|---|
|Spannable.SPAN_EXCLUSIVE_INCLUSIVE  |挿入テキストを含める場合 |
|Spannable.SPAN_EXCLUSIVE_EXCLUSIVE  |挿入テキストを除外する場合 |




## 文字のRangeをとる拡張関数
Spanを使う時に文字の一部を装飾したい機会が多いと思われます。
その際に特定の文字のRangeを取得できるものがあると便利なので以下のような拡張関数を作ります。
この後のサンプルもこれを使ったもので説明していきます。

```kotlin
fun String.rangeOfIndex(string: String): IntRange {
  val startIndex = indexOf(string)
  return startIndex until startIndex + string.length
}
```


## KTX
ktxの拡張機能にもスパンがあります

https://developer.android.com/kotlin/ktx/extensions-list?hl=ja#androidxcoretext


### 依存関係

```kotlin
dependencies {
    implementation("androidx.core:core-ktx:1.5.0")
}
```

# スパンでできるスタイル
スパンで可能となってる装飾を色々紹介していきます。
ktxで出来るものはその実装も一緒に載せておきます。

## 色

`ForegroundColorSpan(Color.RED)`を利用

![スクリーンショット 2021-06-08 2.20.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/975ac4a1-10a7-83fa-adb1-ed9164a6c6da.png)


```kotlin
private fun spanColor(): SpannableStringBuilder {
  val sampleLabel = "Sample Text"
  val colorRange = sampleLabel.rangeOfIndex("Text")
  return SpannableStringBuilder(sampleLabel).apply {
    setSpan(
      ForegroundColorSpan(Color.RED),
      colorRange.first, // start
      colorRange.last.inc(), // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}

//kts
private fun spanColorKtx(): SpannedString {
  val sampleLabel = "Sample Text"
  val colorRange = sampleLabel.rangeOfIndex("Text")
  return buildSpannedString {
    append(sampleLabel.subSequence(0, colorRange.first))
    color(Color.RED) {
      append(sampleLabel.subSequence(colorRange))
    }
  }
}

```


## アンダーライン

`UnderlineSpan()`を利用
![スクリーンショット 2021-06-08 2.23.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/ac100020-3a56-34c6-adf2-d6e661778171.png)

```kotlin
private fun spanUnderline(): SpannableStringBuilder {
  val sampleLabel = "Sample Text"
  val underlineRange = sampleLabel.rangeOfIndex("Text")
  return SpannableStringBuilder(sampleLabel).apply {
    setSpan(
      UnderlineSpan(),
      underlineRange.first, // start
      underlineRange.last.inc(), // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}

//ktx
private fun spanUnderlineKtx(): SpannedString {
  val sampleLabel = "Sample Text"
  val underlineRange = sampleLabel.rangeOfIndex("Text") 
  return buildSpannedString {
    append(sampleLabel.subSequence(0, underlineRange.first))
    underline {
      append(sampleLabel.subSequence(underlineRange))
    }
  }
}
```

## 文字サイズ

`RelativeSizeSpan(1.5f)`を利用
![スクリーンショット 2021-06-08 2.24.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/e3c2c0ec-70ce-30f0-53f6-042dedac073b.png)

50%大きくする場合に

```kotlin
private fun spanSize(): SpannableStringBuilder {
  return SpannableStringBuilder("Sample Text").apply {
    setSpan(
      RelativeSizeSpan(1.5f),
      7, // start
      this.length, // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}

//kts
private fun spanSizeKtx(): SpannedString {
  val sampleLabel = "Sample Text"
  val underlineRange = sampleLabel.rangeOfIndex("Text")
  return buildSpannedString {
    append(sampleLabel.subSequence(0, underlineRange.first))
    scale(1.5f) {
      append(sampleLabel.subSequence(underlineRange))
    }
  }
}
```

## 背景に色を
`BackgroundColorSpan()`を利用

![スクリーンショット 2021-06-08 2.24.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/767bb265-95c7-ff87-a43a-3cc203cc506d.png)

```kotlin
private fun spanBackgroundColor(): SpannableStringBuilder {
  return SpannableStringBuilder("Sample Text").apply {
    setSpan(
      BackgroundColorSpan(Color.RED),
      7, // start
      this.length, // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}

//ktx
private fun spanBackgroundKtx(): SpannedString {
  val sampleLabel = "Sample Text"
  val underlineRange = sampleLabel.rangeOfIndex("Text")
  return buildSpannedString {
    append(sampleLabel.subSequence(0, underlineRange.first))
    backgroundColor(Color.RED) {
      append(sampleLabel.subSequence(underlineRange))
    }
  }
}
```

## 段落
`QuoteSpan()`を利用
![スクリーンショット 2021-06-08 13.55.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/2ba7fb3c-9c42-8403-080f-ff997463e01f.png)

```kotlin
private fun spanParagraph(): SpannableStringBuilder {
    return SpannableStringBuilder("Sample \nText").apply {
      setSpan(
        QuoteSpan(),
        0, // start
        this.length, // end
        Spannable.SPAN_EXCLUSIVE_INCLUSIVE
      )
    }
  }
```

API28からは段落の横のスタイルを変えることもできます。

![スクリーンショット 2021-06-08 14.02.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/9212e04d-24d2-cd77-6e7b-08bf928e3ec3.png)

```kotlin
@RequiresApi(Build.VERSION_CODES.P)
  private fun spanParagraph(): SpannableStringBuilder {
    return SpannableStringBuilder("Sample \nText").apply {
      setSpan(
        QuoteSpan(Color.GREEN, 20, 40),
        0, // start
        this.length, // end
        Spannable.SPAN_EXCLUSIVE_INCLUSIVE
      )
    }
  }
```


## クリック処理

![スクリーンショット 2021-06-08 2.25.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/eda09c99-7059-d438-dd87-24e5d9f5d100.png)

```kotlin
private fun spanClick(): SpannableStringBuilder {
  val sampleLabel = "Sample Text"
  val sizeRange = sampleLabel.rangeOfIndex("Text")
  return SpannableStringBuilder(sampleLabel).apply {
    setSpan(
      object : ClickableSpan() {
        override fun onClick(widget: View) {
          // TODO クリック処理
        }
      },
      sizeRange.first, // start
      sizeRange.last.inc(), // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}

//ktx
private fun spanClickKtx(): SpannedString {
  val sampleLabel = "Sample Text"
  val sizeRange = sampleLabel.rangeOfIndex("Text")
  return buildSpannedString {
    append(sampleLabel.subSequence(0, sizeRange.first))
    inSpans(
      object : ClickableSpan() {
        override fun onClick(widget: View) {
          // TODO クリック処理
        }
      },
    ) {
      append(sampleLabel.subSequence(sizeRange))
    }
  }
}
```

クリック処理を行うには以下も忘れずに

```kotlin
termsText.apply {
  text = spanClick()
  //追加
  movementMethod = LinkMovementMethod.getInstance()
}
```

`movementMethod` 
TextViewにナビゲーションの目的で、キーイベント、トラックボール、モーション、およびタッチの処理をmoveメソッドに委任します。

`LinkMovementMethod`
テキスト内のリンクを移動し、必要に応じてスクロールするためのクラス。

## 複数のスパン

1つのテキストに複数のスパンをアタッチできる

例)色とsyleを指定する
![スクリーンショット 2021-06-08 2.22.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188385/16366460-9d5f-3b7f-5aaa-f22231e47893.png)

```kotlin
private fun spanMultiple(): SpannableString {
  val sampleLabel = "Sample Text"
  val boldRange = sampleLabel.rangeOfIndex("Text")
  val colorRange = sampleLabel.rangeOfIndex("Te")
  return SpannableString(sampleLabel).apply {
    setSpan(
      ForegroundColorSpan(Color.RED),
      colorRange.first, // start
      colorRange.last.inc(), // end
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
    setSpan(
      StyleSpan(Typeface.BOLD),
      boldRange.first,
      boldRange.last.inc(),
      Spannable.SPAN_EXCLUSIVE_INCLUSIVE
    )
  }
}
```

# サンプルレポジトリ
こちらは自分が行ってきた技術サンプルが詰まってます。
その中で今回は
`SpanFragment.kt`に今回のコードが書かれてあります。ぜひご覧ください。

https://github.com/175atsu/AndroidTechSample


# 参考
https://developer.android.com/guide/topics/text/spans?hl=ja


ではまた!