Using a JavaFX WebView to display basic HTML content

A common requirement in modern User Interfaces is to display basic Rich Text/HTML descriptive content to the user. For example you might want to bold part of a piece of text to highlight something for the user or maybe display a bulleted list of items.

Unfortunately, unlike Java Swing, standard JavaFX text components (TextField and TextArea) don’t support this type of basic HTML rendering. The TextFlow component was added in JavaFX 8 and while you can do simple formatting easily, trying to render block level elements like “ul” is not easy, TextFlow also does behave well with nested TextFlow components.

The excellent RichTextFX (not part of the standard JavaFX release) component is an option but it doesn’t support HTML (of any kind) out of the box.

JavaFX does have modern HTML/CSS support via the use of the WebView component, which itself uses WebKit under the hood. But there are a number of issues with WebView that make it difficult to use to display HTML content as if it were just another piece of text in your interface. Luckily for you (well mostly me) I’ve solved these issues.

Let’s cut to the chase, the code

Here’s the code I’m using. Note, I’d recommend you read the explanations after the code which describe the problems that had to be overcome and why the solutions/approaches are there. That way if you hit the problems described then you’ll where to start looking for a fix.

import java.util.*;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Worker.State;
import javafx.geometry.HPos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.scene.effect.BlendMode;
import javafx.scene.paint.*;
import netscape.javascript.*;

public class WebViewFitContent extends Region 
{
    final WebView webview = new WebView ();
    final WebEngine webEngine = webview.getEngine ();
    private double currHeight = 100;
    private Label label = new Label ();
    private String content = null;
    private String divId = UUID.randomUUID ().toString ();

    public WebViewFitContent (String content) 
    {
        this.webview.setPrefHeight(1);
        this.content = content;
        this.getStyleClass ().add ("qwebview");
        this.label.setVisible (false);
        this.label.fontProperty ().addListener ((pr, oldv, newv) ->
        {
            this.setContent (content);
        });

        this.label.textFillProperty ().addListener ((pr, oldv, newv) ->
        {
            this.setContent (content);
        });

        this.backgroundProperty ().addListener ((pr, oldv, newv) ->
        {
            this.setContent (content);
        });

        this.widthProperty ().addListener ((pr, oldv, newv) ->
        {
            this.webview.setPrefWidth (newv.doubleValue ());
            this.adjustHeight ();
        });

        this.webEngine.getLoadWorker ().stateProperty ().addListener ((pr, oldv, newv) ->
        {
            if (newv == State.SUCCEEDED)
            {
                adjustHeight ();
            }
        });

        this.webview.getChildrenUnmodifiable ().addListener ((ListChangeListener<Node>) ev ->
        {
            this.webview.lookupAll (".scroll-bar").stream ()
                .forEach (n -> n.setVisible (false));
        });

        webview.setOnScroll (ev ->
        {
            this.getParent ().fireEvent (ev);
            ev.consume ();
        });

        this.setContent (content);
        this.getChildren ().add (this.webview);
        this.getChildren ().add (this.label);
    }

    public void setContent(String content)
    {
        StringBuilder b = new StringBuilder ();
        b.append ("<html><head>");
        b.append ("<style>");
        b.append ("html, body{padding: 0px; margin: 0px; offset-x: hidden; offset-y: hidden;}");
        b.append (String.format ("body{background-color: %1$s;}",
                                 this.getBackgroundAsCssString (this.getBackground ())));
        b.append (String.format ("body{font-size: %1$spx; font-family: %2$s; color: %3$s;}",
                                 this.label.getFont ().getSize () + "",
                                 this.label.getFont ().getFamily (),
                                 this.getPaintAsCssString (this.label.getTextFill ())));
        b.append ("</style>");

        b.append ("<script>");
        b.append ("function noScroll(){window.scrollTo(0,0);}window.addEventListener('scroll', noScroll);");
        b.append ("</script>");

        b.append ("</head");

        b.append (String.format ("<body><div id='%1$s'>",
                                 divId));
        b.append (content);

        b.append ("</div></body></html>");

        webEngine.loadContent (b.toString ());
    }

    @Override
    protected double computeMinHeight (double width)
    {
        return this.currHeight;
    }

    @Override
    protected double computePrefHeight (double width)
    {
        return this.currHeight;
    }

    @Override
    protected void layoutChildren()
    {
        this.layoutInArea (this.webview,0,0,this.getWidth (), this.getHeight (),0, HPos.CENTER, VPos.CENTER);
        this.layoutInArea (this.label, 0,0,0,0,0,HPos.CENTER,VPos.CENTER);
    }

    private void adjustHeight ()
    {
        Platform.runLater (() ->
        {
            try
            {
                // The document can sometimes be null, usually when the change is the result of a parent width change.
                if (this.webEngine.getDocument () == null)
                {
                    return;
                }

                Object result = this.webEngine.executeScript (String.format ("document.getElementById('%1$s').offsetHeight",
                                                                             divId));

                if (result instanceof Integer)
                {
                    Integer i = (Integer) result;
                    double height = i.doubleValue ();

                    // This check ensures that we don't get into a weird loop where the view is constantly resizing.
                    if (height != this.currHeight)
                    {
                        this.currHeight = height;
                        this.webview.setPrefHeight (height);
                        this.webview.requestLayout ();
                    }
                }

            } catch (Exception e) {
                // You should do something about this!
                e.printStackTrace ();
            }
        });
    }

    private String getPaintAsCssString (Paint p)
    {
        if (p instanceof Color)
        {
            Color c = (Color) p;
            return String.format( "#%02X%02X%02X",
                        (int)( c.getRed () * 255 ),
                        (int)( c.getGreen () * 255 ),
                        (int)( c.getBlue () * 255 ) );

        }
        return "#000000";
    }

    private String getBackgroundAsCssString (Background bg)
    {
        if (bg != null)
        {
            List<BackgroundFill> fills = bg.getFills ();
            if (fills != null)
            {
                return this.getPaintAsCssString (fills.get (0).getFill ());
            }
        }
        return "#ffffff";
    }
}

Preferred size

The first issue with WebView is that it has its own scroll pane and so its preferred size is the size of the parent it is in. In many cases this is undesirable since it can introduce a scroll pane into the UI and hides some of the content. We want the WebView to be the same size (height) as the content it displays.

Frode Johansen mostly solved this problem long ago with his WebViewFitContent class, a post about it can be found at: https://tech.chitgoks.com/2014/09/13/how-to-fit-webview-height-based-on-its-content-in-java-fx-2-2/

When overriding Region.layoutChildren, however, you also need to override Region.computePrefHeight and Region.computeMinHeight if you want the component to behave properly with scroll panes and other components. In this case we don’t have direct access to the height since the content is loaded asynchronously, so in adjustHeight the current height value is saved away and then returned in computeMinHeight/computePrefHeight.

Scrolling

Since WebView has its own scroll pane we need to disable it AND also ensure that scrolling events are communicated to our parent node. WebView will consume any scroll events it receives.

The first part is to add a scroll listener to the WebView then refire events to the parent.

webview.setOnScroll (ev ->
{
   this.getParent ().fireEvent (ev);
   ev.consume ();
});

The second part is to prevent scrolling in the web page displayed itself. To do this we add a javascript function and add it to the header of the content.

b.append ("<script>function noScroll(){window.scrollTo(0,0);}window.addEventListener('scroll',noScroll);</script>");

This adds the function “noScroll” to the window and a scroll listener. The noScroll function scrolls the content back to the top (coords 0,0) when a scroll event occurs.

Credit goes to David Wells for this one, see: https://davidwells.io/snippets/disable-scrolling-with-javascript

Font size, family and color

The WebView will use its own default stylesheet for displaying content which, in general, doesn’t use the same font family or size that is used in the UI generally. The default seems to be Times New Roman (or whatever the generic CSS name is).

To make the WebView content look the same as a regular text component in the UI we need to get font/color information from a text component. We then track the font/color information and update the view as needed. After all, JavaFX is CSS based and the stylesheet may change while the UI is running.

So a Label is created and its properties tracked:

this.label = new Label ();
this.label.setVisible (false);
this.label.fontProperty ().addListener ((pr, oldv, newv) ->
{
    this.setContent (content);
});
this.label.textFillProperty ().addListener ((pr, oldv, newv) ->
{
    this.setContent (content);
});

We also need to layout this label so that the CSS in the stylesheet is applied to it, in layoutChildren we do:

this.layoutInArea (this.label, 0,0,0,0,0, HPos.CENTER, VPos.CENTER);

This lays out the label but gives it a zero width and zero height, it’s there but will never be seen.

Next we need to apply those font/color properties to the stylesheet in the HTML content.

To do this we add:

b.append (String.format ("body{font-size: %1$spx; font-family: %2$s; color: %3$s;}",
    this.label.getFont ().getSize () + "",
    this.label.getFont ().getFamily (),
    this.getPaintAsCssString (this.label.getTextFill ())));

private String getPaintAsCssString (Paint p)
{
   if (p instanceof Color)
   {
       Color c = (Color) p;
       return String.format( "#%02X%02X%02X", (int)(c.getRed () * 255 ), (int)(c.getGreen () * 255 ), (int)(c.getBlue () * 255 ) );
   }
   return "#000000";
}

This sets the font family, font size and text color to be the same as the label, when the label properties get updated the content is updated.

The background color

It would be desirable to make the background color of the web page displayed in the WebView transparent but there is no standard way to do this. I did find a way to do it via reflection but this is on an undocumented com.sun API and so it’s a very bad idea to do it (not least because you also have to open up the module).

So you’ll need to add your own method of tracking the background color, either pass a property to the custom web view component and add a listener or have a method you can call to set it when it needs to change.

The best I came up with is to track the background color of the WebViewFitContent region and then ensure your stylesheet sets that color. i.e.

this.getStyleClass ().add ("qwebview");
this.backgroundProperty ().addListener ((pr, oldv, newv) ->
{
    this.setContent (content);
});

// Update the stylesheet for the webview content.
b.append (String.format ("body{background-color: %1$s;}",
                         this.getBackgroundAsCssString (this.getBackground ())));

// This method could obviously do more and check for images/multiple fills etc 
// but if you are using images as backgrounds for text or multiple fills/gradients 
// then you probably shouldn't be designing UIs anyway!
private String getBackgroundAsCssString (Background bg)
{
    if (bg != null)
    {
        List<BackgroundFill> fills = bg.getFills ();
        if (fills != null)
        {
            return this.getPaintAsCssString (fills.get (0).getFill ());
        }
    }
    return "#ffffff";
}

// And then add this to your stylesheet
.qwebview
{
    -fx-background-color: #ffffff;
}

I use this technique for handling a “night mode” for my application but it’s equally relevant for user defined stylesheets.

So for night mode the css would look like:

:night .qwebview
{
    -fx-background-color: #333333;
}

Other tweaks

I have made a number of other tweaks to Johansen’s original code, most notably using lambdas to reduce the dreaded boilerplate and to use a unique ID for the div that wraps the content. I also change the padding and margins of the body element to be zero.

Credits

My thanks go to: Frode Johansen, David Wells and jewelsea on stackoverflow for doing the heavy lifting on this problem.

https://stackoverflow.com/a/57827593/1784362

https://davidwells.io/snippets/disable-scrolling-with-javascript

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: