Making a browser in JavaFX: Lessons learned
Mon, Jan 15, 2024
7-minute read
The project for this week was to build our own custom browser in JavaFX.
Task:
Create a browser using JavaFX WebView. Start 10am Monday morning and hand it in by 4pm Thursday.
- Navigation
- text for URL and button to load websites
- Navigation buttons (e.g. back, forward, reload)
- Allow flexible user interaction (e.g. typing both “
www.google.com
” and “http://www.google.com
” works)
- Advanced website functionalities
- Menu (e.g. history, bookmarks, print, HTML source)
- Settings (e.g. setting home screen, colour theme, zoom)
- Tabbed user interface
Screenshots
New things I learnt
- JavaFX TableView - pretty painful to build all the columns
- A bit more about Maven and dependencies - using from inside IntelliJ IDEA
- JavaFX TabPanes - programatically opening and loading content into them
- JavaFX WebView and WebEngine
- Java Properties for storing my user settings in a text file
- I used a Trello kanban board to keep track of things
- I used the column headers: ToDo, In Progress, Bugs, Done but maybe using Not Started and Blocked columns might be better to have next time. There were so many features I thought I could add at the start, like Incognito and a custom app icon that just never got off the ground. Maybe a Wishlist column for things like that
- I kept everything in Git but there was so much happening in the set up that it was hard to
- Keep the
start()
/main()
class small and try to move things into new method if not new classes - while balancing the overhead of working in different files. - Also got a good reminder to (should have known) not split code up too soon - moving bookmark functionality into a new class made it hard to call the parent’s openNewTab() method - I ended up passing a reference to the method into the new class.
- I used the
Consumer<T>
functional interface to represent the method signature. - When calling the
renderBookmarks
method, pass the method reference.
- I used the
public void openNewBrowserTabWithURL(String url) {
// Logic to open a new browser tab with the given URL
}
// When calling the renderBookmarks method:
newTab.setContent(BookmarkManager.renderBookmarks(this::openNewBrowserTabWithURL));
and then in the BookmarkManager class:
public static VBox renderBookmarks(Consumer<String> openNewBrowserTabWithURL) {
// ... other code ...
// Use the passed method:
openNewBrowserTabWithURL.accept(rowData.getUrl());
// ... other code ...
}
- Scene Builder and Controllers were a bit of a disaster. I love the idea of having separate view files and controllers but passing messages around from one view to another and keeping track of everything with a controller/multiple controllers was really difficult and I ended up scraping it all at the end of Tuesday. Two days of slow and frustrating work thrown away.
Problems I ran into
- JavaFX stylesheets didn’t seem to be applied properly or not to the things I wanted them to. Apart from the strange syntax and keys that JavaFX wants like
-fx-text-fill: white;
which should turn the text white
Mistakes I made
- I jumped straight into a modular design without realising how complex it is to load controllers and work out where the logic is
- I should start with everything in the same file so I can get some momentum and then slowly extract things after that
- Jumping into FXML and Scene Builder without fully understanding how the Controllers and
@FXML
injection worked
Things I want to check out more in the future
- Observable lists
- Hav a play with DirectoryChooser, Drag and Drop, FlowPane, SplitPane, TitledPane, TilePane, TreeView, TreeTableView, the charts, Animation, Canvas, Concurrency, Toolbar, Tooltip, ProgressBar
- Try to use design patterns a bit more. I think I could have used the Observer Pattern to keep an eye on the current tab and change the window title
Thoughts
- There’s not a huge amount of quality resources on this stuff - mainly the Oracle JavaFX docs which are a bit of a slog.
- JavaFX is cool though and lets you get a lot done quickly. Once I start getting the hang of using controller files and FXML views, I will be flying with it.
- Setting a MacOS app icon was really hard to find. I ended up going down a route that involved installing
awt
and all kinds of things to my dependencies and I still couldn’t get it to work in the time. - I had a custom
BrowserPane
inside and customBrowserTab
class and the nesting got a bit complicated. I eventually had to draw it out on paper but I should have done that sooner. - I’m still struggling with all the IntelliJ shortcuts. I’m so used to the things I can do in VSCode and my VIM navigation shortcuts save a lot of time but it’s still slow for me.
- Getting up and running with a whole new GUI toolkit was a challenge. I didn’t even know different elements existed until towards the end. I’d like to have a way of surveying the lay of the land and at least learning what everything is.
- Didn’t get a chance to use Obervables this time but will have a play for the next project.
Links to good resources
- JenKov.com was amazing - https://jenkov.com/tutorials/javafx/tableview.html
- There’s a blog post on just about everything
Snippets
One-liner and utility method to make a 16x16px icon on a button
// Usage
backBtn.setGraphic(Utility.createIconButton("/icons/back_icon_64.png", 16, 16));
/**
* Utility method for adding an image button */
public static ImageView createIconButton(String iconPath, double width, double height) {
Image image = new Image(Objects.requireNonNull(Utility.class.getResourceAsStream(iconPath)));
ImageView imageView = new ImageView(image);
imageView.setFitWidth(width);
imageView.setFitHeight(height);
return imageView;
}
Make a table row clickable
/*
Makes the row clickable by setting a RowFactory on the table and adding a listener to each row with a lambda to listen for the double click and open a new tab
*/
tableView.setRowFactory(tv -> {
TableRow<Bookmark> row = new TableRow<>();
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2 && (!row.isEmpty())) {
Bookmark rowData = row.getItem();
System.out.println(rowData.getUrl());
openNewBrowserTabWithURL.accept(rowData.getUrl());
}
});
return row;
})
New Observable List
static ObservableList<Bookmark> bookmarks = FXCollections.observableArrayList();
Pretty-print a timestamp
/**
* Prints the timestamp in a nice format for the bookmarks table
*
* @return String The timestamp in the format dd/MM/yyyy HH:mm
*/
public String getTimestampString() {
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
return dtf.format(timestamp);
}
Operations on a WebView
WebView webview = new WebView();
WebEngine webEngine = webview.getEngine();
public void refresh() {
webEngine.reload();
}
public void zoomIn() {
webview.setZoom(webview.getZoom() + 0.1);
}
public void zoomOut() {
webview.setZoom(webview.getZoom() - 0.1);
}
public void resetZoom() {
webview.setZoom(1.0);
}
Basic URL handling
(WebView needs a full URL like http://google.com to work - google.com
is not enough)
urlField.setOnAction(e -> {
String urlString = urlField.getText();
if (!urlString.contains(".") || urlString.contains(" ")) {
urlString = searchURL + urlString;
}
if (urlString.startsWith("http://") || urlString.startsWith("https://")) {
// don't need to add anything
} else {
urlField.setText("https://" + urlString);
}
webEngine.load(urlString);
});
Save Properties
public static void saveSettings(String homepage, String searchEngine, boolean showHomeIcon) {
Properties properties = new Properties();
properties.setProperty("homepage", homepage);
properties.setProperty("searchEngine", searchEngine);
properties.setProperty("showHomeIcon", String.valueOf(showHomeIcon));
try (FileOutputStream out = new FileOutputStream(filePath)) {
properties.store(out, "Browser Settings");
} catch (IOException e) {
e.printStackTrace();
}
}
Load those properties
public static Properties loadSettings(String filePath) {
Properties properties = new Properties();
try (FileInputStream in = new FileInputStream(filePath)) {
properties.load(in);
} catch (IOException e) {
e.printStackTrace();
}
return properties;
}
Get one property
public static String getSearchEngine() {
Properties properties = loadSettings(filePath);
return properties.getProperty("searchEngine");
}
Save properties from a form
Button saveBtn = new Button("Save");
saveBtn.setOnAction(e -> {
Settings.saveSettings(
homePageTextField.getText(),
searchEngineComboBox.getValue(),
showHomePageCheckBox.isSelected()
);
});
Open a new browser tab second from the end
/**
* Open a new tab and select it */
* public void openNewBrowserTab() {
Tab newTab = new BrowserTab(null);
tabPane.getTabs().add(tabPane.getTabs().size() - 1, newTab);
tabPane.getSelectionModel().select(newTab);
}
Keyboard shortcuts
KeyCombination cmdW = new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN);
// Close the current tab
scene.getAccelerators().put(cmdW, () -> {
tabPane.getTabs().remove(tabPane.getSelectionModel().getSelectedItem());
});
Menu Bar
MenuBar menuBar = new MenuBar();
Menu appMenu = new Menu("BRWZR");
MenuItem about = new MenuItem("About");
MenuItem settings = new MenuItem("Settings ⌘ ,");
appMenu.getItems().addAll(about, new SeparatorMenuItem(), settings);
Menu fileMenu = new Menu("File");
MenuItem newTab = new MenuItem("New Tab ⌘ T");
MenuItem closeTab = new MenuItem("Close Tab ⌘ W");
MenuItem exit = new MenuItem("Exit");
newTab.setOnAction(e -> {
this.openNewBrowserTab();
});
closeTab.setOnAction(e -> {
tabPane.getTabs().remove(tabPane.getSelectionModel().getSelectedItem());
});
exit.setOnAction(e -> {
System.exit(0);
});
fileMenu.getItems().addAll(newTab, closeTab, new SeparatorMenuItem(), exit);
menuBar.getMenus().addAll(appMenu, fileMenu);
Set Style and Set ID
hbox.setStyle("-fx-background-color: #f00000;");
appMenu.setId("appMenu");
Add a CSS file to the scene
scene = new Scene(borderPane, 800, 600);
String css = this.getClass().getResource("/styles.css").toExternalForm();
scene.getStylesheets().add(css);
Make a new tab button that can’t be closed
Tab addTab = new Tab("+");
addTab.setClosable(false);
tabPane.getTabs().add(addTab);
// Listener to detect when the "+" tab is clicked
tabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
if (newTab == addTab) {
this.openNewBrowserTab();
}
});