初めに
ポート株式会社 サービス開発部 Advent Calendar 2023 5日目の記事です。
こんにちは。ポート株式会社でAndroid開発をしている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
も記載しておきます。
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の宣言方法がわかります。)
実現
先に結論を述べてしまうと、TextView
をAndroidView 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
を使用することができないため、あえてTextView
をAndroidView
ComposableでComposeとして表現するようにしてあげます。
AndroidView Composable
TextView
やWebView
、ConstraintLayout
などの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
TextView
をAndroidView
Composableで表現しつつ、マークダウンを表示するために必要な設定をしたMarkdownText Composableを作成しました。
実装時は、Text
Composableを呼び出す時と同じような感覚で呼び出せるように、コンポーネント設計をText
Composableを参考にしました。
@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の構築
まず、TextView
をAndroidView
Composableで表現します。
(AndroidView Composableのセクションで簡単な例を記載しているので、参考にしていただけると🙏)
外部から指定があった要素をTextView
に適用しつつ、TextView
を生成し、AndroidView
Composableのfactoryに渡してあげます。
@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
に渡してあげます。
@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
)
}
}
}
終わりに
今回は、Markwon
を使用してMarkdownを表示することを実現しました。
Markwon
のようなComposeに非対応のライブラリーをComposeで表現するために、AndroidView
を使用するということが今回の学びになりました。
とても便利でそこまで複雑なものではないComposableだったので、初めて触ってみても思っていたより苦戦せず実装することができました。
しかし、便利だからといって、無闇に使用するのも今後のメンテナンスコストを考えると、慎重にならないと行けないなと感じました。
また、サードライブラリーに対する理解をしないと、Composeへの移行はできないのも注意点だと思います。
Markwon
の公式ドキュメントを見ると、もっと多くのPluginや設定項目があり、ここでは紹介できなかったテーブルや画像をより柔軟にカスタマイズできるので、興味があるかたはもっと触ってみてもいいかもしれないです!