Introduction

This article/project was inspired by this StackOverflow Question. The OP wanted help creating a “radial menu” for his application. If you’re not familiar with a radial menu, they’re commonly seen in video games (mostly because they work really well with joystick controllers) and they look like this:

Original Idea

This is the example linked to from the original question.

I’m not too sure how useful a radial menu is in a regular, business, application, but I thought it would make for an interesting project and as another example of how to create a custom control by extending Region.

Development Notes

This project didn’t turn out the way I had expected it to. Originally, I had planned to create the menu items as some sort of Pane turned into doughnut wedges via the setShape() function. However, they didn’t behave nicely, and that had to be abandoned.

My next attempt was to build the wedges as Shapes created from Paths, all built at the top of the menu and then rotated around the centre of the menu to their correct places. It would have been a win for the layout, as my intention was to create the Labels at the same time, and I wouldn’t need to worry about rotations when doing the layout.

While that worked better than using Panes, it had some problems. The biggest was that the aspect ratio of the monitor meant that the rated wedges didn’t connect back to make a circle. They connected nicely, but the complete shape was stretched in one dimension. Also, when scaled up during a “mouse” hover, they grew in funny directions, and not straight out from the centre - which was a problem.

Finally, I had no choice but to create each menu “wedge” in its correct place around the menu, and deal with the rotations of the Labels as part of their layout. The result is that the overall design really feels like a disconnected bunch of elements that happen to occupy the same space on the screen. That being said, it works well.

Before we look at any code, let’s take a look at what the final product should look like:

Jeopardy! Screen Shot

If you look closely, you can see that the menu item labelled, “This is the Sixth”, which has the mouse over it, is a bit bigger and brighter than the others. It’s also more opaque, but it’s hard to tell with the white background. It also has a fuzzy outline from the Bloom effect that’s been added. All of this is easier to see when you’re actually using the menu.

You can also see that the text is upside-down for the three items at the bottom of the menu. I decided to leave this way, as I felt that the added complexity of changing this outweighed the benefits. I was also tempted to find a way to have the text curve to follow along the outer radius - for the same reason.

Finally, some of the geometry gets a bit goofy if the menu only has two or three items, and graphics tend to drift towards the centre. Here again, the complexity needed to correct this outweighed the benefits - who needs a radial menu with two items?

Trigonometry and Path Shapes

This project is highly graphical, and drawing the shapes in the correct place is a big part of the coding.

Trigonometry

There’s no getting around it, for this project you’ll need to understand a little trigonometry. So, let’s jump right into that and get it out of the way:

Trigonometry

The whole menu looks like a doughnut, and throughout this article we’ll call the pieces of the doughnut that make up the menu items, “wedges”.

The math libraries, for the most part, deal with angles in radians, not degrees. Don’t freak out, a radian is just an amount and just as easy to deal with as degrees. In the same way that there are 360 degrees in a circle, there are 2π radians in a circle. That’s kind of a weird number, but it’s somewhere around 6, meaning that 1 radian is about 60 degrees - just so you can picture that in your head.

Obviously, the size of a wedge is dependent on the number of wedges that make up the menu. Looking at the diagram above, the total angular width of a wedge is 2θ (that’s the angle in the diagram). Since all of our trigonometry is based on that “theta” angle, that’s what we’re going to call it - “theta”.

The angle width of each wedge is 2π divided by the number of menu items (let’s call that numMenuItems), so 2π/numItems. Since θ is 1/2 of that, we can make it π/numMenuItems. We also want the first menu item to point straight up, and since straight up is 0 radians, we need to straddle 0 with our two θ angles. Looking at the diagram, the angles are -θ, and +θ. Going clockwise, the left side of the next wedge would be 1θ, and the right side would be 3θ. And so on.

Mathematically, if we (as programmers do) start counting at 0, then the left side of each wedge is ((2 * itemNumber) - 1) * θ and the right side is ((2 * itemNumber) + 1) * θ. So item number 0 gives -θ and +θ, and item number 2 gives θ and 3θ, and then 3θ and 5θ and so on.

Each corner has an X and Y coordinate based on which multiple of theta that it uses, and which radius it is located on. For X, it’s:

X = radius * sin(θ)

