Skip to content

ui-frameworks

2 posts with the tag “ui-frameworks”

Scrolling like it's 2008

Back in 2008 when Abe and I were working on our first iPhone app Stanza, there was a very influential blog post by Loren Brichter, the developer of a popular Twitter client app (back when such things were not only permitted, but encouraged), titled: ”Fast Scrolling in Tweetie1”, which opened with:

Scrolling is the primary method of interaction on the iPhone. It has to be fast. It has to be fast.

This is as true in 2024 as it was in 2008. Which makes it all the more surprising that people are still shipping apps that exhibit scrolling issues. Animation jank, muddy inertia, and dropped frames are among the most common issues that plague applications that were built with frameworks that eschew the platform-native list controls and decide to re-invent the wheel. Scrolling is one of the most commonly cited examples of these apps feeling to users like they are in the “uncanny valley” – that oft-indescribable sense that an app feels not quite right.

Back in 2008, making a high-performance list control for iOS could be quite an involved chore. Anyone who recalls fighting with UIKit’s UITableView and all its warts will remember with a shudder just how persnickety the control could be, and how painful coordinating the mess of Objective-C data sources and delegates would invariably become.

Thankfully, the emergence of SwiftUI in 2019 meant that creating a buttery-smooth list control with thousands of elements is as simple as 5 lines:

List {
ForEach(1..<1_000) { i in
NavigationLink("Item \(i)", value: i)
}
}
This example is lifted directly out of the project generated by skip init, as shown in the getting started guide. Run this on your iPhone and fling-scroll the list to your heart's content: never a stutter or pause to be found, and the physics of the interaction feel perfectly correct for the device. This is because SwiftUI's List doesn't re-invent the underlying UIKit list components, but rather it manages them for you. All the complexity and error-prone bookkeeping of the underlying UIKit controls are automatically taken care of.

On the Android side, the equivalent Jetpack Compose list control is a LazyColumn. Compose names are different from SwiftUI, but the effect is the same – you can create a silky-smooth list control with just this 5-line snippet:

LazyColumn {
items(List(1000) { it }) { item ->
Text(text = "Item ${item}")
}
}

And in the same way as SwiftUI’s List wraps and manages the underlying UIKit family of Objective-C classes, Compose’s Kotlin LazyColumn sidesteps having to use the Java RecyclerView and LinearLayoutManager classes from the older Android SDK and manages all the complexity of displaying a high-performance list of items. When coming from the old-school imperative APIs, creating user interfaces with the modern declarative style is a breath of fresh air.

The fact that these vendor-supported toolkits – SwiftUI and Compose – are built atop the platform-native scrolling mechanics stands in contrast with some of the other cross-platform frameworks created in “alien” languages like Dart and JavaScript, that instead attempt to implement all this complexity on their own.

Back in 2012, Benjamin Sandofsky wrote about “The Framework Tax2”:

For native apps, performance is critical to a great user experience. Users notice jerky scrolling, and performance can make or break a feature

Back then, the popular solutions purporting to simplify cross-platform app development were simple WebView-based wrappers designed to make JavaScript and HTML look and feel like a real app. These particular attempts have fallen out of fashion and have been replaced by newer offerings like Flutter, React Native, and Xamarin that use Dart, JavaScript, and C# (respectively) to attempt to abstract away the platform-native frameworks and provide their own homogeneous API for developers to create their apps with.

But what hasn’t changed is that each of these attempts still adds a layer of indirection and overhead to the app, as has been analyzed and confirmed by academic research3. They all require writing your app in a separate language and IDE, and then bundling the distributed app with a separate garbage-collected runtime layer, as well as often including a graphics engine that performs the low-level drawing. All of these frameworks introduce overhead: battery-killing inefficiencies4, pauses from garbage-collection, animation jank from the graphics technology5, or friction resulting from bridging between an alien language and the platform’s native language. For an overview of these issues, see our Skip comparison page.

And that’s the difference with Skip: when you create your app using Skip, you are coding directly to Apple’s SwiftUI – in Swift – on iOS, and transpiling directly Google’s Jetpack Compose – in Kotlin – on Android. These are the official, vendor-recommended languages and toolkits for creating modern apps. They are as fast as they can conceivably be, and they will continue to be supported by the platform vendors in perpetuity. By transpiling your Swift into Kotlin, Skip avoids the overhead of abstracting the platform from an alien language, but instead embraces each platforms’s strengths and performance potential.

That’s why we are convinced that Skip is the right approach for creating mobile apps while still retaining the benefit of a single codebase. Quite simply, it enables your app to be the uncompromisingly best experience it can possibly be. So go ahead: scroll like it’s 2008, when the mobile world was new, apps were fast, and Tweetie was all the rage!

After writing this, Abe informed me that not only did he, while at Twitter, architect the transition from Loren Brichter’s CoreGraphics-based drawing to UIKit Views in the Twitter app, but he also happened to be working with Benjamin Sandofsky at the time as well. I had no idea. Small world! {: style=“font-size: 0.8em;”}

  1. Archive of “Fast Scrolling in Tweetie”: https://web.archive.org/web/20111201182613/http://blog.atebits.com/2008/12/fast-scrolling-in-tweetie-with-uitableview

  2. “Shell Apps and Silver Bullets” by Benjamin Sandofsky: https://www.sandofsky.com/cross-platform/

  3. Jozef Goetz and Yan Li. “Evaluation of Cross-Platform Frameworks for Mobile Applications.” In: International Conference on Engineering and Applied Science https://www.researchgate.net/publication/327719390_Evaluation_of_Cross-Platform_Frameworks_for_Mobile_Applications

  4. Thomas Dorfer, Lukas Demetz, and Stefan Huber. “Impact of mobile cross-platform development on CPU, memory and battery of mobile devices when using common mobile app features.” https://www.sciencedirect.com/science/article/pii/S1877050920317099

  5. Damian Białkowski and Jakub Smołka. “Evaluation of Flutter framework time efficiency in context of user interface tasks.” In: Journal of Computer Sciences Institute 25 https://ph.pollub.pl/index.php/jcsi/article/view/3007

Add a Custom Shadow to Any Content in Compose

Drop shadow on complex content

Skip’s open-source SkipUI library implements the SwiftUI API for Android. To do so, SkipUI leverages Compose, Android’s own modern, declarative UI framework.

The SwiftUI shadow(color:radius:x:y:) modifier adds a drop shadow with a customizable color, blur radius, and offset to any SwiftUI content. Implementing this in Compose posed a problem, because Compose’s own shadow(elevation:shape:clip:ambientColor:spotColor:) modifier works very differently. The most critical difference is readily apparent from the modifier’s signature: you have to supply the shadow’s shape (or be satisfied with the rectangular default). SwiftUI’s shadow, on the other hand, is more akin to a real shadow, automatically mirroring the outline of its target content.

Luckily, Compose is a powerful UI framework. We were able to create a composable function that adds a drop shadow to any content, without affecting your layout and while mirroring the content’s shape (as defined by its non-transparent pixels) exactly. To do so, we used a combination of techniques:

  • Modifier.drawWithContent to re-render the given content as its own shadow
  • A custom ColorMatrix to paint the content in the specified shadow color
  • Layout to place the shadow behind the content with the specified offset, without affecting your layout

The resulting code is below. Note: SkipUI’s implementation is tied to SwiftUI internals, so this is an untested and simplified port of the actual code.

// Compose the given content with a drop shadow on all
// non-transparent pixels
@Composable fun Shadowed(modifier: Modifier, color: Color, offsetX: Dp, offsetY: Dp, blurRadius: Dp, content: @Composable () -> Unit) {
val density = LocalDensity.current
val offsetXPx = with(density) { offsetX.toPx() }.toInt()
val offsetYPx = with(density) { offsetY.toPx() }.toInt()
val blurRadiusPx = ceil(with(density) {
blurRadius.toPx()
}).toInt()
// Modifier to render the content in the shadow color, then
// blur it by blurRadius
val shadowModifier = Modifier
.drawWithContent {
val matrix = shadowColorMatrix(color)
val filter = ColorFilter.colorMatrix(matrix)
val paint = Paint().apply {
colorFilter = filter
}
drawIntoCanvas { canvas ->
canvas.saveLayer(Rect(0f, 0f, size.width, size.height), paint)
drawContent()
canvas.restore()
}
}
.blur(radius = blurRadius, BlurredEdgeTreatment.Unbounded)
.padding(all = blurRadius) // Pad to prevent clipping blur
// Layout based solely on the content, placing shadow behind it
Layout(modifier = modifier, content = {
// measurables[0] = content, measurables[1] = shadow
content()
Box(modifier = shadowModifier) { content() }
}) { measurables, constraints ->
// Allow shadow to go beyond bounds without affecting layout
val contentPlaceable = measurables[0].measure(constraints)
val shadowPlaceable = measurables[1].measure(Constraints(maxWidth = contentPlaceable.width + blurRadiusPx * 2, maxHeight = contentPlaceable.height + blurRadiusPx * 2))
layout(width = contentPlaceable.width, height = contentPlaceable.height) {
shadowPlaceable.placeRelative(x = offsetXPx - blurRadiusPx, y = offsetYPx - blurRadiusPx)
contentPlaceable.placeRelative(x = 0, y = 0)
}
}
}
// Return a color matrix with which to paint our content
// as a shadow of the given color
private fun shadowColorMatrix(color: Color): ColorMatrix {
return ColorMatrix().apply {
set(0, 0, 0f) // Do not preserve original R
set(1, 1, 0f) // Do not preserve original G
set(2, 2, 0f) // Do not preserve original B
set(0, 4, color.red * 255) // Use given color's R
set(1, 4, color.green * 255) // Use given color's G
set(2, 4, color.blue * 255) // Use given color's B
set(3, 3, color.alpha) // Multiply by given color's alpha
}
}

You can see this in action in the Skip Showcase app’s shadow playground, and in the image at the top of this article.

We hope that you find this useful! If you have questions or suggestions for improvements, please reach out to us on Mastodon @skiptools@mas.to, via chat skiptools.slack.com, or in our discussion forums.