søndag 28. oktober 2012

JavaFX WebView size trick


JavaFX comes with a web browser component called WebView. It is based on the open source WebKit browser, which bring tremendous power to Java.
But when you try to use it as a regular component like the Text and want to make it fit within the other components it can be quite a challenge to make it behave. Especially when you don't want to use it as a traditional "surfing the web" browser, but as a component to show more or less static HTML content.

Text will size out between min and max, and try to honor the preferred size and squeeze in where there is place. JavaFX will use its magic to make that happen. WebView is a shell that will kind of fit based on its parent, but the internal Webkit will size up based on its own calculation of the HTML/CSS/JS content, and a scrollbar will pop up when needed. The link between WebView and Webkit seems weird in light of how the rest of JavaFX works.

Lets make it behave, shall we?



Ok. My use case is this:
  • I  get simple HTML snippets from other parts of my code and I want to render it properly. 
  • The WebView (Browser)component should have the same width of the parent Node and follow that size when the window is resized. The content should word wrap instead of showing scrollbars. 
  •  The WebView (Browser)component  should grow its height downwards, but not take more space than necessary. 
  • When the content is replaced or changed, the height should resize to fit its new content.


First a quick code example. I wrap the WebView component inside a class I call Browser.


public class Browser extends Region {

 final WebView webview = new WebView();
    final WebEngine webEngine = webview.getEngine();
     
    public Browser( String content ) {
     ... 
        setContent( content );
    }
    
    public void setContent( final String content ) {
     ...
    }       
}

I will show several code snippets, so it make sense to create a class where I can put them.

The width part is simple.

widthProperty().addListener( new ChangeListener<Object>() {
    public void changed(ObservableValue<?> observable, Object oldValue, Object newValue) 
    { 
        Double width = (Double)newValue;     
        webview.setPrefWidth(width);
        adjustHeight();
    }
});


We want to us the Regions widthProperty, and put that into WebView to resize it. I have covered properties  in a separate post, so I don't have to go over it here :)
By attaching a listener to the Regions widthProperty, we can act on its changes, and inflict the width onto the WebView component. Nice and Simple. The adjustHeight() is explained later, but if the width is changed then you need to recalculate the height.

Unless you add HTML/CSS/JS content with fixed with, or other content that might force its way beyond your dictated width, then this will be sufficient, and the content will word wrap by itself, because that is default behavior in HTML.

Height is a much bigger problem. The WebView has an hard coded initial size of 800x600. This seems to transfer to Webkit which will happily add a lot of whitespace below. You can see this if you ask for the height of the HTML or body tag. The height of the content in Webkit is not transfered to the WebView. If it gets smaller, you get alot of whitespace, if it get bigger you get scrollbars. On top of this, you cannot ask the WebView or the WebEngine inside for the height of the content.


But there is a hack, or is it a trick?
WebView has a JavaScript engine ... and we can execute JavaScript from JavaFX code, and get a result back...

I found an interesting post at the JavaFX forum at Oracle where this trick where shown.

What about:
webEngine.executeScript("document.height")

Long story short. The document take on the height of the WebKit, which take on the height of the WebEngine... which is not what I wanted.

I did this:

My HTML content was not enclosed inside any body or div, so I made an helper function for that...

private String getHtml(String content) {
    return "<html><body>" +
           "<div id=\"mydiv\">" + content + "</div>" +
           "</body></html>";
}

Yes, I put my content inside a div which will squeeze around my content ... and you guess right. I ask the div for its height.

private void adjustHeight() {
    Platform.runLater(new Runnable(){
        @Override                                
        public void run() {
            try {
                Object result = webEngine.executeScript(
                    "document.getElementById('mydiv').offsetHeight");
                if(result instanceof Integer) {
                    Integer i = (Integer) result;
                    double height = new Double(i);
                    height = height + 20;
                    webview.setPrefHeight(height);
                }
            } catch (JSException e) {
                // not important
            } 
        }               
    });
}


