1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

動的に変更する表示言語をComposeに反映する

Posted at

Androidの多言語対応と問題

Androidアプリ開発で多言語対応を行う場合は、言語ごとに res/values*/strings.xmlファイルを用意するだけで簡単に実装が可能です

  • res/values/strings.xml: デフォルトのLocale
  • res/values-${言語コード}/strings.xml: 指定した言語のLocale

多くの場合は十分ですが、特別な要求があると問題が発生します。

  • アプリの表示が即座には反映されない
    設定アプリから言語を変更しても、起動中のアプリの表示にすぐには反映されません。アプリを再起動するかアプリ側で特別な対応が必要です。
  • Androidシステムの言語設定に依存する
    アプリ内の独自の表示言語を使いたい場合は対応できません。

完成物

システムの言語設定とは別にアプリ内部の表示言語を保持し、ユーザ操作により動的に変化する表示言語をUIに反映させます。UIはComposeで実装しています。

各言語の翻訳は ChatGPT に丸投げしましたので、悪しからず。

アプリのコードはGitHubリポジトリで公開しています。

実装と解説

主要ライブラリのバージョン

  • compose-bom:2022.10.00
  • hilt-android:2.44
  • kotlinx-serialization-json:1.5.1

表示言語と翻訳ファイルの用意

アプリ独自の表示言語をenumで用意してあげます。

enum class DisplayLanguage(
    val code: String
) {
    English("en"), 
    Japanese("ja"), 
    French("fr"), 
    Thai("th"),
}

次にアプリで使用するテキストidの一覧もenumで用意します

@Suppress("EnumEntryName")
enum class TextId(
   @StringRes val xmlId: Int
) {
    app_bar_title(R.string.app_bar_title),
    home_title(R.string.home_title),
    // ...以下略...
}

Previewの対応
Previewでは res/values/strings.xmlのテキストを代わりに表示させるため、対応する@StringResを指定しています。

同時に対応する翻訳ファイルをassets/text_${言語コード}.jsonに用意します。

{
  "app_bar_title": "SampleApp",
  "home_title": "Hello, Android!",
  ...以下略...
}

リポジトリの実装

表示言語ごとのテキストを自前で管理します。

sealed interface TextCatalog {
    object Initializing : TextCatalog
    data class Data(
        val language: DisplayLanguage,
        val data: Map<TextId, String>,
    ) : TextCatalog
}

UI側で表示言語の変更を監視するため、リポジトリからはFlowで公開します。

interface DisplayLanguageRepository {
    val catalog: StateFlow<TextCatalog>
    fun onLanguageChanged(language: DisplayLanguage)
}

