Jetpack Glanceでテキストサイズをdp単位で指定する方法

Jetpack Glanceではテキストのサイズはsp単位でしか指定できません。
指定可能な単位はTextUnitでこれにはspとemがありますが、今のところemはサポートされていません。emについては将来的にサポートされる可能性があります（ソースコードにTODOでコメントされていますし）が、dpがサポートされることはなさそうです。アクセシビリティのためsp単位を使うべきというのはその通りですが、状況によってはdpで指定したい場合も多く存在します。
ここではその指定方法を紹介します

# fontScaleを元にspサイズを計算する

Jetpack Composeでも同様にsp単位でしか指定できませんが、`toSp()`メソッドを使うことでdp単位での指定が可能です。

```kotlin
fontSize = with(LocalDensity.current) { 16.dp.toSp() }
```

この`toSp()`はFontScalingに定義されている拡張関数で、実装は以下のようになっています。
単純に言えばfontScaleによる拡大率の逆数を掛けたspサイズを指定するという方法です。

```FontScaling.android.kt
@Stable
actual fun Dp.toSp(): TextUnit {
    if (!FontScaleConverterFactory.isNonLinearFontScalingActive(fontScale) ||
        DisableNonLinearFontScalingInCompose) {
        return (value / fontScale).sp
    }

    val converter = FontScaleConverterFactory.forScale(fontScale)
    return (converter?.convertDpToSp(value) ?: (value / fontScale)).sp
}
```

同等のことをJetpack Glanceでもやれば良いのですが、Jetpack GlanceにはLocalDensityは提供されていません。とはいえ、DensityのインスタンスはdensityとfontScaleを渡すことで作れます。densityとfontScaleはcontextから取得できます。
以下のような拡張関数を作成し、

```kotlin
@Composable
fun Dp.toSp(): TextUnit {
    val resources = LocalContext.current.resources
    val density = resources.displayMetrics.density
    val fontStyle = resources.configuration.fontScale
    return with(Density(density, fontStyle)) { toSp() }
}
```

GlanceのTextに対して以下のように指定することで、dp単位での指定が可能……

```kotlin
fontSize = 16.dp.toSp(),
```

なように見えますが、罠があります。

ComposeではonConfigurationChangedで再描画、再計算が行われるため、システム設定でフォントサイズを変更した場合もそれに伴ってサイズが変化すること無く、dpサイズ指定と同様の状態を維持することが可能です。

しかし、GlanceではConfiguratiojnChangedで再描画は行われません。ACTION_CONFIGURATION_CHANGEDのブロードキャストIntentを使う方法もありますが、このIntentはAndroidManifestに記述したBroadcastReceiverでは受け取ることができず、contextにregisterRecieverで登録しなければなりません。すなわち、アプリのプロセスが起動している状態でなければ受け取ることができないわけです。
ウィジェットはアプリのプロセスが終了している状態でも表示され続けるため、即座に反映されず、次の更新などのタイミングを待つ必要があります。

フォントサイズ設定は頻繁に変更されるものでは無く、次の更新で復帰できるのでどうしても許容できないほどではないでしょうが、やはりdp単位で指定しているのとは異なる状態になってしまいます。

# AndroidRemoteViewsでカスタムTextを作成する

Jetpack ComposeのAndroidViewと同様に、Jetpack Glanceにも旧来のxmlベースのRemoteViewsを埋め込む方法が提供されています。xmlのRemoteViewsではdp単位のテキストサイズが指定できます。とはいえ、xmlでレイアウトを作るのであればGlanceを使う意味がほとんどなくなってしまいます。
そこで、一つのTextViewをもつRemoteViewsをAndroidRemoteViewsで配置し、Glanceからの指定を伝える仕組みを作り、dp単位のサイズ指定を可能にする、というアプローチを取ってみます。

Glanceは本質的にRemoteViewsの上に成り立っています。TextTranslator.kt にTextをRemoteViewsに反映する実装があります。全く同じにはできないもののこれをベースに同様の仕組みが作れそうですね。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:glance/glance-appwidget/src/main/java/androidx/glance/appwidget/translators/TextTranslator.kt

まずは、fontSizeの型をDpに変更したTextStyleを定義してみましょう

```FixedTextStyle.kt
@Immutable
class FixedTextStyle(
    val color: ColorProvider = TextDefaults.defaultTextColor,
    val fontSize: Dp? = null,
    val fontWeight: FontWeight? = null,
    val fontStyle: FontStyle? = null,
    val textAlign: TextAlign? = null,
    val textDecoration: TextDecoration? = null,
    val fontFamily: FontFamily? = null,
)
```

これをstyleとして指定できるFixedTextを以下のように定義します。

```FixedText.kt
@Composable
fun FixedText(
    text: String,
    modifier: GlanceModifier = GlanceModifier,
    style: FixedTextStyle = defaultFixedTextStyle,
    maxLines: Int = Int.MAX_VALUE,
) {
    Box(
        modifier = modifier,
        contentAlignment = style.textAlign.toAlignment(LocalContext.current.isRtl),
    ) {
        AndroidRemoteViews(
            remoteViews = makeRemoteViews(LocalContext.current, text, style, maxLines),
        )
    }
}

private val Context.isRtl: Boolean
    get() = (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL)

private fun TextAlign?.toAlignment(isRtl: Boolean): Alignment =
    when (this) {
        TextAlign.Center -> Alignment.TopCenter
        TextAlign.Left -> if (isRtl) Alignment.TopEnd else Alignment.TopStart
        TextAlign.Right -> if (isRtl) Alignment.TopStart else Alignment.TopEnd
        TextAlign.Start -> Alignment.TopStart
        TextAlign.End -> Alignment.TopEnd
        else -> Alignment.TopStart
    }
```

TextTranslatorではTextAlignはspanもしくはGravityで指定していますが、AndroidRemoteViewsでは反映できないようで、Boxで囲んでBoxのcontentAlignmentに変換して指定するようにしています。

その他属性は、TextTranslatorを参考に以下のように実装します。

```FixedText.kt
private fun makeRemoteViews(
    context: Context,
    text: String,
    style: FixedTextStyle,
    maxLines: Int,
): RemoteViews =
    RemoteViews(context.packageName, R.layout.glance_include_padding_text)
        .setText(context, R.id.text, text, style, maxLines)

internal fun RemoteViews.setText(
    context: Context,
    resId: Int,
    text: String,
    style: FixedTextStyle,
    maxLines: Int,
): RemoteViews = apply {
    if (maxLines != Int.MAX_VALUE) {
        setTextViewMaxLines(resId, maxLines)
    }
    style.fontSize?.let {
        setTextViewTextSize(resId, TypedValue.COMPLEX_UNIT_DIP, it.value)
    }
    setTextWithStyle(context, resId, text, style)
    setTextColorProvider(context, resId, style.color)
}

private fun RemoteViews.setTextWithStyle(
    context: Context,
    resId: Int,
    text: String,
    style: FixedTextStyle,
) {
    val content = SpannableString(text)
    val spans = mutableListOf<ParcelableSpan>()
    style.textDecoration?.let {
        if (TextDecoration.LineThrough in it) {
            spans.add(StrikethroughSpan())
        }
        if (TextDecoration.Underline in it) {
            spans.add(UnderlineSpan())
        }
    }
    style.fontStyle?.let {
        spans.add(StyleSpan(if (it == FontStyle.Italic) Typeface.ITALIC else Typeface.NORMAL))
    }
    style.fontWeight?.let {
        val textAppearance = when (it) {
            FontWeight.Bold -> R.style.Custom_AppWidget_TextAppearance_Bold
            FontWeight.Medium -> R.style.Custom_AppWidget_TextAppearance_Medium
            else -> R.style.Custom_AppWidget_TextAppearance_Normal
        }
        spans.add(TextAppearanceSpan(context, textAppearance))
    }
    style.fontFamily?.let { family ->
        spans.add(TypefaceSpan(family.family))
    }
    spans.forEach { span ->
        content.setSpan(span, 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
    }
    setTextViewText(resId, content)
}

@SuppressLint("RestrictedApi")
private fun RemoteViews.setTextColorProvider(
    context: Context,
    resId: Int,
    colorProvider: ColorProvider,
) {
    when (colorProvider) {
        is FixedColorProvider -> setTextColor(resId, colorProvider.color.toArgb())
        is ResourceColorProvider -> {
            if (Build.VERSION.SDK_INT >= 31) {
                setTextViewTextColorResource(resId, colorProvider.resId)
            } else {
                setTextColor(resId, colorProvider.getColor(context).toArgb())
            }
        }

        is DayNightColorProvider -> {
            if (Build.VERSION.SDK_INT >= 31) {
                setTextViewTextColor(
                    resId,
                    notNight = colorProvider.day.toArgb(),
                    night = colorProvider.night.toArgb()
                )
            } else {
                setTextColor(resId, colorProvider.getColor(context).toArgb())
            }
        }

        else -> Unit
    }
}
```

ポイントとなるフォントサイズの反映は以下のように実装しています。値を逆算するのでは無く、直接DIPとして反映しているため、フォントサイズ設定が変更されても影響を受けません。

```kotlin
style.fontSize?.let {
    setTextViewTextSize(resId, TypedValue.COMPLEX_UNIT_DIP, it.value)
}
```

「フォントサイズをdp単位で指定する」だけにしては大仰な実装になってしまいますが、xmlでできるがGlanceではその口がない、という機能をなんとか自作する入り口になるため、他にも応用が効くかと思います。

---

以上です。

ウィジェットでは通常のアプリ以上に情報を詰め込みたいニーズもあるため、公式に対応してほしいところですが、DpはTextUnitではないというCompose側の制約を受けるため、将来的に対応される可能性は低そうですね。