It gives me great pleasure to announce the next major release of Radiance. Let’s get to what’s been fixed, and what’s been added. First, I’m going to use emojis to mark different parts of it like this:
💔 marks an incompatible API / binary change
🎁 marks new features
🔧 marks bug fixes and general improvements
Dependencies for core libraries
- Gradle: 7.2 ➡ 7.5.1
- Kotlin: 1.5.31 ➡ 1.7.10
- Kotlin coroutines: 1.5.2 ➡ 1.6.4
General
- 🔧💔 A new direct rendering model for all core and custom components in Radiance
- Instead of rendering components as multi-layer combinations of cached offscreen images, Radiance now uses direct rendering to the
Graphics
objects passed to the relevant UI delegates and painting methods
- Use
RadianceCommonCortex.paintAtScale1x
for visuals that need to “fall” on exact pixels, line single-pixel borders, separators, etc
- 🔧 Remove all usages of java.security APIs (that are deprecated in Java 17 going forward)
Animation
- 🎁 New default animation pulse source that is based on the display refresh rate
Component
- 🎁💔 Unify fire action trigger logic for command buttons by replacing
CommandButtonPresentationModel.isFireActionOnRollover
and CommandButtonPresentationModel.isFireActionOnPress
with a single actionFireTrigger
enum that has three values:
OnRollover
to fire action on rollover
OnPressed
to fire action on press
OnPressReleased
to fire action on press release (the default)
- 🎁💔 Unify text action/popup click logic for command buttons by replacing
CommandButtonPresentationModel.isTextClickAction
and CommandButtonPresentationModel.isTextClickPopup
with a single textClick
enum field that has two values:
Action
to activate action on text click
Popup
to activate secondary content on text click
- 🎁💔 Revisit breadcrumb bar APIs
- Remove exception propagation APIs (they were no-op in any case since it wasn’t wired)
- Remove index tracking in
BreadcrumbItem
(not wired to anything)
- Switch
BreadcrumbBarCallBack
APIs from StringValuePair
to BreadcrumbItem
- Also rename
getLeafs
to getLeaves
- Rename
BreadcrumbBarCallBack
to BreadcrumbBarContentProvider`
- Rename
BreadcrumbBarModel
to BreadcrumbBarContentModel
- Add
BreadcrumbBarPresentationModel
and support icon filtering
- Remove
StringValuePair
from the API surface altogether
- Revisit the API surface of
BreadcrumbItem
- 🎁💔 Switch presentation models to use
BackgroundAppearanceStrategy
across all components. This applies to
CommandButtonPresentationModel.setFlat
CommandButtonPresentationModel.Overlay.setFlat
CommandStripPresentationModel.setFlat
CommandPresentationModel.setFlat
- 🎁 Add single row resize policy to ribbon flow bands
- 🔧 Fix lost breadcrumb bar path after skin change
- 🔧 Fix separator drawing over the last text character in
MEDIUM
command buttons that don’t display icons
- 🔧 Command menus now toggle open and close on clicks
- 🔧 Fix issues with command popup menus not closing in certain scenarios
Theming
- 💔 Simplified visuals of tabbed panes
- Remove
SINGLE_FULL and DOUBLE_FULL
from TabContentPaneBorderKind
. Apps that wish to draw border around the content area will need to do so explicitly.
- Remove
RadianceSkin.setTabFadeStart
and RadianceSkin.setTabFadeEnd
and do consistent indication for the selected / rollover tab with no alpha fade gradient.
- Consistent corner radius of tabs across all skins.
- 💔 Clean up the signature of fill painters, removing
isFocused
(not used anywhere, and shouldn’t be since the focus indication is painted separately) and hasShine
(specific to StandardFillPainter
visuals).
- 🔧 Fix issues with various color chooser panels, including the correct wiring of the “Reset” button across all the panels
- 🔧 Fix incorrect bounds of maximized decorated frames on Windows
- 🔧 Fix inverted logic of
ComponentOrParentChainScope.setExtraWidgetsPresence
- 🔧 Fix null pointer exception in rollover button listeners
SVG transcoder
- 🔧 Simplify generated code by not emitting identity affine transforms
- 💔 Remove plain templates
As always, I’d love for you to take this Radiance release for a spin. Click here to get the instructions on how to add Radiance to your builds. And don’t forget that all of the modules require Java 9 to build and run.
And now for the next big thing or two.
This release took almost a year to complete. I needed this time to figure out how to continue evolving Radiance in a meaningful way over the next decade or so. The considerations for what went into this work were laid out last October in this post. The two major areas I wanted to focus on are direct rendering and API consistency.
Direct rendering has touched the UI delegates for every single core Swing component, and almost every custom Radiance component, from command buttons all the way up to the ribbon. API consistency has been driven by the ongoing work in Aurora, as well as the drive to clean up the API surfaces that have been misaligned across the codebase for a while.
Making meaningful changes also means making hard choices about backwards compatibility. Deprecating existing APIs but leaving them available leads to a confusing API surface and increases the cost of maintaining and evolving the codebase. Leaving existing APIs in place, and trying to redirect them under the hood to a “v2” variant places noticeable constraints on what is feasible to do. If I want Radiance to be here in the next 10-15 years, the only practical way forward is to cut out APIs that have not aged well, remove them from the codebase and introduce new ones as necessary. I understand that it causes friction during dependency upgrades on the application side of things, but the only other alternative is abandoning any new development altogether.
With all this in mind, what is next, for 2023 and beyond?
The first major change in Radiance is going to be around defining and using colors. Code-named Chroma, this effort aims to bring more clarity and control over working with colors in core and custom Radiance skins, inspired by the ongoing evolution of design systems such as Material and others.
This change will also find its way into Aurora, as these two projects are twins, in a sense. Once Compose for Desktop hits its official 1.2 release, Aurora will go to 1.2 as well. Afterwards, I will work on window APIs, and will start the long-planned work to port the ribbon component to Aurora.
And last but most definitely not the least, are the plans to explore the third twin to Radiance and Aurora, and bring the theming layer and all the components to the world of Flutter.
As I said last October, it’s going to be a long road, and it may take a bit of time again until the next major release of Radiance. The current goal is to fully complete the color work across both Radiance and Aurora, and have them released at the same time. This will probably happen after the ribbon component is added to Aurora. As for the Flutter twin, it is going to be an exciting, and yet completely unpredictable adventure. I may or may not have something for you to play with in 2023. Time will tell.
It gives me great pleasure to announce the second major release of Aurora. Let’s get to what’s been fixed, and what’s been added. First, I’m going to use emojis to mark different parts of it like this:
💔 marks an incompatible API / binary change
🎁 marks new features
🔧 marks bug fixes and general improvements
Dependencies for core libraries
- Compose Desktop: 1.0.0 ➡ 1.1.0
- Kotlin: 1.5.31 ➡ 1.6.10
- Gradle: 7.3 ➡ 7.4
Release notes
- 🎁 More interaction granularity for command button actions
- Auto-repeat action. Enabled with
autoRepeatAction
boolean, initial delay configured by autoRepeatActionInterval
, subsequent delays configured by autoRepeatSubsequentInterval
- Fire action trigger, configured with
actionFireTrigger
and the new ActionFireTrigger
enum that has three values:
OnRollover
to fire action on rollover
OnPressed
to fire action on press
OnPressReleased
to fire action on press release (the default)
- 🎁 Add a breadcrumb bar composable for quick navigation of multi-level hierarchies, such as file systems, XML documents or abstract syntax trees. See documentation.
- 🎁 Support shader-based fill painters.
- 💔 Revisit the signature of shader-based decoration painters for API consistency.
- 💔 Convert command button panel to use lazy loading. Major performance improvements for panels with thousands+ elements.
- 🔧 Fix incorrect alignment of command button panel content when the content fits without the need to kick in scrolling.
- 🔧 Eliminate flash of color artifacts on opening popup windows.
- 🔧 Use bold font weight on decorated window titles.
- 🔧 Fix text overflow in command button panels with really long text on individual commands.
- 🔧 Fixexceptions when window is made smaller than the original size and starts to cut off some of the content.
- 🔧 Fix the display name in Cerulean skin definition.
This release (code-named Blizzard) brings a couple of new APIs, and otherwise is focused on stabilizing and improving the overall API surface of the various Aurora modules. There’s still a long road ahead to expand Aurora’s capabilities in 2022 and beyond, with the ribbon / command bar planned as the next big addition. If you’re in the business of writing Compose Desktop apps, I’d love for you to take Aurora for a spin. Stay frosty for more features coming in 2022!
Earlier in the Compose Desktop / Skia explorations:
Today, it’s time to take a look at how to leverage Skia to draw texts on paths:
Android’s Canvas
class comes with a few API variants of drawing texts on paths, and those APIs are available to use in your Compose code running on Android via Compose’s Canvas.nativeCanvas
. At the present moment, there is no such API available in Compose Desktop, and this article introduces just such the functionality for you to play with.
If you’re not interested in the particular details, you can head straight to the full implementation. Otherwise, let’s dive in.
First, the overall approach is going to be:
- Get the text metrics (details on the width and horizontal position of each glyph within the text)
- Get the path metrics for mapping each glyph to its position along the path
- Get the position of each glyph on the path
- Get the tangent of the path at that position to determine the glyph’s rotation
- Create a combined translation + rotation matrix for each glyph
- Create a combined text blob that contains position and rotation of all the glyphs
- [Optional] Draw the shadow for that text blob
- Draw that text blob
Now, let’s take a look at each step. We start with getting the text metrics. Note that at the present moment, there is no public bridge API that can convert Compose’s TextStyle
into Skia’s Typeface
or Font
, so in the meanwhile we use the default typeface.
val skiaFont = Font(Typeface.makeDefault())
skiaFont.size = textSize.toPx()
// Get string glyphs, and compute the width and position of each glyph in the string
val glyphs = skiaFont.getStringGlyphs(text)
val glyphWidths = skiaFont.getWidths(glyphs)
val glyphPositions = skiaFont.getPositions(glyphs, Point(x = offset.x, y = offset.y))
Here, we’re using Skia’s Font
APIs to get detailed metrics about each glyph – how wide it needs to be, and where it needs to be positioned (horizontally and vertically) if drawn along a straight line accounting for the specified offset.
Next, we’re getting the path metrics:
val pathMeasure = PathMeasure(path.asSkiaPath())
Next, we determine the start position of our text along the path based on the path pixel length, the text pixel length (based on the position and the width of the last glyph) and the requested text alignment. Note that here we do not support RTL or mixed direction texts.
val pathMeasure = PathMeasure(path.asSkiaPath())
// How long (in pixels) is our path
val pathPixelLength = pathMeasure.length
// How long (in pixels) is our text
val textPixelLength = glyphPositions[glyphs.size - 1].x + glyphWidths[glyphs.size - 1]
// Where do we start to draw the first glyph along the path based on the requested
// text alignment
val textStartOffset = when (textAlign) {
TextAlign.Left, TextAlign.Start -> glyphPositions[0].x
TextAlign.Right, TextAlign.End -> pathPixelLength - textPixelLength + glyphPositions[0].x
else -> (pathPixelLength - textPixelLength) / 2.0f + glyphPositions[0].x
}
Now it’s time to start looking at each glyph to determine its position along the path, as well as how much it needs to be rotated to “follow” the curvature of the path at that particular position. Also, we need to decide what to do with the glyphs that do not fit into the path’s span. While it might be tempting to extrapolate the path beyond its starting and ending point, in this implementation we take a “safer” route and do not display glyphs that cannot fit.
First, we start with a couple of lists to keep track of visible glyphs and their matching transformation matrices, and start iterating over glyphs:
val visibleGlyphs = arrayListOf()
val visibleGlyphTransforms = arrayListOf()
// Go over each glyph in the string
for (index in glyphs.indices) {
...
}
Each glyph needs to be positioned along the path and rotated to match the curvature of the path at that position. Depending on the “nature” of the path, we are going to have more or less space between neighboring glyphs. For example, if you draw text along the outside of a tight curve, there’s going to be more space between the glyphs. On the other hand, if you draw the same text along the inside of the same curve, the glyphs are going to get crowded or might even start overlapping. There’s not much we can really do about that without morphing each glyph, which goes well beyond the scope of this article.
The simplest thing we can do here is to take the mid-horizontal point of the specific glyph, determine its position along the path and use that to cut off those glyphs that do not fit into the path’s span:
val glyphStartOffset = glyphPositions[index]
val glyphWidth = glyphWidths[index]
// We're going to be rotating each glyph around its mid-horizontal point
val glyphMidPointOffset = textStartOffset + glyphStartOffset.x + glyphWidth / 2.0f
// There's no good solution for drawing glyphs that overflow at one of the ends of
// the path (if the path is not long enough to position all the glyphs). Here we drop
// (clip) the leading and the trailing glyphs
if ((glyphMidPointOffset >= 0.0f) && (glyphMidPointOffset < pathPixelLength)) {
...
}
Now that we know that our glyph fits in the path, we ask the path measure to give us two things:
- The (x, y) point that matched the glyph’s mid-horizontal point along the path.
- The tangent of the path at that point.
// Where are we on our path?
val glyphMidPointOnPath = pathMeasure.getPosition(glyphMidPointOffset)!!
// And where is our path tangent pointing? (Needed for rotating the glyph)
val glyphMidPointTangent = pathMeasure.getTangent(glyphMidPointOffset)!!
With these two pieces, we can now compute the translation components of our matrix for this glyph:
var translationX = glyphMidPointOnPath.x
var translationY = glyphMidPointOnPath.y
// Horizontal offset based on the tangent
translationX -= glyphMidPointTangent.x * glyphWidth / 2.0f
translationY -= glyphMidPointTangent.y * glyphWidth / 2.0f
// Vertically offset based on the normal vector
// [-glyphMidPointTangent.y, glyphMidPointTangent.x]
val glyphY = glyphPositions[index].y
translationX -= glyphY * glyphMidPointTangent.y
translationY += glyphY * glyphMidPointTangent.x
And add the glyph itself, as well as its full rotation + translation matrix to our lists:
// Compute the combined rotation-scale transformation matrix to be applied on
// the current glyph
visibleGlyphTransforms.add(
RSXform(
scos = glyphMidPointTangent.x,
ssin = glyphMidPointTangent.y,
tx = translationX,
ty = translationY
)
)
visibleGlyphs.add(glyphs[index])
Now we’re ready to use the TextBlobBuilder
API to create a single text run with the information on all the glyphs that fit along the path and their matrices:
// Create a single text run with all visible glyphs and their transformation matrices
val textBlobBuilder = TextBlobBuilder()
textBlobBuilder.appendRunRSXform(
font = skiaFont,
glyphs = visibleGlyphs.toShortArray(),
xform = visibleGlyphTransforms.toArray(emptyArray())
)
val textBlob = textBlobBuilder.build()!!
Now we’re ready to draw the optional shadow
if (shadow != null) {
nativeCanvas.drawTextBlob(
blob = textBlob,
x = shadow.offset.x,
y = shadow.offset.y,
paint = org.jetbrains.skia.Paint().also { skiaPaint ->
skiaPaint.color4f = Color4f(
r = shadow.color.red,
g = shadow.color.green,
b = shadow.color.blue,
a = shadow.color.alpha
)
skiaPaint.maskFilter =
MaskFilter.makeBlur(FilterBlurMode.OUTER, shadow.blurRadius)
}
)
}
And finally draw the text itself:
nativeCanvas.drawTextBlob(
blob = textBlob,
x = 0.0f, y = 0.0f,
paint = paint.asFrameworkPaint()
)
Let’s take another look at how our texts look like:
Here we have a few sample paths (each path is drawn for the sake of completeness) and texts that are drawn along their contours with and without drop shadows.
Now we can use this in a bigger example that loads daily visits data to a specific Wikipedia page (either remotely with Retrofit and Moshi, or from a local JSON file), and then displays that data as a seasonal spiral based on the visuals from this article:
The full code for this chart can be found over here.
This is it for this installment. Stay tuned for more explorations of Skia in Compose Desktop as the year progresses.