Jetpack Composeでアイコンの直前でテキストを省略したい

こんにちは、Androidエンジニアの @syarihu です。

今回は、Jetpack Composeでアイコンの直前でテキストを省略したい場合の実装について紹介します。

具体的には次のようなUIを実現します。

テキストが2行を超えたら省略するだけならTextの標準機能だけで簡単にできますが、右下に展開アイコンを表示する際に展開アイコンに重ならないようにアイコンの直前で文字列を省略するのが今回のポイントです。

Textの標準機能を使って試してみる

まずはTextの標準機能のみを使うとどうなるのか試してみます。

Box(
  modifier = Modifier
    .fillMaxWidth()
    .background(Color.White)
    .padding(top = 8.dp, start = 8.dp, end = 8.dp, bottom = 4.dp),
) {
  Text(
        text = "これはとても長いテキストです。これはとても長いテキストです。これはとても長いテキストです。" +
        "これはとても長いテキストです。これはとても長いテキストです。",
    style = textStyle,
    maxLines = 2,
    overflow = TextOverflow.Ellipsis,
    modifier = Modifier.padding(bottom = 4.dp),
  )
  Icon(
    imageVector = Icons.Outlined.KeyboardArrowDown,
    contentDescription = null,
    tint = Color.Cyan,
    modifier = Modifier
      .size(20.dp)
      .align(Alignment.BottomEnd),
  )
}

2行を超える場合は省略記号を表示したいので、maxLinesを2に設定し、overflowに TextOverflow.Ellipsis を設定します。

アイコンをテキストの右下に表示したいので、Boxを用意して、その右下にIconを配置します。

これを実行すると次のように表示されます。

テキストは2行目の右端で省略されるのと、アイコンはBoxで重ねて右下に配置しているだけなので、省略記号とアイコンが重なってしまうことが分かります。

これをアイコンの直前で省略されるように省略される位置を調整したいところですが、省略位置の調整を標準機能で対応することはできないので、これを自分で調整する方法を考えます。

自前でテキストを省略する

自前でテキストを省略するには、省略したい行のテキストを取得してその行のテキストを削らなければならないため、行ごとにテキストを取得する必要があります。また、行ごとにテキストを取得するには、どこで折り返されるのか把握しておく必要があるため、テキストの最大幅が必要です。

まずは、これらのテキスト情報を取得します。

テキストが描画できる最大幅を取得する

テキストが描画できる最大幅を取得するには、BoxWithConstraintsで簡単にできます。

BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
  val maxWidth = with(LocalDensity.current) {
    maxWidth.toPx().toInt()
  }
  Text(...)
}

テキストを表示するText ComposableをBoxWithConstraintsで囲み、その中でmaxWidthを呼び出すことで取得できます。maxWidthはDpで返却されますが、テキストの測定時に必要なのはIntなので、DpからIntに変換しています。

テキストを測定する

次に、描画するテキストを事前に測定します。事前に測定することで、省略したい対象の行を取得して、その行を自前で省略できるようにします。

val textStyle = TextStyle(
  fontSize = 12.sp,
  lineHeight = 16.sp,
  fontWeight = FontWeight.W600,
)
val textMeasurer = rememberTextMeasurer()

val textLayoutResult by remember(text) {
  textMeasurer.measure(
    text = text,
    style = textStyle,
    constraints = Constraints(maxWidth = maxWidth),
  ).let {
    mutableStateOf(it)
  }
}

テキストを測定するには実際に描画するのと同じ情報が必要なので、フォントサイズなどをまとめたTextStyleを予め用意しておきます。

テキストの測定はTextMeasurerで行います。TextMeasurer#measure関数を使って実際に描画する予定のテキストやtextStyleを渡し、constraintsに画面の最大幅を渡します。

これで実際に描画する画面の最大幅でテキストが改行され、その測定情報がTextLayoutResultに格納されるのでここから必要な情報が取得できます。

行ごとにテキストを取得する

最大行のみテキストを省略するため、行ごとにテキストを取得する必要があります。

行ごとのテキストの取得はTextLayoutResultにある情報を使って取得できます。

