ComposeでMarkdownを表示するには

# 初めに

[ポート株式会社 サービス開発部 Advent Calendar 2023](https://adventar.org/calendars/9156) ５日目の記事です。

![スクリーンショット 2023-12-05 11.06.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179470/427216d2-62b6-76b6-24da-c30f9ded8b7a.png)

こんにちは。[ポート株式会社](https://www.theport.jp)でAndroid開発をしている[shxun6934](https://twitter.com/shxun6934)です。

自分が担当しているプロジェクトにJetpack Composeを入れて1年が経ちました。（早いw）
新規機能を作成する際に、Jetpack Composeを使用してガンガン開発を進めています。

その新規機能を作成するタスクの中で、WebのAPIのデータからmarkdownの文字列を取得し、そのデータを表示する要件がありました。

AndroidのJetpack標準APIにはMarkdownを出力するためのものが存在しない（と思っている）ので、サードパーティー製のライブラリを使用して、ComposeでMarkdownを表示する実装を行いました。

その際のTipsを紹介できればと思います！

## 前提

※ 今回は、基本的なMarkdownデータをパースします。

- ヘッダー（h1 ~ h6）
- 強調
- 太字
- 水平線
- コード
- コードブロック
- リスト
- 引用
- リンク

## ライブラリー

**Markwon**というライブラリーを使用しました。

公式ドキュメントも公開されていて、個人的に分かりやすいと感じたため、このライブラリーを採用しました。

- Github: https://github.com/noties/Markwon
- Documet: https://noties.io/Markwon/

### インストール

使用したいモジュールの`build.gradle`に、`io.noties.markwon:core`の依存関係を記載します。

また、必要に応じて、`Plugin`を入れておきます。
今回は、リンクを有効化するための`Plugin`も記載しておきます。

```build.gradle.kts
dependencies {
    val markwon_version = 4.6.2

    implementation "io.noties.markwon:core:$markwon_version"

    // URLリンクを有効化するためのPlugin
    implementation "io.noties.markwon:linkify:$markwon_version"
}
```

どんな`Plugin`があるかや依存関係の宣言の仕方は、https://noties.io/Markwon/docs/v4/install.html を見ていただくと分かりやすいと思います！
（各Pluginの簡単な説明やチェックボックスで必要なPluginの宣言方法がわかります。）

![スクリーンショット 2023-12-05 0.52.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179470/dadf15ff-c58c-67e5-89b9-37fb1801a8d3.png)

## 実現

先に結論を述べてしまうと、`TextView`を[**AndroidView** Composable](https://developer.android.com/jetpack/compose/migrate/interoperability-apis/views-in-compose)経由で表示する形で実現しました。

`Markwon`に、表示したい`TextView`とMarkdownデータを渡してあげることで、`Markwon`がよしなに`TextView`にMarkdownデータを表示するという仕組みになってます。（だいぶ雑に説明してます。。。）

```kotlin
// databingで表示する場合
val textView = binding.textView
val markdownText = """
    # Header1

    _Emphasis_

    **Strong Emphasis**

    ---

    - list-1
      - list-1-1
        - list-1-1-1
    - list-2
    - list-3
""".trimIndent()

val markwon: Markwon = Markwon.builder(requireContext())

markwon.setMarkdown(textView, markdownText)
```

Composeでの`Text` Composableでは、`Markwon`を使用することができないため、**あえて**`TextView`を`AndroidView` ComposableでComposeとして表現するようにしてあげます。

### AndroidView Composable

`TextView`や`WebView`、`ConstraintLayout`などのAndroidViewをComposeのUI階層に埋め込むために使用します。

プロジェクト独自で作成したViewをComposableとして使用したい場合に用いることが多いかもしれないです。

例：

```kotlin
@Composable
fun CustomTextView(
    modifier: Modifier = Modifier,
    name: String
) {

    AndroidView(
        modifier = modifier,
        factory = { context ->
            // TextViewを生成する
            TextView(context)
        },
        update = { textView ->
            // 生成されたTextViewに文字列を渡す
            textView.text = "Hello $name!"
        }
    )
}
```

### MarkdownText

`TextView`を`AndroidView` Composableで表現しつつ、マークダウンを表示するために必要な設定をした**MarkdownText** Composableを作成しました。

実装時は、`Text` Composableを呼び出す時と同じような感覚で呼び出せるように、コンポーネント設計を`Text` Composableを参考にしました。

https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#Text(kotlin.String,androidx.compose.ui.Modifier,androidx.compose.ui.graphics.Color,androidx.compose.ui.unit.TextUnit,androidx.compose.ui.text.font.FontStyle,androidx.compose.ui.text.font.FontWeight,androidx.compose.ui.text.font.FontFamily,androidx.compose.ui.unit.TextUnit,androidx.compose.ui.text.style.TextDecoration,androidx.compose.ui.text.style.TextAlign,androidx.compose.ui.unit.TextUnit,androidx.compose.ui.text.style.TextOverflow,kotlin.Boolean,kotlin.Int,kotlin.Int,kotlin.Function1,androidx.compose.ui.text.TextStyle)

```kotlin:Text
@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    minLines: Int = 1,
    onTextLayout: ((TextLayoutResult) -> Unit)? = null,
    style: TextStyle = LocalTextStyle.current
): Unit
```

`Text` Composabelの`text`引数にMarkdownのデータを渡すイメージです。
その他の引数は、必要に応じて削除・更新・追加して、よりプロジェクトにあったコンポーネント設計にしてもいいかなと思います！

自分の場合は、以下のように設定しました。

```kotlin
@Composable
fun MarkdownText(
    markdown: String,  // Markdownの文字列をもらう
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    linkColor: Color = Color.Unspecified, // 埋め込みリンクのフォントカラーを指定できるようにしておく
    fontSize: TextUnit = TextUnit.Unspecified,
    fontWeight: FontWeight? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle = LocalTextStyle.current
): Unit
```

### 実装

`MarkdownText`のコンポーネント設計ができたので、ロジックを組み立て行きます！

#### TextViewの構築

まず、`TextView`を`AndroidView` Composableで表現します。
（AndroidView Composableのセクションで簡単な例を記載しているので、参考にしていただけると🙏）

外部から指定があった要素を`TextView`に適用しつつ、`TextView`を生成し、`AndroidView` Composableのfactoryに渡してあげます。

```kotlin:MarkdownText.kt
@Composable
fun MarkdownText(
    markdown: String,  // Markdownの文字列をもらう
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    linkColor: Color = Color.Unspecified, // 埋め込みリンクのフォントカラーを指定できるようにしておく
    fontSize: TextUnit = TextUnit.Unspecified,
    fontWeight: FontWeight? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle = LocalTextStyle.current
) {
    val defaultColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
  
    AndroidView(
        modifier = modifier,
        factory = { context ->
            createTextView(
                context = context,
                color = color,
                linkColor = linkColor,
                defaultColor = defaultColor,
                fontSize = fontSize,
                fontWeight = fontWeight,
                textAlign = textAlign,
                lineHeight = lineHeight,
                maxLines = maxLines,
                style = style
            )
        }
    )
}

// 外部から指定があった要素を適用したTextViewを生成
private fun createTextView(
    context: Context,
    color: Color = Color.Unspecified,
    linkColor: Color = Color.Unspecified,
    defaultColor: Color,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontWeight: FontWeight? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle
): TextView {
    // カラー・スタイルを指定しない場合は、Themeのカラーを見るようにする。
    val textColor = color.takeOrElse { style.color.takeOrElse { defaultColor } }
    val linkTextColor = linkColor.takeOrElse { style.color.takeOrElse { defaultColor } }

    // TextViewのスタイルをマージ
    val mergedStyle = style.merge(
        TextStyle(
            color = textColor,
            fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize,
            fontWeight = fontWeight,
            textAlign = textAlign,
            lineHeight = if (lineHeight != TextUnit.Unspecified) lineHeight else style.lineHeight
        )
    )

    return TextView(context).apply {
        // フォントカラー適用
        setTextColor(textColor.toArgb())
        setLinkTextColor(linkTextColor.toArgb())

        // lineHeight適用
        when {
            style.lineHeight.isSp -> {
                TextViewCompat.setLineHeight(
                    this,
                    TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_SP,
                        style.lineHeight.value,
                        context.resources.displayMetrics
                    ).toInt()
                )
            }
        }

        // maxLine適用
        setMaxLines(maxLines)

        // フォントサイズ適用
        setTextSize(TypedValue.COMPLEX_UNIT_SP, mergedStyle.fontSize.value)

        // TextViewに埋め込まれたリンクをタップした際にUrlに遷移する。
        // 参考: https://qiita.com/le_skamba/items/cca74696095cbbb65cc3
        movementMethod = LinkMovementMethod.getInstance()

         // textAlign適用
        textAlign?.let { align ->
            textAlignment = when (align) {
                TextAlign.Left, TextAlign.Start -> View.TEXT_ALIGNMENT_TEXT_START
                TextAlign.Right, TextAlign.End -> View.TEXT_ALIGNMENT_TEXT_END
                TextAlign.Center -> View.TEXT_ALIGNMENT_CENTER
                else -> View.TEXT_ALIGNMENT_TEXT_START
            }
        }

        // スタイルのdecorationに取り消し線を有効にしている場合は、適用
        if (mergedStyle.textDecoration == TextDecoration.LineThrough) {
            paintFlags = Paint.STRIKE_THRU_TEXT_FLAG
        }
    }
}
```

#### Markwonの設定・適用

生成した`TextView`に`Markdown`データを表示させるために、`Markwon`を設定していきます。

`remember`関数内で`Markdown`の生成メソッドを呼び出すことで、`MarkdownText`の初回Composition時にのみ呼び出すようにします。

`AndroidView`のupdate時に`Markdown`のデータと生成された`TextView`を`Markwon`に渡してあげます。

https://engawapg.net/jetpack-compose/2113/remember-tips/

```diff_kotlin
@Composable
fun MarkdownText(
    markdown: String,  // Markdownの文字列をもらう
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    linkColor: Color = Color.Unspecified, // 埋め込みリンクのフォントカラーを指定できるようにしておく
    fontSize: TextUnit = TextUnit.Unspecified,
    fontWeight: FontWeight? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle = LocalTextStyle.current
) {
    val defaultColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)

+   val context = LocalContext.current
+   val markwon = remember { createMarkwon(context) }
  
    AndroidView(
        modifier = modifier,
        factory = { context ->
            createTextView(
                context = context,
                color = color,
                linkColor = linkColor,
                defaultColor = defaultColor,
                fontSize = fontSize,
                fontWeight = fontWeight,
                textAlign = textAlign,
                lineHeight = lineHeight,
                maxLines = maxLines,
                style = style
            )
        },
+       update = { textView ->
+           markwon.setMarkdown(textView, markdown)
+       }
    )
}
```

`Markwon`を生成するメソッドは以下。

今回は、`LinkifyPlugin`を使用してリンク埋め込み（`[]()`）に対応したのと、プロジェクト独自のPluginを設定して、テーマをカスタマイズしています。（= Headerのフォントサイズとフォントカラーを変更しています。）

```kotlin
private fun createMarkwon(context: Context): Markwon {
    return Markwon.builder(context)
        .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
        .usePlugin(object : AbstractMarkwonPlugin() {

            // MarkwonでのThemeをカスタマイズする。(= MarkdownのThemeをカスタマイズする。)
            // https://noties.io/Markwon/docs/v4/core/theme.html
            override fun configureTheme(builder: MarkwonTheme.Builder) {
                super.configureTheme(builder)

                // h1 ~ h6タグのテキストサイズをbaseのテキストサイズに対しての比率（Float）で指定する。
                // 参考： https://noties.io/Markwon/docs/v4/core/theme.html#text-size
                val headerTextSizes: FloatArray =
                    floatArrayOf(1.14F, 1.07F, 1.00F, 1.00F, 0.83F, 0.67F)

                builder.headingTextSizeMultipliers(headerTextSizes)
                builder.headingBreakHeight(0)
            }

            // 解析されたMarkdownをレンダリングする。
            // https://noties.io/Markwon/docs/v4/core/visitor.html#visitor-builder
            override fun configureVisitor(builder: MarkwonVisitor.Builder) {
                super.configureVisitor(builder)

                builder.on(Heading::class.java) { visitor, heading ->
                    // headerのbottom_marginの設定
                    // 参考: https://github.com/noties/Markwon/issues/106
                    val length = visitor.length()
                    visitor.visitChildren(heading)

                    CoreProps.HEADING_LEVEL.set(
                        visitor.renderProps(),
                        heading.level
                    )

                    visitor.setSpansForNodeOptional(heading, length)
                    if (visitor.hasNext(heading)) {
                        val start = visitor.length()
                        visitor.ensureNewLine()  // 現在の文字の最後が改行でない場合、改行する。
                        visitor.forceNewLine()  // 条件を無視して、改行する。
                        visitor.setSpans(start, AbsoluteSizeSpan(16)) // 空白にSpannableBuilderを適用する。
                    }
                }
            }

            // 解析されたMarkdownのノードのスパンをカスタマイズする。
            // https://noties.io/Markwon/docs/v4/core/spans-factory.html#spanfactory
            override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
                super.configureSpansFactory(builder)

                builder.appendFactory(Heading::class.java) { _, props ->
                    // h1タグのテキストカラーを変更
                    // 参考: https://github.com/noties/Markwon/issues/318
                    val level = CoreProps.HEADING_LEVEL.require(props)

                    return@appendFactory if (level == 1) {
                        ForegroundColorSpan(
                            ContextCompat.getColor(context, R.color.brand_blue)
                        )
                    } else {
                        ForegroundColorSpan(
                            ContextCompat.getColor(context, R.color.text_main_color)
                        )
                    }
                }
            }
        })
        .build()
}
```

これで、`Markdown`を表示するカスタムComposable`MarkdownText`の作成ができました。

### 表示

実際にPreviewで表示してみると、画像のようになると思います。
無事、表示することができました！🎉

```kotlin
@Composable
fun MarkdownArticle(
    modifier: Modifier = Modifier,
    title: String,
    body: String
) {

    Column(modifier = modifier) {
        Text(
            text = title,
            modifier = Modifier.fillMaxWidth(),
            fontSize = 16.sp,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(20.dp))

        MarkdownText(
            markdown = body,
            modifier = Modifier.fillMaxWidth(),
            linkColor = colorResource(id = R.color.blue),
            fontSize = 14.sp,
            lineHeight = 28.sp
        )
    }
}

@Preview
@Composable
private fun PreviewMarkdownArticle() {
    val title = "Markdownを表示しよう！"
    
    val markdown = """
        # Header1
        ## Header2
        ### Header3
        #### Header4
        ##### Header5
        ###### Header6
                
        _Emphasis_
                
        **Strong Emphasis**
                
        ---

        `code`

        ```
        code block
        ```

        - list-1
            - list-1-1
                - list-1-1-1
        - list-2
        - list-3
                
        1. num-list-1
        1. num-list-2
        1. num-list-3
                
        > inline
                
        URLは、[こちら](https://example.net)です。
    """.trimIndent()

    MaterialTheme {
        Surface {
            MarkdownArticle(
                modifier = Modifier.padding(20.dp),
                title = title,
                body = markdown
            )
        }
    }
}
```

![スクリーンショット 2023-12-05 10.52.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179470/6a53258a-8f1e-1965-5fb8-b6a6c933b8ea.png)


# 終わりに

今回は、`Markwon`を使用してMarkdownを表示することを実現しました。

`Markwon`のようなComposeに非対応のライブラリーをComposeで表現するために、`AndroidView`を使用するということが今回の学びになりました。
とても便利でそこまで複雑なものではないComposableだったので、初めて触ってみても思っていたより苦戦せず実装することができました。

しかし、便利だからといって、無闇に使用するのも今後のメンテナンスコストを考えると、慎重にならないと行けないなと感じました。
また、サードライブラリーに対する理解をしないと、Composeへの移行はできないのも注意点だと思います。

`Markwon`の公式ドキュメントを見ると、もっと多くのPluginや設定項目があり、ここでは紹介できなかったテーブルや画像をより柔軟にカスタマイズできるので、興味があるかたはもっと触ってみてもいいかもしれないです！