Due to the fact that the custom component should host a visual information much larger than it is possible to show on most displays, therefore it is just evident that a kind of scroll pane be used. The already existing Swing counterpart is a scroll pane, with a column header (that's the date and time information) and a row header (those are the resources). So first I have checked the FX ScrollPane (javafx.scene.control.ScrollPane). It turned out soon that the FX version has no headers - neither column, nor row header. There must be a reason why, but I don't really care. I decided at the very beginning that I'll never criticise the JavaFX - I'll just try to use it. That's just a fact that there are no headers of the FX ScrollPane, so I have to consider another solution.
The JavaFX ScrollPane has no column or row header, it maintains only the viewport (called "content"), and the two scroll bars (both are optional, and may appear only on demand). The FX ScrollPane is not a panel at all - it is a specialized control instead. Therefore, its getChildren() method - which is intended to add components to a parent - is not visible, and provides a dedicated method (setContent) to add the only available child - the viewport.
Creating a compound window and using it as the content of the scroll pane is not a real solution. Theoretically, I might be possible that upon scrolling, the content of this compound window be adjusted so that the header parts remain fixed (always shown). However, that doesn't really seem to be a reliable, bulletproof solution, so I had to consider other approaches. Soon I turned to the GridPane (javafx.scene.layout.GridPane). It lays out its children in a Grid, by using rows and columns. The width of the columns and the height of the rows might differ from each other. It is easy to achieve that all components within the same column be sized to have the same width, and within the same row to have the same height. In fact, that's the default behaviour. So I might have the following layout of this GridPane:
|Header of the Row Header||The Column Header - that is, the Timeline view||Header of the Row Footer||Empty|
|The Row Header - that is, the resources the activities are assigned to.||The activities and the relations between the activities. The activities start at a specific point of time, end later, and they belong to resources.||The Row Footer - that is, the resources the activities are assigned to.||Main vertical scroll bar|
|Horitonzal scroll bar of the Row Header||Main horizontal scroll bar||Horizontal scroll bar of the Row Footer||Empty|
Therefore, the resources might be either repeated, or being able to display different set of information on the right side - that's the "Row Footer". It is also shown that the resource view might be wider than the available space - hence the horizontal scroll bars for the row header and footer. This is possible, because the optional header of the row header or the header of the row footer are both supposed to have the same width than their corresponding resource view. However, the height of the timeline view must be fixed, and it cannot be scrolled - that's because the height of the header's and footer's header might have different height than the timeline view has.
There are rules to keep too. The horizontal scroll bar of the Row Header must control the scrolling of the Row Header, and the Header of the Row Header - those two must be synchronized when scrolled. Similarly, the horitontal scroll bar of the Row Footer must control the scrolling of the Row Footer, and the Header of the Row Footer - their horizontal scrolling is again synchronized. The main horizontal scroll bar must control the horizontal scrolling of the main viewport, and the time line simultaneously. The main vertical scroll bar must control the vertical scroll of the Row Header, the Main Viewport, and the Row Footer. Due to the fact that it is possible to scroll by using alternative input methods (mouse wheel, touchpad edges, etc.), therefore it must be ensured that whenever a view scrolls, the corresponding associated other views should be scrolled just the same.
Conclusion: the custom component is a grid pane, with six ScrollPanes and four ScrollBars added. The height of the first row should come from the Timeline view (it is accepted to introduce a limitation that no header might be any taller).The visible and the full width of the Left Resource View should come from that view (the visible width is the width of the first column). The visible and the full width of the Right Resource View should come from that view (and the visible width is the width of the third column). The visible and full width of the second column should come from the main viewport, which is the Activity View. The visible and full height of the second row should come from the Left Resource Pane. It will be a recommendation that both the Activity View and the Right Resource View have the same full height - the custom component might or might not enforce this rule, it could be decided later. The width and height of the scroll bars shouldn't be set explicitely, just let it be set automatically.
In most of the cases, JavaFX provides a builder for most of the controls and other objects. In the above code sample, both the traditional way of using setter methods, and the new way of using builders is presented when creating the column constraints. It's perhaps a personal taste which one is used, I cannot see any advantage or disadvantage of using one way or the other.
Now I have the general layout. The next question is, what should be the content of those six scroll panes. The constraints are that although the resources forming a kind of list, which might be represented as a table, but the timeline is not really a horizontally laid out table (unless I am prepared to use a table with hundreds of thousands of columns). That's because the resolution of the time line is one minute, but the total length might be one full year, thus I have 366*24*60 columns - that's 527,040 columns. That's not very practical to use a table like that. Also, considering that this general tool must be able to support logistics, and one resource may have even more activities on a daily basis, therefore one row might contain few hundred activities. Adding the fact that there might be several hundreds of resources, it doesn't seem to be adequate to use Controls as the representation of activities. So having panels as contents of the scroll panes, and adding controls to those panels is not the way to go. Having many (even hundreds of) thousands of controls on a view is definitely not the way to go - usually, the controls are resources of the underlying system. I do not have the urge to check this assumption currently. I will give it a try later; perhaps Java FX uses the controls differently than all those GUIs I met before.
So for now, the decision I made was to use Canvases, and draw upon those Canvases directly. Then the resources, the timeline, and the activities are all just painted (rendered) shapes like rectangles. Therefore, the adventure will continue with using the Canvas.
part 4 - How to use Canvas
Putting together a JavaFX application is pretty simple. The main class must extend the javafx.application.Application. This main method of this class should call the "launch" static method by forwarding the command line parameters, and that's all. Your FX application starts in the "start" method of this class, which receives the reference to the primary stage (javafx.stage.Stage). The stage more or less corresponds to the Swing's JFrame. Next step is initializing a Scene (javafx.scene.Scene), and adding it to the Stage. The scene more or less similar than the root pane of the JFrame, only it is not created automatically, the user should create it and add it to the Stage. Further, the Scene may have no GlassPane added, it might contain only one content pane (called root pane).
The root pane added to the Scene must be a Parent (javafx.scene.Parent), which is the common ancestor of the Control, Group, and Region classes. The first one are the widgets with the capability of user interaction, and the later two resemble to the Panels of the Swing. Although adding one single Control might be useful in some cases (especially if that Control is complex like the TableView), yet in most of the cases, it is a Panel that is used. Initializing the Scene means adding the widgets (GUI controls) to this pane. In JavaFX, the widgets are represented by Node objects (javafx.scene.Node), which is the common ancestor of most of the GUI elements. The available controls are more or less the same than those available in Swing, but in JavaFX the basic 2D shapes are also Nodes, and might be added to the Scene or its root pane.
When one goes this far, one has to recognize the very first difference between the FX and the Swing. Swing uses one general purpose Panel, associated with layout managers responsible for positioning and sizing the panel and its component. There are but a few special panels, like the JScrollPane, and only in the cases when the view must support laying out of the components. JavaFX uses no layout managers - instead it defines several different panels, each has its own internal layout strategy. It's not that different though - when using Swing, in 99.99% of the cases I just create a JPanel instance with a specific layout manager, and won't change that layout manager later. So effectively, I create a specific panel and just use that.
Let's see where we are getting now. We have an empty window, decorated according to the underlying operation system's style.
The MyCustomNode will be the implementation of that visual table I am going to develop; the next post provides more details about it.
Therefore, and taking advantage of the fact that my employer needs an evaluation about a specific GUI custom component, I have decided to develop that custom component. This custom component is more or less a visual table. Such component might be useful for example in logistic and in scheduling needs. The horizontal axe is therefore the time, the vertical axe is the resources, and the table itself contains the activities. An activity always belongs to one resource, and it starts at a specific point of time, and ends at a later point of time. The activities are represented as rectangles, the resources as several rectangles in a row, and the timeline displays "areas" and "columns". Areas might be months, weeks, or days, and the areas contain the columns; the columns might be days, or hours.
Note: due to the fact that this custom component is (will be) a property of my employer, therefore I won't post many source codes. I will give enough to illustrate my point on stake, but I am sure noone will be able to put together the working custom component from these fragments. I also won't talk much about the JavaFX features covered by the several available tutorials; one has to go and read those, were he interested in such basic information. I will however give an attempt to describe the rough way that will or will not lead to the final goal in details. Hence the title, it's an adventure, I've entered into unknown territory and I have to discover it - just like the pioneers of the former centuries, I will make mistakes, hopefully will be able to recognize them and avoid them in the future. So if you notice or even suspect a trap I've just walked into, please don't hezitate to add your comment. I will investigate the case in details, and will post my findings.
I invite you to join my adventures - start watching this space, then you'll be notified about every change automatically. Currently I am going to post once a week, on the weekends.
Although it also has its own tricks that should be known, yet there are many examples out there providing scrollable JTable with frozen columns on the left. This article will briefly explain that too, but the aim is on showing how it is possible to add horizontal scrolling capabilities to those frozen columns.
To start with, just examine the panel providing scrolling support - JScrollPane. It contains the main viewport, which in turn contains the view - this latter is the graphical information we are going to display, but it is suspected that the screen is just not large enough to show it full.Therefore, there is a small sight (the viewport), and the large graphical view is moved under this sight so it is possible to see only a part of it. There are two scroll bars, a horizontal and a vertical one, which controls the movement of the view under the sight. It is possible to add a column header which appears right above the viewport, and a row header, which appears just left of the viewport. There are four corners which also could be populated by graphical components. The two headers are also viewports, because their content should scroll together with the main view. Synchronizing the positioning of these viewports and adjusting the scrollbars is the responsibility of the JScrollPane.
When a JTable is added to the JScrollPane, the table part appears in the main viewport, and the table header appears as the column header. That's done automatically, without any extra efforts. In fact, JTable's are almost always are added to scroll panes. If the presented table should have frozen columns, then those frozen columns should be added to the row header part of the scroll pane. Sounds simple as it is, yet there are some tricks that should be known. The process briefly is as follows.
- Create another JTable (the header table) which uses the same TableModel.
- It is likely to be wished that the auto resize mode of the main table be switched off (AUTO_RESIZE_OFF), but for the header table it might remain on. Further, the default size of the columns is usually too big, so it is likely that the preferred scrollable viewport size should be adjusted for the header table.
- Set the selection model of the header table to the selection model of the main table (they must share the selection model, then highlighting the rows will appear nicely).
- Retrieve the TableColumnModel of the main table, and remove those columns that will be frozen (the main table shouldn't present these columns).
- Retrieve the TableColumnModel of the header table, and remove all but the frozen columns.
- Optional: The default renderers of the main table might be set for the header table (and their header's default renderers migth be shared too).
- Optional: One might want to add a border to the header table (top, left, bottom is empty, but right is a few pixels thick), in order to more clearly separate the frozen part from the scrollable area.
- The header table should be added as the row header view to the JScrollPane.
- The header table's JTableHeader should be added as the upper left corner to the JScrollPane.
- Optional: Navigating with the keyboard opens a can of worms, so it might be solved. This is out of scope now, but consider that pressing the left arrow key when in the very first column of the main table should result of selecting the last column of the header table's same row (and pressing there the right arrow should bring back the selection to the main table). Further, when moving vertically with the up/down keys in the main table, the header will scroll - but moving vertically in the header table won't adjust the main table's position. This is a known bug (several years old now), and it is unlikely that it will be solved ever.
Now it's done, it works as expected. But suppose it is required to have several columns frozen, but it is not wished to waste too much space for them. In this case, one might want to implement a scrollable row header. Simply putting the header table into a scroll pane, then adding the scroll pane to a viewport is apparently a bad idea for two reasons. First, the scroll bar shouldn't appear in the row header, it should be levelled with the main table's horizontal scroll bar. Second, it's very difficult to implement because of the way how the preferred sizes are calculated. However, one might try to add the header table to a JScrollPane, then retrieve the column header of that JScrollPane and add it to the upper left corner of the main JScrollPane; retrieve the viewport and add it as row header; retrieve the horizontal scroll bar and add it to the lower left corner. Then that temporary scroll pane is no longer needed, it won't ever use any resources - yet the scroll bar and the two viewports are already bound, and horizontal scrolling works perfectly. (Apparently, the preferred scrollable viewport size should be set for the header table, and the auto resize mode should be switched off too.)
But the users want to use the scroll bars of the main table too. Whenever the position of the main view changes, the row header and the column header are repositioned too. The designers of the JScrollPane never thought that one day the row header will scroll horizontally, therefore when the row header is repositioned, the x coordinate is set to 0. The user scrolls the row header horizontally to see the columns he is interested in, then moves the main table, and the row header jumps back to its leftmost position. He will be frustrated soon. With listeners, this problem is almost impossible to be solved. One would attempt to introduce some difficult and complex state machine behaviour, remembering the x position of the header set last, and trying to somehow reset the x position. But of course, when the scroll pane resets the header's x position to zero, the corresponding horizontal scroll bar is also resetted. It is practically impossible, better don't even try.
There's another solution however. If it would be possible to achieve that the position setting call of these header viewports (the row header and its table header) ignores the horizontal setting and preserves the current position, then the problem would have been solved. The position of the views are set by calling the setViewPosition(Point) method of the JViewport. Therefore, it is possible to write a new class (extends JViewport), which rewrites this method by ignoring the x coordinate. Apparently, there should be another method that allows horizontal scrolling. The temporary JScrollPane must use these new classes instead of the JViewports, so creating the scroll pane is a bit more difficult now. Suppose the new class is named XViewport:
But this temporary scroll pane is not very useful any more! There's no way to tell what other class called the setViewPosition method, therefore the binding the temporary scroll pane would create is useless, the x coordinate won't be set ever (the temporary scroll pane doesn't know that our new method allowing horizontal scroll should be called instead). So it's enough to have the XViewport instances, and create the JScrollBar by hand, and populate the main scroll pane with these objects directly. That scroll bar should be listened too, and whenever the AdjustmentEvent occurs, the position of the views should be adjusted by using the new method allowing horizontal position change too. However, this behaviour is something that our new JViewport descendant class might (and should) support. Further, JScrollPane expects fixed size unscrollable components in the corners, therefore it never attempts to set its "view position" (it cannot too because the corners are populated by Component instances, not JViewport ones). So the table header might remain in a JViewport because it's position is not to be changed by other mechanisms, and our new XViewport might take care of its positioning too. Considering all these, that's the way this new class should be used:
Simple and straightforward, isn't it? Now the requirement is clear, we have the order, so let's deliver. As soon as this new class is implemented, the problem is solved. The declaration part and the accessor methods (and the mutator of the header) are simple. Please pardon me that I don't add the javadoc.
Yeah, I know that the constructor is rather unnecessary, but it's easier to debug the code this way (I might check when the class is instantiated), so it's just my behaviour to add the empty constructor. You don't have to like it, just bow to it for a while please. More important are the positioning methods:
The original (now overridden) method will preserve the current value of x, while the new method preserves the current value of y. This latter is not absolutely necessary; true that we are going to use it only for horizontal positioning, but one day we might need vertical scrolling in the column header. However, for simplicity reasons, let's just have it this way. Now we need the mutator method for the scroll bar:
The previous scroll bar (if existed) shouldn't be listened any more. Next is to prepare the scroll bar for use (adjustScrollBarRange method); the possible values should be between zero and the width of the view minus the extent size. This way it is very easy to position the view whenever the scroll bar's value is set. It is possible to synchronize the new scroll bar with the view now, and finally we might start to listen the scroll bar adjustment events. However, it is possible that the viewport is resized; ultimately it results a setBound call (well, this is not carved into stone, but that's what indeed happens), and then the scroll bar range should be recalculated. For the industry-strength application of this class, other resizing methods should be also overridden. This method is again very simple:
All that remains is the adjustment listener:
Now it's clear why the range of the scroll bar was set. This way, when the scroll bar is adjusted, the new value is the x coordinate of the view position. If the header exists, it's x position is also changed, while its original y position is preserved (I'm not going to do a similar mistake of supposing the y coordinate is always 0).
Now it works, although some homework still remains. If the user navigates horizontally in the header table by using the keyboard keys, the table won't scroll as the cell selection changes. That's because upon the table cell selection changes, when the JTable calls the scrollRectToVisible method, the now overridden setViewPosition method is called. Therefore, the new XViewport should override the scrollRectToVisible method and should call the proper setViewPosition method. That's not that hard, but it's out of scope now.