For Y, it’s:

Y = radius * cos(θ)

With θ determined by the formula from above.

Finally, using the standard JavaFX coordinate system, this would put the origin [0,0] in the top left corner of the window. This will result in 3/4 of our menu above or to the left of the window. So we’ll need to translate the whole thing down and to the right. How much? Just a bit more than outer radius. We can just add this offset to every X and Y that we calculate.

Dependencies

This is very important. The geometry of our menu is actually dependent on a very small number of things:

  1. Theta (θ) is dependent on the number of menu items. Nothing else, and we can derive it just from knowing how many menu items we have.

  2. The origin X and Y are entirely dependent on the outer radius.

  3. Every corner is dependent on either the inner or outer radius, the origin location, θ and which itemNumber it is.

We can store all of these dependencies as properties of our class:

private val menuItems = FXCollections.observableArrayList(menuItem1, menuItem2)
private val numItems = Bindings.createIntegerBinding({ menuItems.size }, menuItems)
private val theta: DoubleBinding = Bindings.createDoubleBinding({ PI / numItems.value }, numItems)
private val innerRadiusProperty: StyleableDoubleProperty =
    SimpleStyleableDoubleProperty(INNER_RADIUS_META_DATA, 60.0)
private val outerRadiusProperty: StyleableDoubleProperty =
    SimpleStyleableDoubleProperty(OUTER_RADIUS_META_DATA, 200.0)
private val originX = Bindings.createDoubleBinding({ outerRadiusProperty.value * 1.04 }, outerRadiusProperty)
private val originY = Bindings.createDoubleBinding({ outerRadiusProperty.value * 1.04 }, outerRadiusProperty)

The numItems Property is derived from the size ObservableList holding the menu items. Theta is a binding to that through a formula and the inner and outer radii are StyleableDoubleProperties. OriginX and originY are Bindings based on the outer radius.

Drawing Shapes

JavaFX has a Shape subclass called Path. Path is a closed shape that is constructed by stringing together a set of connected PathElements. Think of Path like drawing the shape with a pencil. You put the pencil down on the paper, then you move it along the paper to the next corner, and then the next and so on.

There’s a number of different PathElements in JavaFX, but we’re going to just use three of them: MoveTo, LineTo and ArcTo. We’ll use MoveTo to get the “pencil” in the right place to start drawing, then we’ll use ArcTo to draw the curves that run along the inner and outer circumfrences of the wedge, and LineTo to draw the straight sides. Actually, we won’t draw the left side because we’ll end at the fourth corner and JavaFX will automatically draw the line to our starting point and close the shape for us.

Our path is going to look like this:

  MoveTo the outer left corner
  ArcTo the outer right corner
  LineTo the inner right corner
  ArcTo the inner left corner
  Auto-close

But here’s the thing: Each of those PathElements is a JavaFX object of it own. This means that each one has Properties! ArcTo, for instance has properties for RadiusX and RadiusY (it allows of ellipse curves), and the destination X and Y. This means that each of these properties can be bound to the two or three Properties of our class that it depends on. Then if the number of menu items changes, or the inner or outer radius are changed, the shapes will automatically adapt and always be correct.

Let’s take a look at that:

private fun createShape(itemNumber: Int) = Path().apply {
    styleClass += "wedge"
    elements += MoveTo().apply {
        xProperty().bind(xBinding(theta.multiply((itemNumber * 2) - 1), outerRadiusProperty))
        yProperty().bind(yBinding(theta.multiply((itemNumber * 2) - 1), outerRadiusProperty))
    }
    elements += ArcTo().apply {
        radiusXProperty().bind(outerRadiusProperty)
        radiusYProperty().bind(outerRadiusProperty)
        isSweepFlag = true
        isLargeArcFlag = false
        xProperty().bind(xBinding(theta.multiply((itemNumber * 2) + 1), outerRadiusProperty))
        yProperty().bind(yBinding(theta.multiply((itemNumber * 2) + 1), outerRadiusProperty))
    }
    elements += LineTo().apply {
        xProperty().bind(xBinding(theta.multiply((itemNumber * 2) + 1), innerRadiusProperty))
        yProperty().bind(yBinding(theta.multiply((itemNumber * 2) + 1), innerRadiusProperty))
    }
    elements += ArcTo().apply {
        radiusXProperty().bind(innerRadiusProperty)
        radiusYProperty().bind(innerRadiusProperty)
        isSweepFlag = false
        xProperty().bind(xBinding(theta.multiply((itemNumber * 2) - 1), innerRadiusProperty))
        yProperty().bind(yBinding(theta.multiply((itemNumber * 2) - 1), innerRadiusProperty))
    }
}

