4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ComposeでMarkdownを表示するには

Last updated at Posted at 2023-12-05

初めに

ポート株式会社 サービス開発部 Advent Calendar 2023 5日目の記事です。

スクリーンショット 2023-12-05 11.06.36.png

こんにちは。ポート株式会社でAndroid開発をしているshxun6934です。

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

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

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

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

前提

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

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

ライブラリー

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

実現

先に結論を述べてしまうと、TextViewAndroidView Composable経由で表示する形で実現しました。

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

// 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を使用することができないため、あえてTextViewAndroidView ComposableでComposeとして表現するようにしてあげます。

AndroidView Composable

TextViewWebViewConstraintLayoutなどのAndroidViewをComposeのUI階層に埋め込むために使用します。

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

例:

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

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

MarkdownText

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

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

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のデータを渡すイメージです。
その他の引数は、必要に応じて削除・更新・追加して、よりプロジェクトにあったコンポーネント設計にしてもいいかなと思います!

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

@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の構築

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

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

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の設定・適用

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

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

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

@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のフォントサイズとフォントカラーを変更しています。)

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を表示するカスタムComposableMarkdownTextの作成ができました。

表示

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

@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

終わりに

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

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

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

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

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?