JavaFX: Change application language on the run

Like @Chiggiddi, i also like the internationalized string approach. Binding each label on the java side is too tedious. So i came up with a mixed approach with binding and expression binding on fxml side. I share this solution here today because i didn't find any like this on all stackoverflow's question i've visited. I hope it will be of some help to someone.

First create an observable map of map populated from ResourceBundle's keys like below:

public class LocaleManager extends SimpleMapProperty<String, Object> {

    private String bundleName = "language"; // a file language.properties must be present at the root of your classpath
    
    public LocaleManager() {
        super(FXCollections.observableHashMap());
        reload();
    }

    public void changeLocale(Locale newLocale) {
        Locale.setDefault(newLocale);
        reload();
    }

    private void reload() {
        ResourceBundle bundle = ResourceBundle.getBundle(bundleName);
        Enumeration<String> keys = bundle.getKeys();
        while (keys.hasMoreElements()) {
            String key = keys.nextElement();
            String value = bundle.getString(key);
            
            String[] parts = key.split("\\.");
            
            MapProperty<String, Object> map = this;
            
            for (int i=0;i < parts.length; i++) {
                String part = parts[i];
                if (i == parts.length - 1) {
                    map.put(part, value);
                } else {
                    if (!map.containsKey(part)) {
                        map.put(part, new SimpleMapProperty<>(FXCollections.observableHashMap()));
                    }
                    map = (MapProperty<String, Object>)map.get(part);
                }
            }
        }
    }
    
    public StringBinding bind(String key) {
        String[] parts = key.split("\\.");
        
        MapProperty<String, Object> map = this;
        
        for (int i=0;i < parts.length; i++) {
            String part = parts[i];
            if (i == parts.length - 1) {
                return Bindings.valueAt(map, part).asString();
            } else {
                if (!map.containsKey(part)) {
                    map.put(part, new SimpleMapProperty<>(FXCollections.observableHashMap()));
                }
                map = (MapProperty<String, Object>)map.get(part);
            }
        }
        throw new NullPointerException("Unknown key : " + key);
    }
}

Now you need to create a base class for your views exposing the LocaleManager as a property with getter and setter included:

public class BaseView {

    private LocaleManager lang;
    
    public BaseView() {
        lang = new LocaleManager();
    }
    
    public LocaleManager langProperty() {
        return lang;
    }

    public ObservableMap<String, Object> getLang() {
        return lang.get();
    }

    public void setLang(MapProperty<String, Object> resource) {
        this.lang.set(resource);
    }

}

Now, if your view extends BaseView

public MyView extends BaseView {}

Any expression in your fxml like ${controller.lang.my.resource.key} will be binded to the same key in your ResourceBundle

Binding on java side can still be done using:

someField.textProperty().bind(langProperty().bind(BUNDLE_KEY));

Now to change the language on the fly just use:

langProperty().changeLocale(newLocale);

Remember to turn LocaleManager as a singleton in your app if you want to change the language for all your app.

On SceneBuilder side binding expression for string fields aren't supported yet. But the following pull request may help if accepted in the future:

Pull request

EDIT:

To answer @Novy comment, the binding key contains 2 parts. The first part "controller.lang" give you access to the lang property of the controller. It is the same as writing this.getLang() in your controller class

So if in your property file you have the following properties:

item1 = "somestring"
item1.item2 = "someotherstring"
item1.item2.item3 = "someotherotherstring"

then

${controller.lang.item1} == "somestring"
${controller.lang.item1.item2} == "someotherstring"
${controller.lang.item1.item2.item3} == "someotherotherstring"

${controller.lang.item1.item2} can be translated to the following java code in your controller class

((Map<String, Object)this.getLang().get("item1")).get("item2").toString()

or with the implicit casting provided by javafx

this.getLang().get("item1").get("item2")

You can do something like this. As in your answer, you would either want to implement this as a singleton, or use a DI framework to inject a single instance wherever you need it:

public class ObservableResourceFactory {

    private ObjectProperty<ResourceBundle> resources = new SimpleObjectProperty<>();
    public ObjectProperty<ResourceBundle> resourcesProperty() {
        return resources ;
    }
    public final ResourceBundle getResources() {
        return resourcesProperty().get();
    }
    public final void setResources(ResourceBundle resources) {
        resourcesProperty().set(resources);
    }

    public StringBinding getStringBinding(String key) {
        return new StringBinding() {
            { bind(resourcesProperty()); }
            @Override
            public String computeValue() {
                return getResources().getString(key);
            }
        };
    }
}

Now you can do things like:

ObservableResourceFactory resourceFactory = .... ;

resourceBundle.setResources(...);

Label greetingLabel = new Label();
greetingLabel.textProperty().bind(resourceFactory.getStringBinding("greeting"));

And any time you update the resource with

resourceFactory.setResources(...);

will cause the label to update its text.

Here's an SSCCE (with apologies for the extremely ugly way of forcing a ResourceBundle into a single runnable class...)

import java.util.ListResourceBundle;
import java.util.Locale;
import java.util.ResourceBundle;

import javafx.application.Application;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;

public class ResourceBundleBindingExample extends Application {

    private static final String RESOURCE_NAME = Resources.class.getTypeName() ;

    private static final ObservableResourceFactory RESOURCE_FACTORY = new ObservableResourceFactory();

    static {
        RESOURCE_FACTORY.setResources(ResourceBundle.getBundle(RESOURCE_NAME));
    }

    @Override
    public void start(Stage primaryStage) {
        ComboBox<Locale> languageSelect = new ComboBox<>();
        languageSelect.getItems().addAll(Locale.ENGLISH, Locale.FRENCH);
        languageSelect.setValue(Locale.ENGLISH);
        languageSelect.setCellFactory(lv -> new LocaleCell());
        languageSelect.setButtonCell(new LocaleCell());

        languageSelect.valueProperty().addListener((obs, oldValue, newValue) -> {
            if (newValue != null) {
                RESOURCE_FACTORY.setResources(ResourceBundle.getBundle(RESOURCE_NAME, newValue));
            }
        });

        Label label = new Label();
        label.textProperty().bind(RESOURCE_FACTORY.getStringBinding("greeting"));

        BorderPane root = new BorderPane(null, languageSelect, null, label, null);
        root.setPadding(new Insets(10));
        Scene scene = new Scene(root, 400, 400);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static class LocaleCell extends ListCell<Locale> {
        @Override
        public void updateItem(Locale locale, boolean empty) {
            super.updateItem(locale, empty);
            if (empty) {
                setText(null);
            } else {
                setText(locale.getDisplayLanguage(locale));
            }
        }
    }

    public static class ObservableResourceFactory {

        private ObjectProperty<ResourceBundle> resources = new SimpleObjectProperty<>();
        public ObjectProperty<ResourceBundle> resourcesProperty() {
            return resources ;
        }
        public final ResourceBundle getResources() {
            return resourcesProperty().get();
        }
        public final void setResources(ResourceBundle resources) {
            resourcesProperty().set(resources);
        }

        public StringBinding getStringBinding(String key) {
            return new StringBinding() {
                { bind(resourcesProperty()); }
                @Override
                public String computeValue() {
                    return getResources().getString(key);
                }
            };
        }

    }

    public static class Resources extends ListResourceBundle {

        @Override
        protected Object[][] getContents() {
            return new Object[][] {
                    {"greeting", "Hello"}
            };
        }

    }

    public static class Resources_fr extends ListResourceBundle {

        @Override
        protected Object[][] getContents() {
            return new Object[][] {
                    {"greeting", "Bonjour"}
            };
        }

    }

    public static void main(String[] args) {
        launch(args);
    }
}