A Deep Dive on Internationalizing Jetpack Compose Android Apps

To unlock the full potential of your Android app on a global scale, you need to make it localization-ready from the very start. Follow these best practices for internationalizing Android apps built with Jetpack Compose.

Thanks to Google Play Console, Android developers are able to distribute their apps worldwide in no time. However, many users aren’t (native) English speakers. In fact, English makes only 25.9% of the global internet user base. This, essentially, means that an English-only app would miss millions of downloads. To unlock the full potential of your app, you need to make it ready for adaptation to the language and culture of your users. The following best practices for internationalizing an Android app built with Jetpack Compose can help.

Getting started 🏁

At Google I/O 2019, Google first announced Jetpack Compose, a modern toolkit for building native Android UI. It simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs. With Jetpack Compose, you can build UI using declarative functions (similar to React and Flutter). Instead of using XML layout, you’ll directly call the Jetpack Compose functions, and the Compose compiler will take care of everything.

Our demo project

For the purpose of this tutorial, we’ve set up a demo app, Wallet, that helps users keep track of their spending habits. A user can add an expense or income and monitor their transactions in the Dashboard. The app summarizes the logs and displays net balance, total expense, and total income in the Dashboard.

🗒 Note » Download all the code you need on GitHub to get the project started.

Our Wallet app contains three screens built with Jetpack Compose: Onboarding, Dashboard, and Edit Transactions. The Onboarding screen consists of a Terms and Conditions page, followed by a page where the user needs to enter their name to move forward. On the Edit Transaction screen, the user is able to log an expense or income, which will be displayed on the Dashboard screen, along with the total expense, total income, and net balance. Currently, the app only supports English.

Removing hard-coded text

First things first, go to the onboarding > TermsAndConditions.kt file.

In termsAndConditions(), we render text using the Text() composable.

@Composable
fun termsAndConditions(increasePageCount: () -> Unit) {
 ...
   Text(text = "Terms And Conditions", style = MaterialTheme.typography.h4, color = Color.White)
 ...
}

As you can see above, the text string is hardcoded. This is not a good practice as the text in your app can never be dynamic, depending upon the user’s locale.

Android Studio provides a great way to extract the string and put it in another file. Click on the “Terms and Conditions” string and hit Option and Return on macOS (or Alt and Enter on Windows), and then click Extract string resource.

Enter the resource name, and hit OK.

Now go to res > values > strings.xml, and you should be able to see the extracted string there.

<resources>
      <string name="terms_and_conditions">Terms And Conditions</string>
</resources>

Now, the Text() composable looks like this:

@Composable
fun TermsAndConditions(increasePageCount: () -> Unit) {
 ...
   //stringResource() is a function that returns the string 
   //associated with the id ("terms_and_conditions" in this case)
   Text(
        text = stringResource(R.string.terms_and_conditions), 
        style = MaterialTheme.typography.h4, color = Color.White),
        ...  
}

Similarly, extract all the hard-coded strings in the app, and move them to the strings.xml file.

Adding resources for other languages

So far, you’ve extracted all the strings in the app and moved them to res > values > strings.xml file. This will serve as a default resource for all the strings in your app. Whenever an app runs in a locale for which there are no specific resources, Android will load the default strings from res  > values > strings.xml. That’s why it’s crucial to define all your strings in this file.

Now, we want to provide support for Spanish and German. To make this possible, we need to create a new resource directory in our project. Make sure you have the Project view in Android Studio open, and then, right-click res and New > New Resources Directory.

Select Locale and then chose es: Spanish as Language and Any Region as Specific Region Only. Hit OK.

🗒 Note » It’s important to choose both the language and region for the resource directory as internationalization may differ across regions speaking the same language.

This creates a new package in the res folder called values-es. Now, we need to create a strings.xml file that will contain all the values for this specific locale. Go to values-en > New > Value Resource File, type the file’s name as strings.xml, and hit OK.

Now, in the newly created file, add the translations for the strings already defined in the default strings.xml file. Your values-es > strings.xml file should look like this:

<?xml version="1.0" encoding="utf-8"?>
<resources>
 <string name="terms_and_conditions">Términos y condiciones</string>