行ごとのテキストの取得方法を次に示します。行の取得は何回か行うので、拡張関数にまとめて、対象の行のindexを渡せばその行のテキストが取得できるようにします。

private fun TextLayoutResult.getLineText(lineIndex: Int): String {
  if (lineIndex < 0 || lineCount <= lineIndex) {
    return ""
  }
  val lineTextStartIndex = if (lineIndex == 0) {
    0
  } else {
    getLineStart(lineIndex)
  }
  val lineTextEndIndex = getLineEnd(lineIndex)

  val originalText = layoutInput.text.toString()
  return originalText.substring(
    lineTextStartIndex,
    lineTextEndIndex
  )
}

TextLayoutResultでは行のテキストを直接取得することはできませんが、行の開始位置と終了位置を取得することはできます。

行の開始位置はgetLineStart、終了位置はgetLineEndで取得できます。また、TextMeasurerに渡した元のテキストはlayoutInputから取得できます。

これらを使って元のテキストを取得し、行の開始位置から終了位置までをsubstringで切り取ります。

これで、引数に行のindexを渡すことでその行のテキストを取得できるようになりました。

アイコンの左端までを最大幅としてテキストを測定し、テキストを省略する

テキストの行が取得できるようになったので、最大表示行までのテキストを行ごとに取得し、最後の行だけアイコンの左端までを最大幅としてテキストを再度測定し、アイコンの直前でテキストを省略します。

// アイコンを表示する行のテキストの最大幅: アイコンの大きさ(24dp)分だけ狭くする
val maxWidthWithIcon = with(LocalDensity.current) {
  maxWidth - 24.dp.toPx().toInt()
}

val textWithEllipsis by remember(text) {
  val lastIndex = (textLayoutResult.lineCount - 1).coerceIn(0, MAX_LINES - 1)
  (0..lastIndex).map { lineIndex ->
    if (lineIndex < lastIndex) {
      textLayoutResult.getLineText(lineIndex)
    } else {
      textLayoutResult.getLineText(lineIndex).let { lineText ->
        textMeasurer.measure(
          text = lineText,
          style = textStyle,
          constraints = Constraints(maxWidth = maxWidthWithIcon),
        ).getLineText(0)
      }
    }
  }.let {
    mutableStateOf(it.joinToString("").plus("…"))
  }
}

最後の行以外はそのままテキストを返し、最後の行だけはテキストを省略したいので、アイコンの幅を除いた幅をmaxWidthとしてテキストの測定をし直します。再度測定したテキストはアイコン分が省略された1行目だけが欲しいので、getLineTextでlineIndexが0のテキストを取得して返しています。

最後に、すべての行を結合して文字列の最後に省略記号「…」を付け足すことで自前でアイコンの手前まで省略したテキストが完成します。

省略したテキストを表示する

省略したテキストが完成したので、あとはこれを最大行数が超えた場合にのみ表示します。

Text(
  text = if (textLayoutResult.lineCount > MAX_LINES) {
    textWithEllipsis
  } else {
    text
  },
  modifier = Modifier.padding(bottom = 4.dp),
  style = textStyle,
  color = Colors.TextPrimary,
)

これで、テキストが最大行数を超える場合は最大行数目のテキストではアイコンの直前でテキストが省略され、テキストが最大行数を超えない場合はそのままテキストが表示されるようになりました。

実際に動かしてみる

これまでのコードをいい感じにまとめて動かしてみると、次のようになります。

右下のアイコンの直前でテキストが省略されて、アイコンとテキストが重ならずに表示されていることが分かります。

本当はこれに文字列をタップしたら省略しない文字列に展開するなどの処理を入れますが、ここでは割愛します。

おわりに

少し手間はかかりましたが、アイコンの手前でテキストを省略させることができました。今回実装したサンプルコードは次のgistにあげているので、もしよければ参考までにどうぞ。

https://gist.github.com/syarihu/d0ae4d103b7c492b2e1bb5949f9ba779

今回利用したTextMeasurerは色々な場面で活用できると思うので、 テキストを事前に測定して何かをしたいケースなどがあればぜひ活用してみてください。


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com