The isSweepFlag and isLargeArcFlag determine which direction the curve of the arc goes, and whether the arc takes the shortest route between the two points or not. The JavaDocs are actually pretty good at describing this.

Menu Items

The Presentation Model for each menu item is the class RadialMenuModel:

class RadialMenuModel(
    initialText: String,
    initialGraphic: Node,
    val action: () -> Unit,
    isDisable: Boolean = false,
    val effect: Effect = Bloom(0.2)
) {
    val disable: BooleanProperty = SimpleBooleanProperty(isDisable)
    val text: StringProperty = SimpleStringProperty(initialText)
    val graphic: ObjectProperty<Node> = SimpleObjectProperty(initialGraphic)
}

We have a field for the text and graphic components of the Label that will appear in the menu wedge, a functional element equivalent to a Java Runnable for the menu action and then an Effect that will be used when the menu item is hovered over. This effect field has a default which is Bloom(0.2).

A new menu wedge can be created by instantiating a RadialMenuModel and then adding via the RadialMenu.addMenuItem() function.

The Labels

The contents of the menu wedges is just a Label. Here, it’s necessary to instantiate the Label and then translate it to the correct position and the use a Rotation transformation to keep it oriented downwards to the centre of the menu.

The tricky part here is that the placement of the items in JavaFX is done via the [0,0] location in the Node, which is the top-left corner. Because of the layout of the Label in this case, that’s the top left corner of the Text enclosed in it (or pretty close).

So, where does this top left corner go?

Well, it depends on the amount of Text, which will determine how wide the Label is. Essentially, we need to get coordinates of the centre angle of the wedge just a bit in from the outer radius, and then shift it counterclockwise by 1/2 the width of the Label. Everything is done in terms of bindings, too. It’s finicky, but the solution in the code seems to work well enough:

private fun createLabel(radialMenuModel: RadialMenuModel, itemNumber: Int) =
    labelOf(radialMenuModel.text, "contents", radialMenuModel.graphic).apply {
        isMouseTransparent = true
        maxWidthProperty().bind(xBinding(theta, outerRadiusProperty).subtract(originX).multiply(1.5))
        translateXProperty().bind(
            xBinding(
                theta.multiply(itemNumber * 2.0).subtract(widthProperty().divide(2).divide(outerRadiusProperty)),
                outerRadiusProperty.multiply(0.98)
            )
        )
        translateYProperty().bind(
            yBinding(
                theta.multiply(itemNumber * 2.0).subtract(widthProperty().divide(2).divide(outerRadiusProperty)),
                outerRadiusProperty.multiply((0.98))
            )
        )
        transforms += Rotate(((theta.value * itemNumber) / PI) * 360.0, 0.0, 0.0).apply {
            angleProperty().bind(
                Bindings.createDoubleBinding(
                    { (this@RadialMenu.theta.value * itemNumber * 360) / PI },
                    this@RadialMenu.theta
                )
            )
        }
    }

Disabling Menu Items

This was a happy surprise. I checked and found that the disable property is inherited from Node - way, way up the hierachy. This also means that both Group and Shape have a disable property. This was counter-intuitive to me. Disable makes sense for controls like Button or TextField, but not so much for Nodes like Shape. So I tried it.

I added a BooleanProperty called disable to RadialMenuItem and the bound the disable property of the Group that holds the wedge and the Label. I set up the sixth item to have its diable set to true. Bingo! It just worked. The wedge no longer responded to hover or mouse clicks. It also inherited some default styling from the Modena style sheet, which basically made it more transparent.

Styling

The idea for this project was to create a control which, for the programmer, has the same kind of interface as any other control. This means making its styling configurable via CSS, just like any other JavaFX Node. For the most part, this done by giving the menu and the menu items specific styleclass selectors. Which then allows standard JavaFX styling elements to be used.