表示言語が変更されたらassets/*に用意した翻訳ファイルを読み出します。

class DisplayLanguageRepositoryImpl @Inject constructor(
    @ApplicationContext private val context: Context,
) : DisplayLanguageRepository {
    private val _catalog = MutableStateFlow<TextCatalog>(TextCatalog.Initializing)
    override val catalog = _catalog.asStateFlow()

    @OptIn(ExperimentalSerializationApi::class)
    override fun onLanguageChanged(language: DisplayLanguage) {
        val fileName = "text_${language.code}.json"
        val serializer = MapSerializer(String.serializer(), String.serializer())
        val map = context
            .assets
            .open(fileName)
            .use {
                Json.decodeFromStream(serializer, it)
            }
            .entries
            .associate { entry ->
                val id = TextId.values().first { it.name == entry.key }
                id to entry.value
            }
        _catalog.update {
            TextCatalog.Data(
                language = language,
                data = map,
            )
        }
    }
}

Composeに表示言語の変更を反映する

原則、Composeの表示を変更するには新しい引数をComposableに渡してRecompositionをトリガーするしかありません。

Compose では、コンポーズ可能な関数を新しいデータで再度呼び出します。すると、関数が再コンポーズされます。

しかし、アプリ表示言語を引数で渡そうとすると、テキスト表示がある全てのComposableに引数のバケツリレーが必要となります。画面数を多い大規模なアプリとなれば実装は現実的ではありません。そこで CompositionLocalを利用し、アプリ表示言語の情報をComposable全体に暗黙的に流します。

表示言語の更新がUIに反映されるまでのデータの流れは以下のようになります。

dynamic_text_data_flow.png

表示言語のCompositionLocalを実装

CompositionLocalの定義
val LocalTextCatalog = compositionLocalOf<TextCatalog>(
    policy = neverEqualPolicy(),
) {
    TextCatalog.Initializing
}

@Composable
fun rememberTextCatalog(): State<TextCatalog> {
    return if (LocalInspectionMode.current) {
        // Previewの場合は res/values/strings.xml を参照するため適当な値で固定
        rememberUpdatedState(newValue = TextCatalog.Initializing)
    } else {
        val context = LocalContext.current.applicationContext
        val entry = remember(context) {
            EntryPoints.get(context, ThemeEntryPoint::class.java)
        }
        entry.displayLanguageRepository.catalog.collectAsStateWithLifecycle(
            minActiveState = Lifecycle.State.RESUMED,
        )
    }
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface ThemeEntryPoint {
    val displayLanguageRepository: DisplayLanguageRepository
}

Compose + Hilt
ViewModelやActivity以外でDIする場合は、EntryPointを自前で用意する必要があります。

collectAsStateWithLifecycle
ライフサイクルを考慮しないと、画面が表示されていないLifecycle.State.RESUMED未満の状態でも表示言語の更新をcollect & Recompositionをトリガーし、場合によってはクラッシュします!

定義した LocalTextCatalog を Composable全体に流すため、Themeの部分をProviderでラップします。

CompositionLocalのスコープ設定
@Composable
fun YourTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // ...中略...(プロジェクトの自動生成コードのまま)

    val textCatalog by rememberTextCatalog()

    CompositionLocalProvider(
        LocalTextCatalog provides textCatalog,
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            content = content
        )
    }
}

表示言語の変更を考慮したテキストの参照

アプリ独自の表現言語に応じたテキストを取得する関数stringResourceを実装します。内部でLocalTextCatalog.currentを参照するため、表示言語が更新されると関数stringResourceの使用箇所が Recompose されます。

UI側からテキストを参照する
@Composable
fun stringResource(id: TextId): String {
    return if (LocalInspectionMode.current) {
        // Previewでは `res/values/strings.xml`のテキストを代わりに表示させます
        androidx.compose.ui.res.stringResource(id = id.xmlId)
    } else {
        when (val catalog = LocalTextCatalog.current) {
            TextCatalog.Initializing -> throw IllegalStateException("not initialized yet")
            is TextCatalog.Data -> catalog.data[id]
                ?: throw IllegalArgumentException("string not found. id: $id")
        }
    }
}

初期化処理の実装

アプリ起動時はTextCatalog.Initializingであり、まだ表示言語ごとのテキストを読み込んでいません。このまま Compose からテキストを参照するとクラッシュします。初期表示の言語を設定する処理を追加します。

class BootUseCase @Inject constructor(
    private val displayLanguageRepository: DisplayLanguageRepository,
) {
    suspend operator fun invoke() {
        // その他の初期化処理
        delay(2000L)
        // 初期表示の言語は英語で固定する
        displayLanguageRepository.onLanguageChanged(DisplayLanguage.English)
    }
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var bootUseCase: BootUseCase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            YourTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    when (LocalTextCatalog.current) {
                        TextCatalog.Initializing -> LoadingScreen()
                        is TextCatalog.Data -> MainScreen()
                    }
                }
            }
        }
        lifecycleScope.launch {
            bootUseCase()
        }
    }
}

LoadingScreen
初期化処理が完了するまでの画面です。アプリ表示言語に依存するUIは描画できませんので、stringResource(TextId)関数は使えません。

初期化処理のタイミング
今回は実装の簡単のためMainActivityから呼び出しますが、スプラッシュ画面などアプリの実装状況に応じて適切に実行してください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?