Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Jetpack Composeで「もっと見る」をタップしたら伸縮するExpandableTextを自作しました

こんにちは、技術本部 Mobile Applicationグループに所属しているAndroidエンジニアの鎌田です。

TLDR

こんな感じのUIをJetpack Composeで自作しました!

初めに

スマホアプリを使用していると時折、先述のようなUIを見かけると思います。*1

要件を言葉で説明すると以下のようになるでしょうか。

  • 長いテキストを表示したいが、初めは先頭の数行と「...もっと見る」のようなテキストだけ表示しておく
  • タップするとテキストが縦に伸びて全文が表示される。最後の行の行末に「閉じる」を表示する

このようなUIは既に海外の優秀なエンジニアさんが自作してブログ等に公開されているため、それを参考にすれば良いのですが、

日本語環境の場合、全角文字がアルファベットより文字幅が大きいことが理由で、レイアウトが崩れたり画面端で文字が見切れるなど、そのまま参考にできないことが多くありました。

上記の経緯で既存の実装を調べることを諦め、観念して自作しました😢

同じ悩みを抱えるエンジニアさんの助けになるため、「日本語環境だとこういう感じに実装するといいよ!」というナレッジをこの記事で共有しようと思います。

なお、この記事で使用しているコードの全体像は以下のレポジトリにまとめています。それでは実際に実装を見ていきましょう。

github.com

実装

タップの状態やテキストの長さの分岐

    // テキストが伸びているか縮んでいるか
    var isExpanded by remember { mutableStateOf(false) }

    BoxWithConstraints(
        modifier = modifier
    ) {

        val clickable: Boolean
        val displayText: AnnotatedString
        if ( /*テキストがmaxLineに収まるかどうか*/ ) {
            // テキストがmaxLineに収まる場合
            clickable = false
            displayText = // 加工せずテキストをそのまま表示する
        } else {
            clickable = true
            displayText = if (isExpanded) {
                // テキストがmaxLineを超えて、かつテキストの全体が表示されている
                // 本文の全体と最終行に「閉じる」を付け足したものを表示する
            } else {
                // テキストがmaxLineを超えて、かつテキストの一部が表示されている
                // 本文の先頭数行と「...もっと見る」を付け足したものを表示する
            }
        }

        SelectionContainer(
            modifier = Modifier
                .clickable(enabled = clickable) {
                    isExpanded = !isExpanded
                }
                .animateContentSize()
        ) {
            Text(
                text = displayText,
                maxLines = if (isExpanded) Int.MAX_VALUE else maxLine,
                lineHeight = lineHeight
            )
        }
    }
}

clickabledisplayText を、テキストの伸縮の状態やテキストの長さに合わせて切り替えます。