There are two elements, the inner and outer radii, that need to be available via CSS, so these two parameters are implemented via StyleableDoubleProperty, which then needs to be configured to connect to the CSS. Let’s take a look at these. First, the Properties themselves:

private val innerRadiusProperty: StyleableDoubleProperty =
    SimpleStyleableDoubleProperty(INNER_RADIUS_META_DATA, 60.0)
private val outerRadiusProperty: StyleableDoubleProperty =
    SimpleStyleableDoubleProperty(OUTER_RADIUS_META_DATA, 200.0)

These are both straight-forward. They’re just regular Properties with tag for the CSS metadata that they link to.

And now the configuration stuff where that CSS metadata is defined:

companion object CssStuff {
    val OUTER_RADIUS_META_DATA: CssMetaData<RadialMenu, Number> =
        object : CssMetaData<RadialMenu, Number>("-rm-outer-radius", StyleConverter.getSizeConverter()) {
            override fun isSettable(styleable: RadialMenu) = !styleable.outerRadiusProperty.isBound
            override fun getStyleableProperty(styleable: RadialMenu) = styleable.outerRadiusProperty
        }
    val INNER_RADIUS_META_DATA: CssMetaData<RadialMenu, Number> =
        object : CssMetaData<RadialMenu, Number>("-rm-inner-radius", StyleConverter.getSizeConverter()) {
            override fun isSettable(styleable: RadialMenu) = !styleable.innerRadiusProperty.isBound
            override fun getStyleableProperty(styleable: RadialMenu) = styleable.innerRadiusProperty
        }
    private val cssMetaDataList =
        (Region.getClassCssMetaData() + OUTER_RADIUS_META_DATA + INNER_RADIUS_META_DATA) as MutableList
     fun getClassCssMetaData() = cssMetaDataList
    }
override fun getCssMetaData() = getClassCssMetaData()

This type of code is explained in detail in another article, you can read about it here.

In a nutshell, we’re instantiating two anonymous inner classes that extend CssMetaData and linking them to our two StyleableDoubleProperties. Then we add these two objects to the cssMetaDataList that comes from our parent class, which is Region. Finally, we provide the functions so that the JavaFX layout engine can find this metadata and connect it to the actual CSS files for the Scene.

This code is all pretty much boilerplate, but you should note that the two styleclass selectors that it creates are -rm-outer-radius and -rm-inner-radius. Those are the selectors that we’ll need to use in our stylesheet.

Styleclass Selectors and Colours

The entire Radial Menu is given the selector radial-menu.

Each menu item is given two selectors: radial-menu-item and radial-menu-item-{itemNumber}. So we have radial-menu-item-0 and radial-menu-item-1 and so on. The label in each item is given the selector: contents.

The central circle is given the selector centre. The Label in the centre is given the selector centre-contents. The central Circle and the Label aren’t contained in a group, so the Label selector isn’t nested inside the centre selector.

I checked the JavaFX source code for some routines like Color.saturate() to see how they work. Bascially, these routines convert the colours to HSB (Hue, Saturation, Brightness) and then manipulate the saturation and brightness elements of the colour. That gave me some insight into how to define the colours so that it was easy to come up with two versions of each colour - one for the “normal” status, and one for the “hover” status.

I came up with a pallet of complementary pastel colours and got their HSB definitions. I decided on a two stage approach to colour definition. First I would define them as “named” colours; one for normal and then a saturated version. Like this:

-brown-sugar: hsb(11, 42%, 54%);
-s-brown-sugar: hsb(11, 52%, 45%);

You can see that, in this case, the saturated version has the saturation parameter raised, and the brightness dropped, so it should be darker but more vibrant. Darker base colours need the brightness raised for the saturated version.

I think that the HSB definitions made it super easy to fine tune the colours once I saw them running in the application. I even adjusted the hue on a couple of them when I thought that they were too similar. It would have been way, way more troublesome having to adjust the RGB value in a hex definition of the colours.

And then I would assign them to “colour roles” with names that linked them to the RadialMenu. Like this:

-rm-colour-0: -brown-sugar;
-rm-saturate-0: -s-brown-sugar;

I used the “-rm” prefix to avoid collisions with the Modena namespace. This gave me two colours for each menu item.

To implement the colours, I used a standard “trick” from Modena:

.radial-menu-item .wedge {
  -fx-fill: -rm-fill-colour;
}

.radial-menu-item .wedge:hover {
  -rm-fill-colour: -rm-saturate-colour;
}

.radial-menu-item-0 {
  -rm-fill-colour: -rm-colour-0;
  -rm-saturate-colour: -rm-saturate-0;
}

(Note that this snippet just has the colour stuff, other styling info has been stripped out.)

The “trick” here is to define a standard colour, -rm-fill-colour, using it in the base radial-menu-item selector, then redefine it for the hover status. In this case, it’s a standard colour called -rm-saturate-colour. Default values for these colours are defined in the .root section. Finally, the standard definitions for these colours are overridden in the context of the specific menu items.

Using this technique, we don’t have to define two states, “normal” and “hover” for each menu item, which keeps the stylesheet a bit simpler.

The colour scheme can be completely changed without touching anything other than the definitions created in the .root section.

The Stylesheet

Here’ the whole stylesheet, so you can see how it hangs together:

.root {
  -brown-sugar: hsb(11, 42%, 54%);
  -s-brown-sugar: hsb(11, 52%, 45%);
  -sage: hsb(83, 12%, 55%);
  -s-sage: hsb(83, 23%, 35%);
  -air-force-blue: hsb(199, 20%, 53%);
  -s-air-force-blue: hsb(199, 35%, 45%);
  -burnt-sienna: hsb(19, 75%, 60%);
  -s-burnt-sienna: hsb(19, 85%, 45%);
  -space-cadet: hsb(227, 42%, 19%);
  -s-space-cadet: hsb(227, 65%, 40%);
  -lion: hsb(38, 47%, 57%);
  -s-lion: hsb(38, 65%, 45%);
  -feldgrau: hsb(159, 30%, 31%);
  -s-feldgrau: hsb(159, 40%, 31%);
  -rm-colour-0: -brown-sugar;
  -rm-saturate-0: -s-brown-sugar;
  -rm-colour-1: -sage;
  -rm-saturate-1: -s-sage;
  -rm-colour-2: -air-force-blue;
  -rm-saturate-2: -s-air-force-blue;
  -rm-colour-3: -burnt-sienna;
  -rm-saturate-3: -s-burnt-sienna;
  -rm-colour-4: -space-cadet;
  -rm-saturate-4: -s-space-cadet;
  -rm-colour-5: -lion;
  -rm-saturate-5: -s-lion;
  -rm-colour-c: -feldgrau;
  -rm-saturate-c: -s-feldgrau;
  -rm-fill-colour: -feldgrau;
  -rm-saturate-colour: -s-feldgrau;
}

.radial-menu {
  -rm-outer-radius: 200.0;
  -rm-inner-radius: 70.0;
}

.radial-menu .centre {
  -fx-fill: -rm-colour-c;
  -fx-opacity: 0.7;
}

.radial-menu .centre:hover {
  -fx-fill: -rm-saturate-c;
  -fx-opacity: 0.99;
  -fx-scale-x: 1.1;
  -fx-scale-y: 1.1;
}

.radial-menu-item {
  -fx-opacity: 0.85;
}

.radial-menu-item .contents {
  -fx-text-fill: ladder(-rm-fill-colour, BISQUE 34%, -space-cadet 35%);
  -fx-font-weight: bold;
  -fx-font-size: 16px;
  -fx-wrap-text: true;
  -fx-text-alignment: center;
  -fx-content-display: bottom;
}

.radial-menu-item .wedge {
  -fx-fill: -rm-fill-colour;
  -fx-stroke: derive(-rm-fill-colour, -40%);
  -fx-stroke-width: 3px;
}

.radial-menu-item .wedge:hover {
  -rm-fill-colour: -rm-saturate-colour;
  -fx-scale-x: 1.05;
  -fx-scale-y: 1.05;
  -fx-opacity: 1.0;
}