</resources>

Similarly, you can create this file for the German locale, and translate all the strings from the default strings.xml file.

Interpolation

Interpolation refers to adding text into translation strings. The text that is to be added can be static or dynamic (initialized at runtime).

Static text

For our Wallet app, we will add the text “Visit our website https://xyz.com/ to know more” on the Terms and Conditions screen. Go to the TermsAndConditons.kt file, and add a Text() composable.

Text(
     text = stringResource(R.string.landing_url),
     style = MaterialTheme.typography.body1
    )

Then, add the resource associated with the “landing_url” id in the strings.xml file.

<string name="landing_url">Visit our website https://xyz.com/ to know more.</string>

Don’t forget that the website URL (https://xyz.com/) must stay the same across all languages. However, the position of the URL in the string might differ based on language.

To make sure your translators don’t change the URL while translating, use an <xliff:g> placeholder tag. When you declare a placeholder tag, always add an id attribute explaining what the placeholder is for to provide the translators with enough context about the string. Modify the string resource as follows:

//For default strings.xml file
<string name="landing_url"> Visit us at <xliff:g id="application_homepage">https://xyz.com/</xliff:g> to know more.</string>


//For the German strings.xml file, similarly add this string     
<string name="landing_url">Erfahren Sie mehr unter <xliff:g id="application_homepage">https://xyz.com/</xliff:g></string>

After adding the placeholder, the URL in both English and German should be the same, i.e. https://xyz.com/.

Dynamic Text

Now, we want to display a greeting message (“Hello John”) on the Dashboard screen, where “John” is any name that the user entered previously on the Onboarding screen. Here, “John” is dynamic and depends on the user’s input. You need to pass the name as a parameter while fetching the string from the resource using the same stringResource() function as above. In the DashboardScreen.kt file, add the following:

fun DashboardScreen(..,displayName: String) {
    WalletTheme() {
     ...
       Text(
           //display name is a parameter that 
           //is passed to be interpolated, eg. Rob
           text = stringResource(R.string.greeting,displayName),
            )
      ...
    }
}

In the strings.xml file, add a resource associated with the greeting string. Use an <xliff:g> placeholder tag with both an id and example attribute. The example attribute helps your translator understand what kind of values will be passed as the parameter. Wrap the %s within <xliff:g> tags. The parameter passed in the function above will replace the %s value. Here, %s stands for a string parameter. You could similarly pass a decimal integer (%d) and floating-point number(%f), too .

//example attribute = "Joe" helps your translator get more idea about what parameter will be passed here
<string name="greeting">Hello, 
      <xliff:g name="name" example="Joe">%s</xliff:g> 
</string>

Now, the app displays a dynamic string (Hello, ${name}) at the top of the Dashboard screen.

Plurals

To differentiate between plural and singular strings, we can define plurals in the strings.xml file and list different quantities. In our Wallet, the heading “Transactions” needs to be dynamic, depending on the quantity of the transaction: It should display “Transaction” when there is only one transaction and “Transactions” in case of other quantities.

🗒 Note »  Some languages like Arabic have 6 plural forms. In our app, we only use the attributes “one” and “other”, but you can also use the attributes “zero,” “two,” “a few,” “many” as required by the locale.

In the strings.xml file, add the following:

 <plurals name="heading_transaction">
<!--
          As a developer, you should always supply "one" and "other"
          strings. Your translators will know which strings are actually
          needed for their language. More information on attributes on 
          https://developer.android.com/guide/topics/resources/string-resource
       -->
     <item quantity="one">Transaction</item>
     <item quantity="other">Transactions</item>
 </plurals>

🗒 Note » The 1.0.0-beta05 version of Jetpack Compose doesn’t offer a built-in function for getting plural resources yet.

We can create our own custom function that uses LocalContext and returns the appropriate string. In ui > dashboard > DashboardScreen.kt, add the following:

val transactions = viewModel.transactions.value
...
  //Use the custom function here, while passing the size of the list.
  Text(
       text = quantityStringResource(R.plurals.heading_transaction, transactions.size)
       )
     }

