javawebviewjavafx-2

Detect URL change in JavaFX WebView


In JavaFX's WebView I am struggling to detect change in URL.

I have this method in a class:

public Object urlchange() {
    engine.getLoadWorker().stateProperty().addListener(new ChangeListener<State>() {
        @Override
        public void changed(ObservableValue ov, State oldState, State newState) {
            if (newState == Worker.State.SUCCEEDED) {
                return engine.getLocation()); 
            }
        }
    });         
}

and I am trying to use it for an object called loginbrowser like:

System.out.print(loginbrowser.urlchange());

Can you see what I've done wrong?


Solution

  • (Part of) what you are doing wrong

    The code you provided in your question doesn't even compile. The changed method of a ChangeListener is a void function, it can't return any value.

    Anyway, loading of stuff in a web view is an asynchronous process. If you want the value of the location of the web view after the web view has loaded, you need to either wait for the load to complete (inadvisable on the JavaFX application thread, as that would hang your application until the load is complete), or be notified in a callback that the load is complete (which is what the listener you have is doing).

    (Probably) what you want to do

    Bind some property to the location property of the web engine. For example:

    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.scene.control.Label;
    import javafx.scene.layout.VBox;
    import javafx.scene.web.*;
    import javafx.stage.Stage;
    
    public class LocationViewer extends Application {
        @Override
        public void start(Stage stage) throws Exception {
            Label location = new Label();
    
            WebView webView = new WebView();
            WebEngine engine = webView.getEngine();
            engine.load("http://www.fxexperience.com");
    
            location.textProperty().bind(engine.locationProperty());
    
            Scene scene = new Scene(new VBox(10, location, webView));
            stage.setScene(scene);
            stage.show();
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
    

    The above code will update the location label whenever the location of the web view changes (try it by running the code then clicking on some links). If you wish to only update the label once a page has successfully loaded, then you need a listener based upon the WebView state, for example:

    import javafx.application.Application;
    import javafx.concurrent.Worker;
    import javafx.scene.Scene;
    import javafx.scene.control.Label;
    import javafx.scene.layout.VBox;
    import javafx.scene.web.*;
    import javafx.stage.Stage;
    
    public class LocationAfterLoadViewer extends Application {
        @Override
        public void start(Stage stage) throws Exception {
            Label location = new Label();
    
            WebView webView = new WebView();
            WebEngine engine = webView.getEngine();
            engine.load("http://www.fxexperience.com");
    
            engine.getLoadWorker().stateProperty().addListener((observable, oldValue, newValue) -> {
                if (Worker.State.SUCCEEDED.equals(newValue)) {
                    location.setText(engine.getLocation());
                }
            });
    
            Scene scene = new Scene(new VBox(10, location, webView));
            stage.setScene(scene);
            stage.show();
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
    

    If you run the last program and click on some links, you will notice it delays the updating of the location label until after the pages you click on completely finish loading, as opposed to the first program which updates the label as soon as the location changes, regardless of whether the load takes a while or indeed works at all.

    Answers to additional questions

    How can I use the url value in the label in a conditional statement? I want an action to be preformed if it changed from the original one.

    Let's assume that the Label you refer to is called location and the text property of the label is updated as per the information in the above answer (either by binding the label's text property to the WebView location property or by setting the label's text property on successful load of a new page in the WebView).

    Then you can listen to the text property of the label, and if it changes take the action you wish to perform:

    location.textProperty().addListener((observable, oldValue, newValue) -> {
        // perform required action.
    });
    

    Update 2023

    For most uses, I think the information in the original answer above still stands OK today and is sufficient for most purposes. This answer just adds some additional information to address Pablo's comment:

    the code above it is not a complete solution. The URL may change without the state property changing thanks to JavaScript and the HTML5 History feature.

    My tests, based on my understanding of the comment, show that if you listen to the WebEngine location property independent of monitoring WebEngine load state changes, then you will be notified of location manipulations via JavaScript history API calls.

    I believe what may be referred to here is what happens with the location in the following situation, the push state usage of the history API:

    But it could also be just standard navigation calls to the history object like back() or forward.

    WebView has a WebHistory Java object that provides insight into the navigation history for the current session. It is analogous to the history API in JavaScript/HTML and the Java object can be used to perform some of those functions, such as navigating the WebEngine back or forward through the history. The WebHistory API does not have a pushState() API like the JavaScript one, but you can call the JavaScript history API from Java to invoke it.

    So I put together a test harness to try all this out see how it behaves and interacts.

    test harness sample

    The test harness shows

    In the example screen shot, you can see that when pages are loaded in the engine the "Last visited" field in the history object is updated. But if the navigation didn't result in a load, it is not updated. An example is navigating to a fragment on a page or using the pushState() API.

    In such cases, if you monitor the location based on a successful page load, you won't receive a notification of location change at that time.

    However, if you listen for changes to the WebEngine's location property, manipulations of the WebEngine history even through JavaScript will be reflected in changes to the WebEngine's location property and you will be notified of those. This can be seen in the sample screenshot where the location property at the top of the screen is able to reflect the location set in JavaScript via a history pushState() call.

    Note that even though the location field at the top of the screen shows the pushState() result the WebEngine does not actually navigate to that URL on the pushState() call it is simply a way for the web applications to manipulate the history and url location information in the browser so that it can reflect an important web application state change (e.g page content updated via an Ajax call in a single page application, SPA). So, if want your Java application to be notified of such changes, then your Java application can do that by listening to the location property of the WebEngine.

    Test Harness Code

    import javafx.application.Application;
    import javafx.beans.binding.Bindings;
    import javafx.beans.property.ReadOnlyObjectWrapper;
    import javafx.geometry.Insets;
    import javafx.geometry.Pos;
    import javafx.scene.Scene;
    import javafx.scene.control.Button;
    import javafx.scene.control.Label;
    import javafx.scene.control.TableColumn;
    import javafx.scene.control.TableView;
    import javafx.scene.layout.HBox;
    import javafx.scene.layout.Pane;
    import javafx.scene.layout.Priority;
    import javafx.scene.layout.VBox;
    import javafx.scene.web.WebEngine;
    import javafx.scene.web.WebHistory;
    import javafx.scene.web.WebView;
    import javafx.stage.Stage;
    import org.jetbrains.annotations.NotNull;
    
    import java.util.Date;
    
    public class LocationWithHistoryViewer extends Application {
        @Override
        public void start(Stage stage) throws Exception {
            WebView webView = new WebView();
            WebEngine engine = webView.getEngine();
            engine.load("https://openjfx.io/javadoc/20/");
    
            HBox controls = createNavigationControls(engine);
            HistoryView historyView = new HistoryView(
                    engine.getHistory()
            );
    
            Scene scene = new Scene(new VBox(10, controls, webView, historyView));
            stage.setScene(scene);
            stage.show();
        }
    
        @NotNull
        private static HBox createNavigationControls(WebEngine engine) {
            WebHistory history = engine.getHistory();
    
            Button back = new Button("<");
            back.setOnAction(e -> {
                if (history.getCurrentIndex() > 0) {
    // history navigation can be performed via either the Java WebHistory object or JavaScript calls.
    // in either case the outcome is the same -> if the navigation results in a page load then
    // monitoring the location on a successful load state will pick up the location change,
    // if the navigation does not generate a page load, monitoring the location property
    // of the webview will pick up the navigation.
    //
    // Most history navigations will generate a page load,
    // with a counterexample being navigating forward to a location created by history.pushState.
    //                history.go(-1);
                    engine.executeScript("history.back()");
                }
            });
            back.disableProperty().bind(
                    history.currentIndexProperty().lessThan(1)
            );
    
            Button forward = new Button(">");
            forward.setOnAction(e -> {
                if (history.getCurrentIndex() < history.getEntries().size()) {
    //                history.go(+1);
                    engine.executeScript("history.forward()");
                }
            });
            forward.disableProperty().bind(
                    history.currentIndexProperty().isEqualTo(
                            Bindings.size(history.getEntries()).subtract(1)
                    )
            );
    
            // If you try to manipulate the url after navigate off of the openjfx.io site, it won't let you do that
            // you will receive:
            //   Exception in thread "JavaFX Application Thread" netscape.javascript.JSException:
            //     SecurityError: Blocked attempt to use history.pushState()
            //        to change session history URL from https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/RecursiveAction.html
            //        to https://openjfx.io/javadoc/20/javafx.web/javafx/scene/web/WebHistory.html#fake.
            //     Protocols, domains, ports, usernames, and passwords must match.
            // This is an expected cross-domain security enforcement system built into WebView.
            Button manipulateURL = new Button("Manipulate URL");
            final String MANIPULATE_SCRIPT = // language = javascript
                    """
                    document.title = "Manipulated title";
                    history.pushState({}, "", "https://openjfx.io/javadoc/20/javafx.web/javafx/scene/web/WebHistory.html#fake");
                    """;
            manipulateURL.setOnAction(e ->
                    engine.executeScript(MANIPULATE_SCRIPT)
            );
    
            Label location = new Label();
    
            Pane spacer = new Pane();
            HBox.setHgrow(spacer, Priority.ALWAYS);
    
            HBox controls = new HBox(10,
                    back,
                    forward,
                    location,
                    spacer,
                    manipulateURL
            );
            controls.setAlignment(Pos.BASELINE_LEFT);
            controls.setPadding(new Insets(3));
    
    // monitoring load state will not pick up history manipulations which do not generate page loads (e.g. history.pushState JavaScript calls).
    //        engine.getLoadWorker().stateProperty().addListener((observable, oldValue, newValue) -> {
    //            if (Worker.State.SUCCEEDED.equals(newValue)) {
    //                location.setText(engine.getLocation());
    //            }
    //        });
    
    // monitoring the location property will pick up history manipulations which do not generate page loads (e.g. history.pushState JavaScript calls).
            location.textProperty().bind(engine.locationProperty());
    
            return controls;
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
    
    class HistoryView extends TableView<WebHistory.Entry> {
        public HistoryView(WebHistory history) {
            setItems(history.getEntries());
    
            TableColumn<WebHistory.Entry, Date> lastVisitedColumn = new TableColumn<>("Last visited");
            lastVisitedColumn.setCellValueFactory(param ->
                    param.getValue().lastVisitedDateProperty()
            );
            lastVisitedColumn.setStyle("-fx-font-family: monospace;");
            lastVisitedColumn.setPrefWidth(240);
    
            TableColumn<WebHistory.Entry, String> titleColumn = new TableColumn<>("Title");
            titleColumn.setCellValueFactory(param ->
                    param.getValue().titleProperty()
            );
            titleColumn.setPrefWidth(300);
    
            TableColumn<WebHistory.Entry, String> urlColumn = new TableColumn<>("URL");
            urlColumn.setCellValueFactory(param ->
                    new ReadOnlyObjectWrapper<>(param.getValue().getUrl())
            );
            urlColumn.setPrefWidth(1_000);
    
            //noinspection unchecked
            getColumns().addAll(lastVisitedColumn, titleColumn, urlColumn);
        }
    }