.radial-menu-item-0 {
  -rm-fill-colour: -rm-colour-0;
  -rm-saturate-colour: -rm-saturate-0;
}

.radial-menu-item-1 {
  -rm-fill-colour: -rm-colour-1;
  -rm-saturate-colour: -rm-saturate-1;
}

.radial-menu-item-2 {
  -rm-fill-colour: -rm-colour-2;
  -rm-saturate-colour: -rm-saturate-2;
}

.radial-menu-item-3 {
  -rm-fill-colour: -rm-colour-3;
  -rm-saturate-colour: -rm-saturate-3;
}

.radial-menu-item-4 {
  -rm-fill-colour: -rm-colour-4;
  -rm-saturate-colour: -rm-saturate-4;
}

.radial-menu-item-5 {
  -rm-fill-colour: -rm-colour-5;
  -rm-saturate-colour: -rm-saturate-5;
}

.radial-menu-item-6 {
  -rm-fill-colour: -rm-colour-6;
  -rm-saturate-colour: -rm-saturate-6;
}

.wrapper-region {
   -fx-background-colour: linear-gradient(to bottom, deepskyblue, khaki);
}

Putting it All Together

The centre section is pretty staight-forward, as there are no translations involving angles. Also, the centre isn’t put into a Group as there’s no need to handle toFront() for it.

Here’s the code for the RadialMenu all in one place:

class RadialMenu(
    menuItem1: RadialMenuModel,
    menuItem2: RadialMenuModel,
    centreMenuItem: RadialMenuModel,
    vararg additionalMenuItems: RadialMenuModel
) : Pane() {

    private val menuItems = FXCollections.observableArrayList(menuItem1, menuItem2)
    private val numItems = Bindings.createIntegerBinding({ menuItems.size }, menuItems)
    private val theta: DoubleBinding = Bindings.createDoubleBinding({ PI / numItems.value }, numItems)
    private val innerRadiusProperty: StyleableDoubleProperty =
        SimpleStyleableDoubleProperty(INNER_RADIUS_META_DATA, 60.0)
    private val outerRadiusProperty: StyleableDoubleProperty =
        SimpleStyleableDoubleProperty(OUTER_RADIUS_META_DATA, 200.0)
    private val originX = Bindings.createDoubleBinding({ outerRadiusProperty.value * 1.04 }, outerRadiusProperty)
    private val originY = Bindings.createDoubleBinding({ outerRadiusProperty.value * 1.04 }, outerRadiusProperty)

    companion object CssStuff {
        val OUTER_RADIUS_META_DATA: CssMetaData<RadialMenu, Number> =
            object : CssMetaData<RadialMenu, Number>("-rm-outer-radius", StyleConverter.getSizeConverter()) {
                override fun isSettable(styleable: RadialMenu) = !styleable.outerRadiusProperty.isBound
                override fun getStyleableProperty(styleable: RadialMenu) = styleable.outerRadiusProperty
            }
        val INNER_RADIUS_META_DATA: CssMetaData<RadialMenu, Number> =
            object : CssMetaData<RadialMenu, Number>("-rm-inner-radius", StyleConverter.getSizeConverter()) {
                override fun isSettable(styleable: RadialMenu) = !styleable.innerRadiusProperty.isBound
                override fun getStyleableProperty(styleable: RadialMenu) = styleable.innerRadiusProperty
            }
        private val cssMetaDataList =
            (Region.getClassCssMetaData() + OUTER_RADIUS_META_DATA + INNER_RADIUS_META_DATA) as MutableList

        fun getClassCssMetaData() = cssMetaDataList
    }

    override fun getCssMetaData() = getClassCssMetaData()

    fun outerRadiusProperty() = outerRadiusProperty
    fun innerRadiusProperty() = innerRadiusProperty
    var outerRadius: Double
        get() = outerRadiusProperty.get()
        set(value) = outerRadiusProperty.set(value)
    var innerRadius: Double
        get() = innerRadiusProperty.get()
        set(value) = innerRadiusProperty.set(value)

    init {
        styleClass += "radial-menu"
        children += createMenuItem(0, menuItem1)
        children += createMenuItem(1, menuItem2)
        children += createCentre(centreMenuItem)
        children += createCentreLabel(centreMenuItem)
        addMenuItem(*additionalMenuItems)
        minHeight = 2.08 * outerRadiusProperty.value
        minWidth = 2.08 * outerRadiusProperty.value
    }

    fun addMenuItem(vararg radialMenuModels: RadialMenuModel) {
        radialMenuModels.forEach { radialMenuItem ->
            menuItems += radialMenuItem
            children += createMenuItem(numItems.value - 1, radialMenuItem)
        }
    }

    private fun createCentre(radialMenuModel: RadialMenuModel) = Circle().apply {
        styleClass += "centre"
        centerXProperty().bind(originX)
        centerYProperty().bind(originX)
        radiusProperty().bind(innerRadiusProperty.subtract(15.0))
        onMouseClicked = EventHandler { radialMenuModel.action.invoke() }
        onMouseEntered = EventHandler { effect = radialMenuModel.effect }
        onMouseExited = EventHandler { effect = null }
    }

    private fun createCentreLabel(radialMenuModel: RadialMenuModel) =
        labelOf(radialMenuModel.text, "centre-contents", radialMenuModel.graphic).apply {
            this.translateXProperty().bind(originX.subtract(this.widthProperty().divide(2)))
            this.translateYProperty().bind(originY.subtract(this.heightProperty().divide(2)))
            isMouseTransparent = true
        }

    private fun createMenuItem(itemNumber: Int, radialMenuModel: RadialMenuModel) = Group().apply {
        val wedge = createShape(itemNumber)
        disableProperty().bind(radialMenuModel.disable)
        styleClass += "radial-menu-item"
        styleClass += "radial-menu-item-$itemNumber"
        maxWidth = outerRadiusProperty.value * 2.0
        children += wedge
        children += createLabel(radialMenuModel, itemNumber)
        wedge.onMouseEntered = EventHandler {
            toFront()
            wedge.effect = radialMenuModel.effect
        }
        wedge.onMouseExited = EventHandler { wedge.effect = null }
        wedge.onMouseClicked = EventHandler<MouseEvent> { radialMenuModel.action.invoke() }
    }

    private fun createLabel(radialMenuModel: RadialMenuModel, itemNumber: Int) =
        labelOf(radialMenuModel.text, "contents", radialMenuModel.graphic).apply {
            isMouseTransparent = true
            maxWidthProperty().bind(xBinding(theta, outerRadiusProperty).subtract(originX).multiply(1.5))
            translateXProperty().bind(
                xBinding(
                    theta.multiply(itemNumber * 2.0).subtract(widthProperty().divide(2).divide(outerRadiusProperty)),
                    outerRadiusProperty.multiply(0.98)
                )
            )
            translateYProperty().bind(
                yBinding(
                    theta.multiply(itemNumber * 2.0).subtract(widthProperty().divide(2).divide(outerRadiusProperty)),
                    outerRadiusProperty.multiply((0.98))
                )
            )
            transforms += Rotate(((theta.value * itemNumber) / PI) * 360.0, 0.0, 0.0).apply {
                angleProperty().bind(
                    Bindings.createDoubleBinding(
                        { (this@RadialMenu.theta.value * itemNumber * 360) / PI },
                        this@RadialMenu.theta
                    )
                )
            }
        }

    private fun getX(origin: Double, angle: Double, radius: Double) = origin + (radius * sin(angle))
    private fun getY(origin: Double, angle: Double, radius: Double) = origin - (radius * cos(angle))

    private fun xBinding(angle: ObservableDoubleValue, radius: ObservableDoubleValue) =
        Bindings.createDoubleBinding({ getX(originX.value, angle.get(), radius.get()) }, angle, radius, originX)

    private fun yBinding(angle: ObservableDoubleValue, radius: ObservableDoubleValue) =
        Bindings.createDoubleBinding({ getY(originY.value, angle.get(), radius.get()) }, angle, radius, originY)

    private fun createShape(itemNumber: Int) = Path().apply {
        styleClass += "wedge"
        elements += MoveTo().apply {
            xProperty().bind(xBinding(theta.multiply((itemNumber * 2) - 1), outerRadiusProperty))
            yProperty().bind(yBinding(theta.multiply((itemNumber * 2) - 1), outerRadiusProperty))
        }
        elements += ArcTo().apply {
            radiusXProperty().bind(outerRadiusProperty)
            radiusYProperty().bind(outerRadiusProperty)
            isSweepFlag = true
            isLargeArcFlag = false
            xProperty().bind(xBinding(theta.multiply((itemNumber * 2) + 1), outerRadiusProperty))
            yProperty().bind(yBinding(theta.multiply((itemNumber * 2) + 1), outerRadiusProperty))
        }
        elements += LineTo().apply {
            xProperty().bind(xBinding(theta.multiply((itemNumber * 2) + 1), innerRadiusProperty))
            yProperty().bind(yBinding(theta.multiply((itemNumber * 2) + 1), innerRadiusProperty))
        }
        elements += ArcTo().apply {
            radiusXProperty().bind(innerRadiusProperty)
            radiusYProperty().bind(innerRadiusProperty)
            isSweepFlag = false
            xProperty().bind(xBinding(theta.multiply((itemNumber * 2) - 1), innerRadiusProperty))
            yProperty().bind(yBinding(theta.multiply((itemNumber * 2) - 1), innerRadiusProperty))
        }
        toBack()
    }
}