//Custom function to get plural resources
@Composable
fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String {
    return LocalContext.current.resources.getQuantityString(id, quantity)
}

You can see the heading “Transactions” when there are more than 2 items in the list and the heading “Transaction” heading when there is only one item in the list.

Internationalizing Numbers

Numbers are used in different formats across the globe. For example, in the US, “one thousand seventy” is written as 1,070. Thousands get separated by a comma (,). In Germany, on the other hand, the same number is transcribed as 1.070, with a period (.) as the separator. Similarly, in negative numbers, the minus (-) symbol is placed either before or after the digits, depending on where they’re used.

🗒 Note » Feel free to have a look at this number localization guide if you want to learn more.

When it comes to Android, the operating system provides a class to easily format numbers while taking the user’s locale into consideration. At the moment, our Wallet app displays the transactions without any number formatting.

In Dashboard > TransactionItem.kt, use the NumberFormat class to format the numbers, depending upon the phone’s locale.

@Composable
fun TransactionItem(transaction: Transaction) {  
...
   val formattedAmount = NumberFormat.getInstance().format(transaction.amount);
        Text(
            text = formattedAmount,
            fontSize = 20.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier.align(Alignment.CenterVertically)
        )
...
}

Similarly, in the DashboardViewmodel.kt file, use the NumberFormat class to format the balance, total expense, and total income, which is collected in the ViewModel.

 private fun collectFlows() {
    viewModelScope.launch {
        repository.getTransactions().collect {
             ...
         //Remove these lines
//       netExpence.value = "$expense"
//       netIncome.value = "$income"
//       balance.value = (income-expense).toString()

         //Add this
         val numberFormat = NumberFormat.getInstance()
         netExpence.value = numberFormat.format(expense)
         netIncome.value = numberFormat.format(income)
         balance.value = numberFormat.format(income-expense)
           }
       }
 }

You can see below how numbers get formatted according to the locale now. From left to right: en-US for English in the US, de-DE for German in Germany, and fr-FR for French in France.

Internationalizing currency

Currently, on the Dashboard screen, we’ve got the dollar ($) sign hardcoded at the beginning of the balance.

To internationalize it, we need to consider the following:

  • Currency symbol: for the US, it’ll be $, but for Europe, it’ll be €
  • Currency symbol placement: it can be placed either before or after the digits:
    • £227.54 for the UK
    • 227,54 € for France
  • Negative amount display: in case of negative amounts, the amount of -12354 will be as follows:
    • -123,45 € for France
    • € 123,45- for the Netherlands
    • ($127.54) for the US

In the DashboardViewModel.kt file, we’ll use the NumberFormat class again:

private fun collectFlows() {
       viewModelScope.launch {
          ...
               //Remove this 
               //balance.value = numberFormat.format(income-expense)

               val netBalance = income - expense
               val formattedCurrency = NumberFormat.getCurrencyInstance().format(netBalance)

               balance.value = formattedCurrency
         ...
       }
   }

Now, the amount will be formatted according to the locale’s currency. Have a look at the screenshots below for the locales from left to right: en-IN for English in India, de-DE for German in Germany, nl-NL for Dutch in the Netherlands, and en-US for English in the US.

Internationalizing dates

Our app can currently display the epoch time below each transaction (in milliseconds). We need to internationalize this as well to display dates in a format based on the user’s locale.

For example, the date of 27th April 2021, is represented by “4/27/21″in U.S. English, while the same date in Canadian French is represented by “2021-04-27”. We’ll convert the epoch time to these date formats using DateTimeFormatter and Local Date classes. Go to ui > dashboard > transactionItem.kt, and edit the transactionInfo() composable as follows:

@Composable
fun transactionInfo(transaction: Transaction) {
...
        val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) // Alternatively use FormatStyle.LONG, FULL, MEDIUM for detailed dates
        val transactionDate: LocalDate = LocalDate.ofEpochDay(transaction.dateAdded/86400000L) //86400000L represents number of seconds in a day.
        val formattedTransactionDate: String = transactionDate.format(dateFormatter)

        Text(
            //Use the formatted date in this composable
            text = formattedTransactionDate,
            fontSize = 16.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier.align(Alignment.Start)
        )