「テキストがmaxLineに収まるかどうか」の判定

    BoxWithConstraints(
        modifier = modifier
    ) {
        // 先に文字を全て描画した場合の計算を行い結果に応じて表示の整形を行う。
        val noEllipsedBodyParagraph = MultiParagraph(
            intrinsics = MultiParagraphIntrinsics(
                annotatedString = buildAnnotatedString { withStyle(bodyStyle) { append(text) } },
                style = Typography.body1,
                density = LocalDensity.current,
                resourceLoader = LocalFontLoader.current,
                placeholders = emptyList()
            ),
            width = with(LocalDensity.current) { maxWidth.toPx() }
        )

        val clickable: Boolean
        val displayText: AnnotatedString
        /*テキストがmaxLineに収まるかどうか*/
        if (bodyParagraph.lineCount <= maxLine) {
            // テキストがmaxLineに収まる場合
            clickable = false
            displayText = // 加工せずテキストをそのまま表示する
        } else {
            clickable = true
            displayText = if (isExpanded) {
                // テキストがmaxLineを超えて、かつテキストの全体が表示されている
                // ......

テキストがmaxLineに収まるかどうか、の判定にはMultiParagraphを使用します。

MultiParagraph  |  Android Developers

表示したい文字列やstyle、widthなどを設定するとその設定に基づいてレイアウトを行なった場合の行数やx行目の座標、などを取得できます。

bodyParagraph.lineCount <= maxLine とする事で、本文の行数がmaxLineより大きい( = 「もっと見る」で切り取る必要がある)かどうかを判定できます。

実際に表示するべきテキストを作成する

タップにより以下のように伸縮しながらテキストを切り替えたいため、「(本文の一部)...もっと見る」「(本文)閉じる」の2パターンの文字列を用意する必要があります。

あのイーハトーヴォのすきとおった風、
夏でも底に冷たさをも...もっと見る

↓タップ↑

あのイーハトーヴォのすきとおった風、
夏でも底に冷たさをもつ青いそら、
うつくしい森で飾られたモリーオ市、
郊外のぎらぎらひかる草の波。閉じる

特に、「(本文の一部)...もっと見る」は、計算が少し煩雑になります。

ロジックの流れはコメントでも説明していますが、以下の順番で実装します。

  • 2行表示したい場合、2行分のテキストを取得する
  • 「2行分のテキスト」から、「...もっと見る」の幅の分だけ2行目末尾から削除する
  • 「2行分のテキストから数文字取り除いたもの」に「...もっと見る」を追加する

その際には以下を考慮して実装する必要があります。

  • 最後の行が極端に短い場合(改行が入っている、など)
  • Android端末の設定でフォントサイズや表示サイズが変わった場合
  • (本文の一部)の最後が英単語や記号、空白文字のため文字幅が異なる

またここでは「もっと見る」の幅や、本文をellipseしてレイアウトした場合のParagraphも必要になるため、Paragraphを増やしています。

// 本文をwidthに合わせてレイアウトした場合のparagraph
noEllipsedBodyParagraph = MultipleParagraph( /**/ )
// 本文をレイアウトするが、2行でellipseした場合のparagraph
ellipsedBodyParagraph = MultipleParagraph( /**/ )

if (bodyParagraph.lineCount <= maxLine) {
    // テキストがmaxLineに収まる場合はそのまま表示を行う。
    // 略
} else {
    // テキストがmaxLineに収まらない場合
    displayText = if (isExpanded) {
        // (本文)...閉じるを描画
        // 略
    } else {
        // (本文の一部)...もっと見るを描画

        // 「...もっと見る」の幅をParagraphから取得する
        seeMoreTextWidth = MultipleParagraph( /**/ ).getBoundingBox("$ellipsisText$showMoreText ".length - 1).right
        // 最終行の下端のY座標
        val maxLineBottomYCoordinate = bodyParagraph.getLineBottom(maxLine - 1)
        // 「...もっと見る」の左端になるべき座標
        val seeMoreTextStartCoordinateOffset = Offset(x = bodyParagraph.width - seeMoreTextWidth, y = maxLineBottomYCoordinate)
        // seeMoreTextStartCoordinateOffsetを踏まえて実際に表示できる本文(ellipseやseeMoreText以外)の文字数
        val displayBodyLength = ellipsedBodyParagraph.getOffsetForPosition(seeMoreTextStartCoordinateOffset)
        // maxLineまでの本文
        val textInMaxLine = text.substring(startIndex = 0, endIndex = bodyParagraph.getLineEnd(maxLine - 1, true))

        // 実際に表示するべき文字は以下の手順で作られる
        // 1. maxLineまでの本文の末尾を (dropCount)文字 だけ取り除く
        // (改行が入っている等の理由で最終行が短くなっている場合はdropCountが負になる。そのままdropしようとするとIllegalArgumentExceptionになる。)
        // 2. 取り覗かれた部分にellipsisを付け足す
        // 3. showMoreText("...もっと見る")を付け足す
        val dropCount = textInMaxLine.length - displayBodyLength

        val body = if (dropCount > 0) {
            textInMaxLine.dropLast(textInMaxLine.length - displayBodyLength) // 1. 
        } else {
            textInMaxLine // 改行が入っている等の理由で最終行が短くなっている場合
        }
        buildAnnotatedString {
            withStyle(bodyStyle) { append(body) }
            withStyle(transparent) { append(ellipsisText) } // 2.
            withStyle(transparent) { append(showMoreText) } // 3.
        }
    }
}

完成

以上を踏まえて完成したものがこちらです。

これを装飾して、LazyColumnで表示するとこのようなアプリを作る事ができます。

▶︎ Composableの全体像(クリックで開きます)

@Composable
fun ExpandableLineLimitText(
    modifier: Modifier = Modifier,
    text: String,
    ellipsisText: String = "...",
    showMoreText: String = "もっと見る",
    showLessText: String = "閉じる",
    maxLine: Int = 2,
    lineHeight: TextUnit = 1.55.em,
    onClickMore: () -> Unit = {}
) {
    var isExpanded by remember { mutableStateOf(false) }

    val bodyStyle = SpanStyle(
        color = Color.Gray,
        fontSize = Typography.body1.fontSize
    )
    val suffixStyle = SpanStyle(
        color = Color.Black,
        fontSize = Typography.body1.fontSize,
        textDecoration = TextDecoration.Underline
    )

    BoxWithConstraints(
        modifier = modifier
    ) {

        // 先に文字を全て描画した場合の計算を行い結果に応じて表示の整形を行う。
        val noEllipsedBodyParagraph = MultiParagraph(
            intrinsics = MultiParagraphIntrinsics(
                annotatedString = buildAnnotatedString { withStyle(bodyStyle) { append(text) } },
                style = Typography.body1,
                density = LocalDensity.current,
                resourceLoader = LocalFontLoader.current,
                placeholders = emptyList()
            ),
            width = with(LocalDensity.current) { maxWidth.toPx() }
        )

        val ellipsedBodyParagraph = MultiParagraph(
            intrinsics = noEllipsedBodyParagraph.intrinsics,
            ellipsis = true,
            maxLines = maxLine,
            width = with(LocalDensity.current) { maxWidth.toPx() }
        )

        val clickable: Boolean
        val displayText: AnnotatedString
        if (noEllipsedBodyParagraph.lineCount <= maxLine) {
            // テキストがmaxLineに収まる場合はそのまま表示を行う。
            clickable = false
            displayText = buildAnnotatedString { withStyle(bodyStyle) { append(text) } }
        } else {
            // テキストがmaxLineを超える場合は「もっと見る」「閉じる」の状態に応じてテキストを整形する。
            clickable = true
            displayText = if (isExpanded) {
                buildAnnotatedString {
                    withStyle(bodyStyle) { append(text) }
                    withStyle(suffixStyle) { append(showLessText) }
                }
            } else {
                // ellipsisとshowMoreTextの文字幅の計算。この幅が収まるだけ本文の末尾を取り除く処理のために必要。
                val seeMoreTextWidth = MultiParagraph(
                    intrinsics = MultiParagraphIntrinsics(
                        annotatedString = buildAnnotatedString {
                            withStyle(bodyStyle) { append(ellipsisText) }
                            // 本文の末尾を取り除く計算の際に1文字ずれて「もっと見る」が1文字だけ見切れる可能性があるため、ここで空文字を1つ付けておく事で事前に回避する。
                            // https://github.com/JetBrains/compose-jb/issues/2570
                            withStyle(suffixStyle) { append("$showMoreText ") }
                        },
                        style = Typography.body1,
                        density = LocalDensity.current,
                        resourceLoader = LocalFontLoader.current,
                        placeholders = emptyList()
                    ),
                    width = with(LocalDensity.current) { maxWidth.toPx() }
                ).getBoundingBox("$ellipsisText$showMoreText ".length - 1).right

                // 最終行の下端のY座標
                val maxLineBottomYCoordinate = noEllipsedBodyParagraph.getLineBottom(maxLine - 1)
                // ellipseの左端の座標
                val seeMoreTextStartCoordinateOffset = Offset(x = noEllipsedBodyParagraph.width - seeMoreTextWidth, y = maxLineBottomYCoordinate)
                // 実際に表示できる本文(ellipseやseeMoreText以外)の文字数
                val displayBodyLength = ellipsedBodyParagraph.getOffsetForPosition(seeMoreTextStartCoordinateOffset)
                // maxLineまでの本文
                val textInMaxLine = text.substring(startIndex = 0, endIndex = noEllipsedBodyParagraph.getLineEnd(maxLine - 1, true))

                // 実際に表示する文字は以下の手順で作られる
                // 1. maxLineまでの本文の末尾を数文字取り除く
                // (改行が入っている等の理由で最終行が短くなっている場合はdropCountが負になる。そのままdropしようとするとIllegalArgumentExceptionになる。)
                // 2. 取り覗かれた部分にellipsisを付け足す
                // 3. showMoreText("...もっと見る")を付け足す
                val dropCount = textInMaxLine.length - displayBodyLength
                val body = if (dropCount > 0) {
                    textInMaxLine.dropLast(textInMaxLine.length - displayBodyLength)
                } else {
                    textInMaxLine
                }
                buildAnnotatedString {
                    withStyle(bodyStyle) { append(body) }
                    withStyle(bodyStyle) { append(ellipsisText) }
                    withStyle(suffixStyle) { append(showMoreText) }
                }
            }
        }

        SelectionContainer(
            modifier = Modifier
                .clickable(enabled = clickable) {
                    if (!isExpanded) {
                        onClickMore.invoke()
                    }
                    isExpanded = !isExpanded
                }
                .animateContentSize()
        ) {
            Text(
                text = displayText,
                maxLines = if (isExpanded) Int.MAX_VALUE else maxLine,
                lineHeight = lineHeight
            )
        }
    }
}

終わりに

以上、自作のExpandableTextの紹介でした。

ここまで読んだくださりありがとうございました。

*1:思い当たる範囲だと通知画面などで似たようなUIを見かけますねhttps://developer.android.com/develop/ui/views/notifications/expanded#large-style

© Sansan, Inc.