【SwiftUI/JetpackCompose】文字サイズの自動調整　~比較で覚える宣言的モバイルUI~

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装

そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。

今回は、**文字サイズの自動調整編**です。

:::note warn
なお、見た目をSwiftUI寄りにしがちです
:::

# 対応関係

【SwiftUI】```minimumScaleFactor(_:)```修飾子をつける
【JetpackCompose】(現状公式のものはないので)自作の```Text```コンポーザブルを作成する

# SwiftUI
SwiftUIでは、```Text```ビューに```.minimumScaleFactor(最小サイズの倍率)```という修飾子をつけることで実現できます。

## minimumScaleFactor(_:)
【[公式](https://developer.apple.com/documentation/swiftui/text/minimumscalefactor(_:))の定義】
```swift
func minimumScaleFactor(_ factor: CGFloat) -> some View
```
【使い方】
引数には、縮小する倍率の最小値を入れます。0.5を入れた場合、0.5倍のサイズまでは縮小され、それ以下になると省略されます。
```swift
Text("Hello, World!")
    .minimumScaleFactor(0.5) // 0.5倍のサイズまで縮小され、
                             // それより小さい場合は文末が「...」になる
```

## 具体例
```.lineLimit(1)```にすることで、行数を1行に限定しています。
```swift
struct MinimumScaleFactorExample: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("Hello, World!")
                .font(.largeTitle)
                .lineLimit(1)
                .minimumScaleFactor(0.5)
            
            Text("Hello, World!")
                .font(.largeTitle)
                .lineLimit(1)
                .minimumScaleFactor(0.5)
                .frame(width: 160)
            
            Text("Hello, World!")
                .font(.largeTitle)
                .lineLimit(1)
                .minimumScaleFactor(0.5)
                .frame(width: 80)
        }
    }
}
```

## 実行結果
きちんと文字が縮小されています。指定した最小倍率以下になると、文末が「...」になっています。
![スクリーンショット 2022-10-29 17.27.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/8eede142-9083-acc7-ee44-5cd009b56bf9.png)

# Jetpack Compose
Jetpack Composeはまだ登場してから日が浅いこともあり、まだ公式のものはなく、自作する必要があります。

## 自作する
[こちら](https://stackoverflow.com/questions/63971569/androidautosizetexttype-in-jetpack-compose)のStackOverflowの記事を参考に、若干改造させていただきました。

名前は任意ですが、表示時にサイズを調節する、```Text```をラップした以下のようなコンポーザブルを作ります。
ポイントは```onTextLayout```という引数で、これにより文字のレイアウトが計算されたときに、コールバックを実行してくれます。
もう一つのポイントはModifierの```drawWithContent```修飾子で、この中で```drawContent()```をBooleanで分岐させることで、計算が終わって初めて表示されるようになります。

<details><summary>AutoResizeTextコンポーザブル</summary>

```kotlin:AutoResizeText.kt
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp

@Composable
fun AutoResizeText(
    modifier: Modifier = Modifier,
    text: String,
    fontSizeRange: FontSizeRange,
    color: Color = Color.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    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.Ellipsis, // あふれた場合は省略する
    softWrap: Boolean = true,
    style: TextStyle = LocalTextStyle.current,
) {
    var fontSizeValue by remember { mutableStateOf(fontSizeRange.max.value) }
    var readyToDraw by remember { mutableStateOf(false) }

    Text(
        text = text,
        color = color,
        maxLines = maxLines,
        fontStyle = fontStyle,
        fontWeight = fontWeight,
        fontFamily = fontFamily,
        letterSpacing = letterSpacing,
        textDecoration = textDecoration,
        textAlign = textAlign,
        lineHeight = lineHeight,
        overflow = overflow,
        softWrap = softWrap,
        style = style,
        fontSize = fontSizeValue.sp,
        onTextLayout = {
            if (it.didOverflowHeight && !readyToDraw) {
                val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value
                if (nextFontSizeValue <= fontSizeRange.min.value) {
                    // 最小サイズに到達した場合、縮小を終了する。
                    fontSizeValue = fontSizeRange.min.value
                    readyToDraw = true
                } else {
                    // まだ領域より大きく、最小サイズに達していない場合は縮小を続ける
                    fontSizeValue = nextFontSizeValue
                }
            } else {
                // 最適なサイズに達した時にことを知らせる
                readyToDraw = true
            }
        },
        modifier = modifier.drawWithContent { if (readyToDraw) drawContent() }
    )
}

data class FontSizeRange(
    val min: TextUnit,
    val max: TextUnit,
    val step: TextUnit = DEFAULT_TEXT_STEP,
) {
    companion object {
        private val DEFAULT_TEXT_STEP = 1.sp
    }
}
```

</details>

【使い方】
文字サイズの最小値と最大値をそれぞれ指定します。
```kotlin
AutoResizeText(
    text = "Hello World!",
    fontSizeRange = FontSizeRange(min = 20.sp, max = 40.sp)
)
```

## 具体例
```maxLines = 1```にすることで、行数を1行に限定しています。
```kotlin
@Composable
fun AutoResizeTextExample (
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(20.dp)
        ) {
            AutoResizeText(
                text = "Hello World!",
                maxLines = 1,
                fontSizeRange = FontSizeRange(min = 20.sp, max = 40.sp)
            )

            AutoResizeText(
                modifier = Modifier.width(160.dp),
                text = "Hello World!",
                maxLines = 1,
                fontSizeRange = FontSizeRange(min = 20.sp, max = 40.sp)
            )

            AutoResizeText(
                modifier = Modifier.width(80.dp),
                text = "Hello World!",
                maxLines = 1,
                fontSizeRange = FontSizeRange(min = 20.sp, max = 40.sp)
            )
        }
    }
}
```

## 実行結果
こちらでも、きちんと文字が縮小されています。指定した最小サイズ以下になると、文末が「...」になります。(```Text```の引数に```overflow = TextOverflow.Ellipsis```を指定しているため)
なお、プレビューはInteractive Modeでないとうまく表示されません。
![スクリーンショット 2022-10-29 19.25.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/4410bf22-982b-3178-c2ce-3d2ce2a7a40b.png)


# まとめ
**文字サイズの自動調整**

対応関係を再掲します。
【SwiftUI】```minimumScaleFactor(_:)```修飾子をつける
【JetpackCompose】(現状公式のものはないので)自作の```Text```コンポーザブルを作成する

Jetpack Composeでは、現時点では自作する必要があるとはいえ、```Text```コンポーザブルの```onTextLayout```という引数のおかげで、ちゃんと実装できるようになっているのがありがたいです。


# 参考
【SwiftUI】
[【SwiftUI】Textの使い方 by カピ通信](https://capibara1969.com/1981/)
[minimumscalefactor by Apple公式](https://developer.apple.com/documentation/swiftui/text/minimumscalefactor(_:))

【Jetpack Compose】
[android:autoSizeTextType in Jetpack Compose by StackOverflow](https://stackoverflow.com/questions/63971569/androidautosizetexttype-in-jetpack-compose)
[Compose 修飾子のリスト by AndroidDeveloper](https://developer.android.com/jetpack/compose/modifiers-list?hl=ja#:~:text=Modifier.drawWithContent)
[Jetpack Compose のフェーズ by AndroidDeveloper](https://developer.android.com/jetpack/compose/phases?hl=ja)
[JETPACK COMPOSE: TEXTコンポーザブルを使う by TECHBOOSTER](https://techbooster.org/android/ui/18458/)