The Application

Here’s the code for my sample application that draws the RadialMenu:

class RadialMenuApplication : Application() {
    override fun start(stage: Stage) {
        val scene = Scene(createContent { Platform.exit() }, 640.0, 500.0).apply {
            addStyleSheet("/css/RadialMenu.css")
            addWidgetStyles()
        }
        stage.title = "RadialMenu Demo"
        stage.scene = scene
        stage.show()
    }

    private fun createContent(exitAction: () -> Unit) = BorderPane().apply {
        val menu = RadialMenu(
            RadialMenuModel(
                "This is the first",
                FontIcon("captainicon-049:52:BISQUE"),
                { println("First item selected") }),
            RadialMenuModel(
                "This is the second item in the menu",
                FontIcon("captainicon-176:52:BISQUE"),
                { println("Second item selected") }),
            RadialMenuModel("Cut!", FontIcon("captainicon-238:52:BISQUE"), { exitAction.invoke() })
        )
        menu.addMenuItem(
            RadialMenuModel(
                "This is the third",
                FontIcon("captainicon-066:52:BISQUE"),
                { println("Third item selected") }),
            RadialMenuModel(
                "This is the Fourth",
                FontIcon("captainicon-039:52:BISQUE"),
                { println("Fourth item selected") }),
            RadialMenuModel(
                "This is the fifth",
                FontIcon("captainicon-154:52:BISQUE"),
                { println("Fifth item selected") }),
            RadialMenuModel(
                "This is the Sixth",
                FontIcon("captainicon-271:52:BISQUE"),
                { println("Sixth item selected") }, true
            )
        )
        center = menu
        padding = Insets(50.0)
        styleClass += "wrapper-region"
    }
}

fun main() {
    Application.launch(RadialMenuApplication::class.java)
}

I used the Ikonli package which gives excellent integration with FontAwesome for JavaFX. This gives FontIcon, which is an extension of Text that shows icons. There’s lots of good icons spread across several dozen icon packs made by various people. I used the Captionicon pack for this project.

Conclusion

I felt like this was a fun project, even if it isn’t really all that useful for a typical CRUD type business application. It’s a lot more about graphical drawing and layout than it is about functionality, but it has some interesting concepts that are worth learning about.

As an example of how to build a custom control by extending Region, I think it also has some merit. In fact, the extreme focus on the graphics shows that even this kind of approach can lend itself to creating a general purpose control that you can fit into any kind of application.

How to Implement the RadialMenu

This is different from most other kinds of controls because it’s intended to be superimposed over top of the other elements in the window, take over the screen and then disappear when an item is selected.

So, how do you do that?

My approach would be to put the main content pane of the application and the RadialMenu into a StackPane, with the RadialMenu added last and not visible. Whatever action in the main application would make the toggle the isVisible flag of the RadialMenu to true, and any action from the menu would toggle it back to false. You’d probably want to set the PickOnBounds property of the RadialMenu to true so that items under the RadialMenu in the main application screen are disabled.

Categories:

Updated: