New Custom Control: TaskProgressView

I have written a new custom control and commited it to the ControlsFX project. It is a highly specialized control for showing a list of background tasks, their current status and progress. This is actually the first control I have written for ControlsFX just for the fun of it, meaning I do not have a use case for it myself (but sure one will come eventually). The screenshot below shows the control in action.

task-monitor

If you are already familiar with the javafx.concurrent.Task class you will quickly grasp that the control shows the value of its title, message, and progress properties. But it also shows an icon, which is not covered by the Task API. I have added an optional graphics factory (a callback) that will be invoked for each task to lookup a graphic node that will be placed on the left-hand side of the list view cell that represents the task.

A video showing the control in action can be found here:

The Control

Since this control is rather simple I figured it would make sense to post the entire source code for it so that it can be used for others to study. The following listing shows the code of the control itself. As expected it extends the Control class and provides an observable list for the monitored tasks and an object property for the graphics factory (the callback).

package org.controlsfx.control;

import impl.org.controlsfx.skin.TaskProgressViewSkin;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.util.Callback;

/**
 * The task progress view is used to visualize the progress of long running
 * tasks. These tasks are created via the {@link Task} class. This view
 * manages a list of such tasks and displays each one of them with their
 * name, progress, and update messages.<p>
 * An optional graphic factory can be set to place a graphic in each row.
 * This allows the user to more easily distinguish between different types
 * of tasks.
 *
 * <h3>Screenshots</h3>
 * The picture below shows the default appearance of the task progress view
 * control:
 * <center><img src="task-monitor.png" /></center>
 *
 * <h3>Code Sample</h3>
 *
 * <pre>
 * TaskProgressView&lt;MyTask&gt; view = new TaskProgressView&lt;&gt;();
 * view.setGraphicFactory(task -> return new ImageView("db-access.png"));
 * view.getTasks().add(new MyTask());
 * </pre>
 */
public class TaskProgressView<T extends Task<?>> extends Control {

    /**
     * Constructs a new task progress view.
     */
    public TaskProgressView() {
        getStyleClass().add("task-progress-view");

        EventHandler<WorkerStateEvent> taskHandler = evt -> {
            if (evt.getEventType().equals(
                    WorkerStateEvent.WORKER_STATE_SUCCEEDED)
                    || evt.getEventType().equals(
                            WorkerStateEvent.WORKER_STATE_CANCELLED)
                    || evt.getEventType().equals(
                            WorkerStateEvent.WORKER_STATE_FAILED)) {
                getTasks().remove(evt.getSource());
            }
        };

        getTasks().addListener(new ListChangeListener<Task<?>>() {
            @Override
            public void onChanged(Change<? extends Task<?>> c) {
                while (c.next()) {
                    if (c.wasAdded()) {
                        for (Task<?> task : c.getAddedSubList()) {
                            task.addEventHandler(WorkerStateEvent.ANY,
                                    taskHandler);
                        }
                    } else if (c.wasRemoved()) {
                        for (Task<?> task : c.getAddedSubList()) {
                            task.removeEventHandler(WorkerStateEvent.ANY,
                                    taskHandler);
                        }
                    }
                }
            }
        });
    }

    @Override
    protected Skin<?> createDefaultSkin() {
        return new TaskProgressViewSkin<>(this);
    }

    private final ObservableList<T> tasks = FXCollections
            .observableArrayList();

    /**
     * Returns the list of tasks currently monitored by this view.
     *
     * @return the monitored tasks
     */
    public final ObservableList<T> getTasks() {
        return tasks;
    }

    private ObjectProperty<Callback<T, Node>> graphicFactory;

    /**
     * Returns the property used to store an optional callback for creating
     * custom graphics for each task.
     *
     * @return the graphic factory property
     */
    public final ObjectProperty<Callback<T, Node>> graphicFactoryProperty() {
        if (graphicFactory == null) {
            graphicFactory = new SimpleObjectProperty<Callback<T, Node>>(
                    this, "graphicFactory");
        }

        return graphicFactory;
    }

    /**
     * Returns the value of {@link #graphicFactoryProperty()}.
     *
     * @return the optional graphic factory
     */
    public final Callback<T, Node> getGraphicFactory() {
        return graphicFactory == null ? null : graphicFactory.get();
    }

    /**
     * Sets the value of {@link #graphicFactoryProperty()}.
     *
     * @param factory an optional graphic factory
     */
    public final void setGraphicFactory(Callback<T, Node> factory) {
        graphicFactoryProperty().set(factory);
    }

The Skin

As you might have expected the skin is using a ListView with a custom cell factory  to display the tasks.

package impl.org.controlsfx.skin;

import javafx.beans.binding.Bindings;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.util.Callback;

import org.controlsfx.control.TaskProgressView;

import com.sun.javafx.css.StyleManager;

public class TaskProgressViewSkin<T extends Task<?>> extends
        SkinBase<TaskProgressView<T>> {

    static {
        StyleManager.getInstance().addUserAgentStylesheet(
                TaskProgressView.class
                        .getResource("taskprogressview.css").toExternalForm()); //$NON-NLS-1$
    }

    public TaskProgressViewSkin(TaskProgressView<T> monitor) {
        super(monitor);

        BorderPane borderPane = new BorderPane();
        borderPane.getStyleClass().add("box");

        // list view
        ListView<T> listView = new ListView<>();
        listView.setPrefSize(500, 400);
        listView.setPlaceholder(new Label("No tasks running"));
        listView.setCellFactory(param -> new TaskCell());
        listView.setFocusTraversable(false);

        Bindings.bindContent(listView.getItems(), monitor.getTasks());
        borderPane.setCenter(listView);

        getChildren().add(listView);
    }

    class TaskCell extends ListCell<T> {
        private ProgressBar progressBar;
        private Label titleText;
        private Label messageText;
        private Button cancelButton;

        private T task;
        private BorderPane borderPane;

        public TaskCell() {
            titleText = new Label();
            titleText.getStyleClass().add("task-title");

            messageText = new Label();
            messageText.getStyleClass().add("task-message");

            progressBar = new ProgressBar();
            progressBar.setMaxWidth(Double.MAX_VALUE);
            progressBar.setMaxHeight(8);
            progressBar.getStyleClass().add("task-progress-bar");

            cancelButton = new Button("Cancel");
            cancelButton.getStyleClass().add("task-cancel-button");
            cancelButton.setTooltip(new Tooltip("Cancel Task"));
            cancelButton.setOnAction(evt -> {
                if (task != null) {
                    task.cancel();
                }
            });

            VBox vbox = new VBox();
            vbox.setSpacing(4);
            vbox.getChildren().add(titleText);
            vbox.getChildren().add(progressBar);
            vbox.getChildren().add(messageText);

            BorderPane.setAlignment(cancelButton, Pos.CENTER);
            BorderPane.setMargin(cancelButton, new Insets(0, 0, 0, 4));

            borderPane = new BorderPane();
            borderPane.setCenter(vbox);
            borderPane.setRight(cancelButton);
            setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
        }

        @Override
        public void updateIndex(int index) {
            super.updateIndex(index);

            /*
             * I have no idea why this is necessary but it won't work without
             * it. Shouldn't the updateItem method be enough?
             */
            if (index == -1) {
                setGraphic(null);
                getStyleClass().setAll("task-list-cell-empty");
            }
        }

        @Override
        protected void updateItem(T task, boolean empty) {
            super.updateItem(task, empty);

            this.task = task;

            if (empty || task == null) {
                getStyleClass().setAll("task-list-cell-empty");
                setGraphic(null);
            } else if (task != null) {
                getStyleClass().setAll("task-list-cell");
                progressBar.progressProperty().bind(task.progressProperty());
                titleText.textProperty().bind(task.titleProperty());
                messageText.textProperty().bind(task.messageProperty());
                cancelButton.disableProperty().bind(
                        Bindings.not(task.runningProperty()));

                Callback<T, Node> factory = getSkinnable().getGraphicFactory();
                if (factory != null) {
                    Node graphic = factory.call(task);
                    if (graphic != null) {
                        BorderPane.setAlignment(graphic, Pos.CENTER);
                        BorderPane.setMargin(graphic, new Insets(0, 4, 0, 0));
                        borderPane.setLeft(graphic);
                    }
                } else {
                	/*
                	 * Really needed. The application might have used a graphic
                	 * factory before and then disabled it. In this case the border
                	 * pane might still have an old graphic in the left position.
                	 */
                	borderPane.setLeft(null);
                }

                setGraphic(borderPane);
            }
        }
    }
}

The CSS

The stylesheet below makes sure we use a bold font for the task title, a smaller / thinner progress bar (without rounded corners), and list cells with a fade-in / fade-out divider line in their bottom position.

.task-progress-view  {
       -fx-background-color: white;
}

.task-progress-view > * > .label {
 	-fx-text-fill: gray;
 	-fx-font-size: 18.0;
 	-fx-alignment: center;
 	-fx-padding: 10.0 0.0 5.0 0.0;
}

.task-progress-view > * > .list-view  {
 	-fx-border-color: transparent;
 	-fx-background-color: transparent;
}

.task-title {
	-fx-font-weight: bold;
}

.task-progress-bar .bar {
	-fx-padding: 6px;
	-fx-background-radius: 0;
	-fx-border-radius: 0;
}

.task-progress-bar .track {
	-fx-background-radius: 0;
}

.task-message {
}

.task-list-cell {
    -fx-background-color: transparent;
    -fx-padding: 4 10 8 10;
    -fx-border-color: transparent transparent linear-gradient(from 0.0% 0.0% to 100.0% 100.0%, transparent, rgba(0.0,0.0,0.0,0.2), transparent) transparent;
}

.task-list-cell-empty {
	-fx-background-color: transparent;
    -fx-border-color: transparent;
}

.task-cancel-button {
	-fx-base: red;
	-fx-font-size: .75em;
	-fx-font-weight: bold;
	-fx-padding: 4px;
	-fx-border-radius: 0;
	-fx-background-radius: 0;
}

9 Comments

  1. May I ask why you get the added sublist, when an item is removed from the observable list? Shouldn’t you be getting he removed sublist? Also why do you attempt to remove the event handlers? Shouldn’t the event handlers get removed when the task object is garbage collected after it is removed from the list?

    I am asking because I need to make a more specialized version of this control. I need a download manager for my app that will download .txt files from the web. This does almost everything I need it to, however I need the tasks to stay in the list after the download is complete. The reason I want the task to stay in the list, is so that I can can change the button on the right to offer different options for the downloaded .txt file. For example, when the file is downloading from the internet, the button will say “cancel”. But after the file is downloaded, it will say “delete” to remove the downloaded file from the computer. And if the file is not on the computer yet, it will say “download” to begin the download.

    Also I am not sure you have any suggestions about the progress of downloading a file, but I am having a bunch of trouble getting progress information for the progress bar. I know that I can download a file by doing the following.

    URL copyFromURL = new URL(downloadLocation);
    ReadableByteChannel readableByteChannel = Channels.newChannel(copyFromURL.openStream());
    FileOutputStream fileOutputStream = new FileOutputStream(saveLocation);
    fileOutputStream.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE);

    However, how do I get progress information from this? Ill take anything I can get (number of bytes, number of line numbers etc…). The only thing I can think of so far is to do a single pass on the .txt file using a bufferedreader or something and count the number of lines. Then read the lines in one by one and update the progress bar on each line. Do you know of any better ways. I can assume the thing I am downloading is a .txt file (if that helps).

    Thanks for any help.

  2. Good catch: yes, there is a bug. I should of course get the removed items / removed sublist. And I am removing the event handler because these are strong references. Without this the task will not get garbage collected.

  3. Regarding the progress information: I really do not know how this is typically done for text files. But isn’t there a way to ask an HttpURLConnection for the resource size?

  4. I have looked at the URLConnection’s getContentLength() method, however I guess it is not always reliable. People have said that it can get in a “chunked” transfer mode, which means its length is unknown.

    This is the discussion I read: http://stackoverflow.com/questions/263013/java-urlconnection-how-can-i-find-out-the-size-of-a-web-file

    I am a little bit out of my element with this one. I wish there was a library or something that would let me download a resource from http and get progress information reliably. But I guess for right now I’ll just have to do two passes through the .txt files.

  5. Hello,

    Could you please let me know if its possible to hide the Cancel button in TaskProgressView control ?
    I see that its bind to the running property of task but wanted to check …
    in case i have a task which cannot be cancelled how can i handle this scenario ?

    Any help, highly appreciated.

  6. Hiding the cancel button is currently not supported. If all of your tasks are non-cancellable then you might want to try “styling it away”. The style class is called “task-cancel-button”. But this really only works if none of your tasks are cancellable.

    I would strongly suggest that you a) submit a JIRA ticket to the ControlsFX project and b) work on a solution on the ControlsFX code and submit a pull request.

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 )

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