...
}

In the image below, you can now see how the same dates get formatted according to the respective locale. From left to right:en-US for English in the US, de-DE for German in Germany, and fr-FR for French in France.

Internationalizing images

Currently, on the UpdateName Screen, we only see this dollar banknote image for all supported locales.

To reflect the user’s local currency, we’ll internationalize the image in a way that would allow the Wallet to display, for example, a euro banknote for the German locale.

🗒 Note » Make sure you have opened Project View (and not Android View).

RIght-click res > New > Android Resource Directory …

… and select language, region, and type in drawable (followed by locale, which is prewritten automatically) in Directory name. Finally, click OK.

Copy the locale-specific image into the newly created resource directory. Make sure it has the same name as the default image in the drawable.

In Updatename.kt, reference the above image resource using the “bank_note” id. Android will automatically display the image according to the locale. Compose also makes it mandatory to provide a content description for any image composable. This text is used by accessibility services to describe what the image conveys.

Image(
     painter = painterResource(R.drawable.bank_note),
     //Compose makes it mandatory to add a content Description
     contentDescription = stringResource(R.string.image_description_bank_note),
     Modifier
            .height(100.dp)
            .width(100.dp)
      )

Now, Android will display the appropriate image taking the locale into consideration. For the de-DE locale, it’ll show the euro banknote, while for all other regions, it’ll display the default dollar banknote.

Localization-friendly layouts using Jetpack Compose

Jetpack Compose makes it easier than ever to build dynamic layouts. You should always build a flexible layout that will accommodate any imaginable text in any language.

Pay attention to the following when building layouts with Compose:

  • Some texts might take up more width than you think they will. For example, the English word “skip” contains only 4 characters, but its German equivalent, “Überspringen,” takes up more than 10 characters.
  • Users might have different font size settings for accessibility, which might break the design.
  • Different Andoird devices use different sizes and aspect ratios; the design must adapt according to the user’s device.

Currently, our app suffers from all these issues. Let us address them one by one.

Avoid using fixed width or frames

In the onboarding > TermsAndConditions.kt file, we defined the Surface() composable with a fixed height.

@Composable
fun OnBoardingContentOne(increasePageCount: () -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Surface(
            color = Color.DarkGray, modifier = Modifier
                //Surface is defined with a fixed height 
                .height(600.dp)
                .fillMaxWidth(),
   }  
}

Now, this may look fine on large phones, but since the height is fixed, it will crop the text container on smaller screens, moving the button out of the visible area. Compare both examples below.

To fix the issue, Compose comes with a Modifier extension function called weight(). It sizes the composable’s height with respect to other siblings in the view (Column in our case). Here, we want the Surface to occupy 70% and the remaining view 30% of the height for devices of all sizes. Modify the code as follows:

@Composable
fun termsAndConditions(increasePageCount: () -> Unit) {
    Column(...) {
        Surface(
            color = Color.DarkGray, modifier = Modifier
                //this will occupy 70% of height.
                .weight(7f)
                .fillMaxWidth(),
               )
            }

        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier
               //this will occupy 30% of height.
                .weight(3f)
        ) 
       ...
}

Now, the views are in fixed proportions for all sizes.

Similarly, in the onboarding > TermsAndConditions.kt file, we defined the Button composable as follows:

@Composable
fun termsAndConditions(increasePageCount: () -> Unit) {
   ...
        //Notice the fixed width 130.dp here
        Button(onClick = increasePageCount, modifier = Modifier.width(130.dp)) {
            Text(
                text = stringResource(R.string.i_agree),
                style = MaterialTheme.typography.h6
            )
        }
...
}

As you can see below, this works well in English (notice the “I Agree” button). However, when the device language is set to German, the button text breaks into two lines, which eventually changes the height of the button.

Removing the width modifier automatically makes the button wrap around the text, increasing and decreasing width based on the content.

Button(onClick = increasePageCount) {
                Text(
                    text = stringResource(R.string.i_agree),
                    style = MaterialTheme.typography.h6
                )
            }

We now have a button that nicely wraps around the content inside it. However, if the button content is very short, let’s say when we only have “OK,” the button size gets drastically reduced.

To fix this issue, Compose provides the Modifier.widthIn() function. You can define the minimum width of the button (or any composable) using this function.

Button(onClick = increasePageCount,modifier = Modifier.widthIn(100.dp) ) {
                   Text(
                       text = stringResource(R.string.i_agree),
                       style = MaterialTheme.typography.h6
                   )
               }

The button’s width is now set to a minimum of 100 dp, while the max. width is dynamic, depending upon the text inside.

.

🗒 Note » Always ask translators to use the shortest word possible for buttons.

Avoid fixed spaces

In the Dashboard, we also have Transaction Items, a list of transactions added by the user. For any locale that takes up a little more space, the UI will break. While we have fixed the space width of 120 dp between the transaction title and the transaction amount, whenever the text length increases, it will push the transaction amount out of the view. As you can see below, the layout isn’t optimized.

Notice the fixed Composable Space() width of 120 dp.

To internationalize this, we’ll use Compose’s Arrangement interface and control the layout of the components inside the parent layout. For the parent row that contains both the transaction description and the amount, we apply the Arrangement.SpaceBetween property. It’ll push both components to the end of the parent row. We also apply .fillMaxWidth(0.7f)  to the Column view containing the transaction information, so its width occupies a maximum of 70% of the parent’s width.

In the ui > dashboard > TransactionItem.kt file, modify the code to:

@Composable
fun TransactionItem(transaction: Transaction) {
       ...
        Row(
            modifier = Modifier
                .fillMaxWidth(),
            //Evenly add space between the components
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            transactionInfo(transaction = transaction)
            Text(
                text = formattedAmount,
                fontSize = 20.sp,
                textAlign = TextAlign.End,
                modifier = Modifier.align(Alignment.CenterVertically)
            )
       ...
}



@Composable
fun transactionInfo(transaction: Transaction) {

    Column(
        verticalArrangement = Arrangement.Center,
        modifier = Modifier
            .padding(horizontal = 10.dp)
            //This will set a max width of this column to 
            //be 70% of the parent
            .fillMaxSize(0.7f)
    ) 
    ...
}

This is how Transactions Items looks like at the moment. The list is now able to handle all kinds and lengths of text without disrupting the UI.

Scrollable text

The terms and conditions of our app are very important pieces of information and shouldn’t get clipped in any language. That’s exactly what happens right now on our TermsAndConditions.kt screen. Originally, it contains 5 paragraphs, but as you can see below, it only displays 1 paragraph. The rest is clipped. It’s even worse for smaller devices, or in German, where the text takes up way more space than in English.

🗒 Note » Your layout always needs to be built in a way that avoids cropping text.

To handle this, Jetpack Compose offers Scroll Modifiers. The verticalScroll and horizontalScroll modifiers provide the simplest way to allow the user to scroll an element when the bounds of its content are larger than its maximum size constraints. Modify the Text() Composable in the TermsAndConditions.kt  file like this:

//Add vertical scroll modifier to the Text Composable.
Text(
    modifier = Modifier.verticalScroll(rememberScrollState()),
    textAlign = TextAlign.Center,
    color = Color.White,
    text = stringResource(R.string.lorem_ipso),
    style = MaterialTheme.typography.body1,
)

After adding the vertical scroll modifier, the Terms and Conditions page is now scrollable, and the content, irrespective of text length, won’t ever get clipped in any language.

Adding ellipsis to text

Sometimes, it might be more important to have form rather than function. In those cases, you can just display a part of the text instead of its full length. Compose provides certain TextOverflow options to handle situations when the text overflows.

  • Clip: It clips the overflowing text to fit its container
  • Ellipsis: Used to indicate that the text has overflowed

At the moment, a transaction item description in our Wallet may occupy more than one line. This isn’t a priority piece of text for our app, and the line breaks make the transaction items look non-uniform. That’s why we’d like to cut off the text, and add an ellipsis if it takes up more than one line.

In the ui > dashboard > trasactionItem.kt file, add the following attributes to the Text() composable:

@Composable
fun transactionInfo(transaction: Transaction) {
   ...
        Text(
            text = transaction.title,
            fontSize = 18.sp,
            textAlign = TextAlign.Start,
            modifier = Modifier.align(Alignment.Start),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis
   ...
}

The transaction title will now always fit in one line. Should the text not fit, it’ll display an ellipsis at the end. Notice the 1st and 4th entry specifically.

Dynamic font size

Sometimes, you may come across a situation where the text in a certain language overflows the set constraints/bounds and adding an ellipsis or clipping the text isn’t an option. We have a similar problem on our EditTransaction.kt screen. The English text in the buttons fits well, but the equivalent text in German takes up a lot more space and, quite undesirably, ends up splitting into two lines.

🗒 Note » This is not a recommended approach as the button has to be re-rendered if the text inside it overflows. Ideally, the UI should be designed in a way that always allows for extra space for the text to expand.

Using Compose, you can easily observe when a new text layout is calculated. To change the text size dynamically in case of an overflow, go to EditTransactionScreen.kt file, and add the following code:

fun EditTransactionScreen() {
    ...
    //The size of the button text 
    //will be remembered here
    val size = remember { mutableStateOf(value = 14.sp) }
    ...
       Button(...){
                   Text(
                        text = stringResource(R.string.add_income),
                        style = MaterialTheme.typography.button,
                        //Set font size to the above initialized state
                        fontSize = buttonTextSize.value,
                        overflow = TextOverflow.Clip,
                        //Fix the max lines to 1
                        maxLines = 1,
                        onTextLayout = { textLayoutResult ->
                        //If text has overflowed, 
                        //reduce the button text size by a 
                        //factor of 0.9
                         if(textLayoutResult.didOverflowHeight) {
                             size.value=size.value* 0.9f
                                 }
                             }
                          )
                      }
       Button(...) {
                   Text(
                        text = stringResource(R.string.add_expense),
                        style = MaterialTheme.typography.button,
                        fontSize = buttonTextSize.value,
                        maxLines = 1,
                        overflow = TextOverflow.Clip,
                        onTextLayout = { textLayoutResult ->
                        if(textLayoutResult.didOverflowHeight) {
                             size.value=size.value* 0.9f
                                 }
                             )
                          }
                      }
                }

Now, whenever the text inside any of the buttons overflows, the font size will be reduced, and the composable will be re-rendered. In case the text still overflows, the size of the font is again reduced by a factor of 0.9. This keeps happening until the text fits properly within the button. You can see how the text size in the German locale is optimized, i.e. reduced in comparison to the English text) to fit within the button.

Android Studio warnings and Translations Editor

Android Studio comes with a Translations Editor that provides a consolidated and editable view of all of your default and translated string resources. To open the Editor, go to any strings.xml file and click open Editor on the top right.

The Translation Editor is smart enough to warn you about the irregularities in the string.xml files. The keys will be highlighted in red to signalize that something is wrong.

You can hover over the highlighted key to see the warning. In our app, we see the app_name key is showing a warning as it is not translated into German and Spanish. We’ll mark it as untransable, and the warning will go away. Similarly, for the header_name key, we see a warning that there is no default value string for it. This will crash your app in case any screen tries to access the default value for this key.

With the help of the Translations Editor, you’ll get an overview of all the strings used in the project, and you can easily fix these warnings in the editor itself.

🗒 Note » Download the final version of the app on GitHub.

We’re done ✅

We internationalized our Wallet app, built with Jetpack Compose, using best practices for internationalization and an adaptive user interface.

Now, if you feel it is the right time for your translators to start localizing your app, give Phrase a try. The fastest, leanest, and most reliable localization management platform worldwide will help you streamline the i18n process with everything you need to:

  • Build production-ready integrations with your development workflow,
  • Invite as many users as you wish to collaborate on your projects,
  • Edit and convert localization files with more context for higher translation quality.

Sign up for a free 14-day trial, and see for yourself how it can make a developer’s life easier.

5 (100%) 74 votes
Comments
close

Take Your App Global.
With Confidence.
Now for Free.

Startups? Enterpreneurs? Freelancers?

Get everything you need to reach new markets.

Start now with Phrase's Free Plan!