Android: Add Bilingual Support

Homan Huang
The Startup
Published in
6 min readDec 4, 2020

Adding a different language to an App is easy on Android. The Locale function can support Android to switch to different languages. By SharedPreference storage, we can save the language setting into internal memory. Combine these two, we can have a multilingual App.

After the configuration update, we can swap the hardcoded strings of strings.xml under the corresponding language.

— === Menu === —

👾1. Add Language Resource
✍️2
. UI Design
🎫3
. Translation Editor
🏹4
. Inject Storage in ViewModel
🔨5
. Common Helpers
🌗6
. UI for Switching Locale
✈️7
. Final Result

👾1. Add Language Resource

< === Menu

Let’s open a new project. After you’re done, we can add a new resource.

Click Locale:

The RHD of UI will shift to:

Input “zh” to get Chinese

A new strings.xml will be created.

✍️2. UI Design

< === Menu

Just make the UI easy, one label, and one floating button. This is our legend demo HelloWorld. Now, we need to create a hardcoded string list by converting all of

android:text= “…” or android:contentDescription= “…”

with Alt+Enter.

Filled new language checkbox:

🎫3. Translation Editor

< === Menu

When you finish them all, you can translate them on the Translation Editor.

Now, at the end of the menu bar,

let’s click search.

Click the Translations Editor.

I enter some Chinese translation. It’s ready!

🏹4. Inject Storage in ViewModel

< === Menu

Thanks for Hilt Codelab. We can expand the Storage function into this project.

Storage.kt:

interface Storage {
fun setString(key: String, value: String)
fun getString(key: String): String

fun setBoolean(key: String, value: Boolean)
fun getBoolean(key: String): Boolean

fun setInt(key: String, value: Int)
fun getInt(key: String): Int

fun delKey(key: String): Boolean
fun clear()
}

SharedPreferencesStorage.kt:

class SharedPreferencesStorage @Inject constructor(
@ApplicationContext context: Context
) : Storage {

private val fileName = "gitPractice"

private val sharedPreferences = context
.getSharedPreferences(fileName, Context.MODE_PRIVATE)

// String Value
override fun setString(key: String, value: String) {
with(sharedPreferences.edit()) {
putString(key, value)
apply()
}
}

override fun getString(key: String): String {
return sharedPreferences.getString(key, UNKNOWN)!!
}

// Boolean Value
override fun setBoolean(key: String, value: Boolean) {
with(sharedPreferences.edit()) {
putBoolean(key, value)
apply()
}
}

override fun getBoolean(key: String): Boolean {
return sharedPreferences.getBoolean(key, false)
}

// Integer Value
override fun setInt(key: String, value: Int) {
with(sharedPreferences.edit()) {
putInt(key, value)
apply()
}
}

override fun getInt(key: String): Int {
return sharedPreferences.getInt(key, 0)
}

// Remove value
override fun delKey(key: String): Boolean {
with(sharedPreferences.edit()) {
remove(key)
apply()
}
return !sharedPreferences.contains(key)
}

// Clear all data
override fun clear() {
with(sharedPreferences.edit()) {
clear()
apply()
}
}
}

StorageModule.kt:

@InstallIn(ApplicationComponent::class)
@Module
abstract class StorageModule {
@Binds
abstract fun provideStorage(
storage: SharedPreferencesStorage
): Storage
}

Also, don’t forget the application:

GitPracticeApp.kt:

@HiltAndroidApp
class GitPracticeApp : Application()

If you have any injection questions, please try one Hilt Codelab.

In any ViewModel, we can simply inject our storage function.

class BaseViewModel @ViewModelInject constructor(
private val storage: Storage
) : ViewModel() {

🔨5. Common Helpers

< === Menu

I have some common files to support the app.

Config.kt: Constant strings.

// Storage
const val UNKNOWN = "UNKNOWN"
const val CHINA = "China"
const val US = "United State"
const val LANGUAGE = "Language"

LocaleHelper.kt: Get locale from storage.

val Lang_Chinese: Locale = Locale.CHINA
val Lang_US_English: Locale = Locale.US

fun getLocale(storage: Storage): Locale {
lgd("LocaleHelper: getLocale()")
val language = storage.getString(LANGUAGE)

var locale: Locale? = null
when (language) {
UNKNOWN -> {
lgd("LocaleHelper: 无记录,中文为第一选择。")
storage.setString(LANGUAGE, CHINA)
locale = Lang_Chinese
}
CHINA -> locale = Lang_Chinese
US
-> locale = Lang_US_English
}
return locale!!
}

LogHelper.kt: Logcat helper

const val TAG = "MLOG"
fun lgd(s:String) = Log.d(TAG, s)
fun lgi(s:String) = Log.i(TAG, s)
fun lge(s:String) = Log.e(TAG, s)

Must add a Locat filter with a tag name, “MLOG”.

🌗6. UI for Switching Locale

< === Menu

My MainActivity inherits to its BaseActivity. I’m also using the MVVM design pattern. So the base files have two.

BaseViewModel.kt:

class BaseViewModel @ViewModelInject constructor(
private val storage: Storage
) : ViewModel() {

var mLocale: Locale? = null

init {
setDefault()
}

fun setDefault() {
lgd("BaseVM: setDefault()")
mLocale = getLocale(storage)
}
}

BaseActivity.kt:

@Suppress("DEPRECATION")
@AndroidEntryPoint
abstract class BaseActivity : AppCompatActivity() {

private var mCurrentLocale: Locale? = null

// ViewModel
private val baseVM: BaseViewModel by viewModels()

override fun onStart() {
super.onStart()
mCurrentLocale = resources.configuration.locale
lgd("BaseAct: Locale: $mCurrentLocale")
}

override fun onRestart() {
super.onRestart()
val locale = baseVM.mLocale
if (locale != mCurrentLocale) {
mCurrentLocale = locale
recreate()
}
}

override fun applyOverrideConfiguration(
overrideConfiguration: Configuration?
) {
if (overrideConfiguration != null) {
val uiMode = overrideConfiguration.uiMode
overrideConfiguration.setTo(
baseContext.resources.configuration)
overrideConfiguration.uiMode = uiMode
}
super.applyOverrideConfiguration(overrideConfiguration)
}

fun changeLocale(context: Context, locale: Locale?) {
lgd("BaseAct: changeLocale() ================> $locale")
val res: Resources = context.resources
val conf: Configuration = res.configuration
conf.setLocale(locale)
baseContext.resources.updateConfiguration(
conf, baseContext.resources.displayMetrics)
}
}

MainViewModel.kt: Swap the locale.

class MainViewModel @ViewModelInject constructor(
private val storage: Storage
) : ViewModel() {

val currentLocale = MutableLiveData<String>()

init {
//storage.clear()
when (getLocale(storage)) {
Locale.CHINA-> currentLocale.value = CHINA
Locale.US-> currentLocale.value = US
}
}

fun swapLocale() {
val language = storage.getString(LANGUAGE)
lgd("MainVM: Current language = $language")

when (language) {
CHINA -> {
storage.setString(LANGUAGE, US)
currentLocale.postValue(US)
}
US -> {
storage.setString(LANGUAGE, CHINA)
currentLocale.postValue(CHINA)
}
}
}
}

MainActivity.kt: It extends BaseActivity, so it doesn’t need to add @AndroidEntryPoint.

class MainActivity : BaseActivity() {

// ViewModel
private val mainVM: MainViewModel by viewModels()

val localeFab: CoordinatorLayout by lazy {
findViewById(R.id.localeFab) }
val titleTV: TextView by lazy { findViewById(R.id.titleTV) }
val changeTV: TextView by lazy { findViewById(R.id.changeTV) }
val countryIV: ImageView by lazy { findViewById(R.id.countryIV)}
val countryTV: TextView by lazy { findViewById(R.id.countryTV) }

@SuppressLint("UseCompatLoadingForDrawables")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

localeFab.setOnClickListener {
lgd("MainAct: Clicked")
mainVM.swapLocale()
}

mainVM.currentLocale.observe(
this,
{ mLocale ->
lgd("MainAct: Observable Language => $mLocale ")
when (mLocale) {
CHINA -> {
lgd("MainAct: Selected China...")
changeLocale(this, Locale.CHINA)
countryIV.setImageDrawable(
getDrawable(china_flag))
updateText()
}
US -> {
lgd("MainAct: Selected English(US)...")
changeLocale(this, Locale.US)
countryIV.setImageDrawable(
getDrawable(us_flag))
updateText()
}
}
}
)
}

private fun updateText() {
lgd("MainAct: 更新版面, updateText().")
val changeText = getString(R.string.change_language)
changeTV.text = changeText
val helloText = getString(R.string.helloworld)
titleTV.text = helloText
val imageLabel = getString(R.string.country)
countryTV.text = imageLabel
countryIV.contentDescription = imageLabel
val actionText = getString(R.string.app_name)
supportActionBar?.title = actionText
lgd("MainAct: Language swap: " +
"$changeText, $helloText, $actionText ")
}

@TestOnly
fun getLocale(): String? {
return mainVM.currentLocale.value
}
}

✈️7. Final Result

< === Menu

Let’s run.

--

--

Homan Huang
The Startup

Computer Science BS from SFSU. I studied and worked on Android system since 2017. If you are interesting in my past works, please go to my LinkedIn.