Fantastic right? Notice the Platform.runLater() ... does it seem similar to SwingUtilities.invokeLater()? It is not the same, but the concept is the same. JavaFX run it its own thread, and some things are just better to do a bit later, else you get random weird results.

In order to resize when you change content, there is a possibility to listen for when the loading is done.

webview.getEngine().getLoadWorker().stateProperty().addListener(new ChangeListener<State>() {

    @Override
    public void changed(ObservableValue<? extends State> arg0, State oldState, State newState) {
        if (newState == State.SUCCEEDED) {
            adjustHeight();
        }    
    }
});

Easy peasy right?

I mentioned there was issues right? After adding logic for resizing WebView when you resize the window in order to avoid scrollbars, you might on rare occasions get one anyway. I don't know the logic behind this, but the scrollbar logic inside WebView is not exposed in normal ways.

In order to make sure the scrollbar stays hidden no matter what, we need to apply another trick. This trick I found on the StackOverflow:

// http://stackoverflow.com/questions/11206942/how-to-hide-scrollbars-in-the-javafx-webview
webview.getChildrenUnmodifiable().addListener(new ListChangeListener<Node>() {
    @Override public void onChanged(Change<? extends Node> change) {
        Set<Node> scrolls = webview.lookupAll(".scroll-bar");
        for (Node scroll : scrolls) {
            scroll.setVisible(false);
        }
    }
});

It is an odd way of getting the Scrollbars, but it works. In regular JavaFX this is much simpler. Its just the WebView component that is a weird piece of software.

Last weirdness is the loadContent method. The entire thing is unstable. Just by resizing the window you might experience that the WebView will add a lot of whitespace below the content. The code I will show tries to counter this odd behavior by doing a adjustHeight() with a runLater.

public void setContent( final String content )
{
    Platform.runLater(new Runnable(){
        @Override
        public void run() {
            webEngine.loadContent(getHtml(content));
            Platform.runLater(new Runnable(){
                @Override                                
                public void run() {
                    adjustHeight();
                }               
            });
        }               
    });
}

I cannot explain this properly. I thought that listening for the State.SUCCEEDED on the loadWorker would be sufficient to trigger resizing. But this is not good enough apparently. I had to put the adjustHeight into a  Platform.runLater. This would work 95% of times. Then I had to loadContent inside a Platform.runLater, and do a Platform.runLater for the adjustHeight after the content was loaded. As you can see, there is a lot of  Platform.runLater. Now it works - but I can't explain it well enough, because I don't have the source for JavaFX.

Most of my experience with JavaFX is ok. Its relatively easy to do what you want. WebView on the other hand was a little challenge.

Now the code supports changing height both when loading new content, and when resizing the window.

The code in this post can be found on github
https://github.com/frtj/javafx_examples

v1.0 28.okt.2012

4 kommentarer:

  1. Hmmm... interesting stuff. A good deep dive that shows a lot of tricks.

    I sort of got hooked on the webEngine.executeScript. You don't figure this could execute JavaScript tests in a JUnit test runner....?

    SvarSlett
  2. You might want to look at this:
    http://docs.oracle.com/javafx/2/api/javafx/scene/web/WebEngine.html


    The following example shows a callback that resizes a browser window:

    Stage stage;
    webEngine.setOnResized(
    new EventHandler>() {
    public void handle(WebEvent ev) {
    Rectangle2D r = ev.getData();
    stage.setWidth(r.getWidth());
    stage.setHeight(r.getHeight());
    }
    });

    SvarSlett
    Svar
    1. Sorry for answering late. It has been a busy year for me with new job.

      I cannot say for sure if I tried this when I worked with this a year ago. But i gave it a quick try now with Java 8 Lambda 106b. But I cannot make it work well because it doesn't seem to get invoked when you use webEngine.loadContent(someHtmlContent).

      It seems that the handle you set via setOnResized only get called when the javascript on the page is doing some adjustment to window size. But I haven't checked this out extencively so there might be something I have overlooked.

      Slett
  3. awesome! works perfectly for me. thanks!

    SvarSlett