DEV Community

Cover image for Jetpack Compose and WindowSize Classes
Eric Donovan
Eric Donovan

Posted on

Jetpack Compose and WindowSize Classes

Unlike iOS, Android was always envisioned as a platform that would run on a range of devices and screen types, created by different manufacturers.

As such, Android has various ways of creating UIs that work well with different physical screen types, and you might have been a little surprised (at least I was) that these techniques have largely been dropped when it comes to Jetpack Compose.

  • If you're new to Android, the situation can be a bit confusing, especially when pixel density comes in to play.
  • If you're an old hand you'll know how hard it can be to add responsive design to an app later rather than building it in from the beginning.

So here is a summary of what the modern android developer needs to think about when developing UIs that are expected to work across a large range of screen types.

Example app showing a responsive UI which appears different depending on the screen space available to it

Our adaptive counter app displayed in split screen mode with the font size settings taking up the other window

We start by reviewing density indepence, then discuss the difference between responsive and adaptive design. But feel free to skip straight ahead to the WindowSize class discussion here


I want things to look the same

Let's get screen density out of the way first as it's a really simple solution.

The problem:

physical pixels aren't the same size... they're not even all square

If you display a 50x50 pixel element on a screen made up of large pixels (the type that you can easily see with the human eye) that element is going to look fairly big (regardless of the physical size of the actual screen).

The same 50x50 pixel element on a screen with a much higher pixel density (i.e. small pixels, like the type that are so small you can't even see them) will be rendered much smaller.

A logo with the same number of pixels displayed on a low density screen, versus displayed on a high density screen

This is probably not what you want. Mostly you want the element to look the same physical size, no matter what the pixel density is of the screen it's being displayed on (this is separate to the size of the screen itself, we'll get to that next)

So we tend to specify things in terms of dp, a type of virtual pixel that may be rendered with 1 physical pixel (if the screen has pixels that are quite large) or maybe 2.5 physical pixels (if the screen has very small pixels)

A logo specified in dp looks the same physical size regardless of the size of the pixels being used to display it

Obviously I've used images for these examples but the same issue exists for paddings, layout sizes etc.

In any case, I'd say that the screen density issue is mostly a solved problem on Android (especially when preferring vector images over raster-based images). We just need to understand the issue and avoid specifying things in terms of pixels. The Jetpack Compose API also does a good job of encouraging you to specify dimensions using dp, not pixels.


I want things to look big, on big screens

The problem:

Sometimes you don't actually want things to look the same physical size.

Sometimes you want your screen elements to take up more space if more space is available. What's the point of having all that lovely space on a tablet display, if your one-size-fits-all design is squeezed into a little corner?

A tablet and phone showing the same size icon, the tablet icon looks too small because the tablet screen is much larger

The pre Jetpack Compose solution for this was to use size-buckets (beyond certain screen size breakpoints, the dimension of the box would be taken from a different bucket). So it could be a 50dp x 50dp element for small screens, but a 100dp x 100dp element for large screens.

Note: the screen sizes we consider here are specified in dp, so they are completely independent of pixel density. It's true that smaller screens are often cheaper and have larger pixels. And that larger, more expensive screens also often have higher pixel densities. But that relationship is not guaranteed. Screen size and pixel density are completely independent, and need to be considered separately.

In Compose, the size bucket system is being replaced by WindowSize classes. Nevertheless, I think what makes the most sense in a compose context is to design our UIs using percentages/proportions of the available space wherever possible.

Let's say an element should be 25% of the width of the screen. That's the same value, whether our screen is a small phone or a large tablet, or (crucially) somewhere in between. Where a particular screen size falls on the boundary between two WindowSizes, percentage based layouts will still work fine.

An icon shown on a tablet screen and a phone screen, the tablet version is a little larger to match the larger screen of the tablet

Another major advantage of this technique is the reduction in overhead needed. Designing, developing, and testing size-bucket style UIs, with multiple sets of dimensions, for multiple screen sizes can be a significant challenge for a large project. Percentage based dimensions also tend to be well understood by design, development and test teams.

For basic element sizes, fillMaxWidth() and fillMaxHeight() are very useful in this regard as they accept a fraction parameter:

  modifier = Modifier.fillMaxWidth(0.25f)
Enter fullscreen mode Exit fullscreen mode

To derive other values from the space available, BoxWithConstraints is also very useful:

BoxWithConstraints {

  val derivedDimension = this.maxWidth * 0.10f // 10% of the width

  Box(modifier = Modifier.padding(derivedDimension)){
    // content
  }
}
Enter fullscreen mode Exit fullscreen mode

Be careful here though. Depending on what your parent view is, the values for maxWidth or maxHeight can sometimes be Infinity.dp, there's a solution for that below


I want alternative layouts for different screen sizes

The problem:

Scaling the same design to be bigger or smaller will only get you so far, sometimes the actual design needs to change

Let's say we have a design for a TV Guide that we hope our users will enjoy using on their android tablet. Maybe we can have a nice big grid with a row per channel, and the programs on that channel going from left to right.

If we specify our dimensions in terms of percentage of the screen, it's possible that we can use this same design on a phone too (it'll work, it'll just look a little smaller).

But there will be a point where scaling things won't work. That same design on a watch sized display would be unusable. That requires a completely different TV Guide UI.

Replacing the layout with an alternative one more suited to our user's screen size, is what the Android docs call "Adaptive design" (as opposed to responsive design). And if we get to this point, we need to talk about WindowSize classes...

WindowSize Classes

The current Android docs do have some advice about this, but the advice comes pretty close to saying "do it yourself". That's especially true if you look at the kotlin views example:

enum class WindowSizeClass { COMPACT, MEDIUM, EXPANDED }

fun computeWindowSizeClasses() {

    val widthDp = //... from WindowMetricsCalculator

    val widthWindowSizeClass = when {
        widthDp < 600f -> COMPACT
        widthDp < 840f -> MEDIUM
        else -> EXPANDED
    }

    val heightDp = //... from WindowMetricsCalculator

    val heightWindowSizeClass = when {
        heightDp < 480f -> COMPACT
        heightDp < 900f -> MEDIUM
        else -> EXPANDED
    }

    // Use widthWindowSizeClass and heightWindowSizeClass.
}
Enter fullscreen mode Exit fullscreen mode

At least with the compose version, they have provided that function for you, which does approximately the same thing:

val windowSizeClass = calculateWindowSizeClass(this)
Enter fullscreen mode Exit fullscreen mode

Presumably this is intended to be used like this:

val boxPadding = when (windowSizeClass.widthSizeClass) {
    Compact -> 2.dp
    Medium -> 10.dp
    else -> 25.dp
}

Box(
    modifier = Modifier.padding(boxPadding)
) {
    // box content
}
Enter fullscreen mode Exit fullscreen mode

or

Box {
    if (windowSizeClass.widthSizeClass == Compact) {
        SomeSmallLayout()
    } else {
        SomeLargeLayout()
    }
}
Enter fullscreen mode Exit fullscreen mode

I've been a bit surprised at how basic / ad hoc this code seems to be, but the package is still marked as experimental currently.

Firstly, it doesn't look that maintainable. With a larger more complex UI, all the if statements might start to become problematic.

It also feels a bit risky that the breakpoints these classes are built on are hard coded, and we're only being given size classes for Width and Height (not Orientation or Minimum Dimension for example)

Something that might not be apparent to someone using this code until they need to implement a UI for a small screen: the sizes themselves are huge. COMPACT covers everything up to 600dp in width (which means there is no way to distinguish between large phones, small phones, or watch size screens).

Of course a lot of the time you won't need that level of granularity, but sometimes you will. And if we already built half of the app with a certain breakpoint scheme, it's a little late to realise it doesn't do what we need it to do for the next piece of UI we tackle.

// width
COMPACT < 600 <= MEDIUM < 840 <= EXPANDED
// height
COMPACT < 480 <= MEDIUM < 900 <= EXPANDED
Enter fullscreen mode Exit fullscreen mode

Also, and this probably just my personal issue, but the naming... SMALL, MEDIUM, LARGE not good enough? this isn't Starbucks πŸ˜‚

The maven package this code comes from is material3-window-size-class and maybe its going to improve as it is still experimental at the moment.

But the breakpoints are taken from the Material3 design guidelines themselves, so they probably won't change, and they may not be what you actually want (the docs do claim that it's "opinionated" and I assume that's why).

The trouble is it doesn't serve the needs of projects that:

  • a) aren't that interested in Material3 design beyond picking a few components and design elements or
  • b) want the option to write UI code that will work on any size of android screen, and not treat everything with a portrait width of less than 600dp as the same device!

Even the horizontal grid system discussed in Material2 and referenced by the Material3 docs, would not be implementable with the breakpoints provided by material3-window-size-class. For example:

  • 600dp is the breakpoint to switch from 4 to 8 columns
  • 905dp switches to 12 columns, with scaling margins
  • 1240dp margins no longer scale, but content does
  • 1440dp content stops scaling, margins take over again

I suspect there'll be a fair number of developers who don't have time to read an article like this one, or to investigate the material3 breakpoints themselves and live to regret building their UIs with these hard coded breakpoints. Only time will tell I guess.


An attempt at a better WindowSize class

Firstly in an ideal WindowSize scheme, we'd want to be able to select a preferred value, based on just SMALL vs LARGE. A lot of app projects are going to want to support two versions of a design and that's all. Here's how we would specify the responsive boxPadding example we had above:

val boxPadding = WidthBasedDp(s = 5.dp, l = 53.dp)
Enter fullscreen mode Exit fullscreen mode

Maybe we have some rather specific designs that require a different value for screen sizes roughly equivalent to: watches, small phones, large phones, tablets, large desktops

val boxPadding = WidthBasedDp(
  xs = 2.dp,
  s = 3.dp,
  m = 5.dp,
  l = 20.dp,
  xl = 50.dp
)
Enter fullscreen mode Exit fullscreen mode

There are a lot of ways to pick a value, it won't always be the width, so for example we can do this too:

val boxPadding = HeightBasedDp(s = 3.dp, m = 5.dp, l = 20.dp)
val boxPadding = AspectBasedDp(port = 3.dp, land = 15.dp, squarish = 5.dp)
val boxPadding = MinDimBasedDp(s = 3.dp, m = 5.dp, l = 20.dp)
Enter fullscreen mode Exit fullscreen mode

And of course we don't just want adaptive dps, sometimes it's useful to have a Float or a TextUnit based on a WindowSize class too:

val myInt = WidthBasedInt(s = 3, m = 5, l = 20)
val myFontSize = AspectBasedTextUnit(port = 3.em, land = 6.sp)
val myFloat = HeightBasedFloat(s = 3.fl, l = 20.fl)
Enter fullscreen mode Exit fullscreen mode

In fact, why not allow us to choose anything based on a WindowSize class:

val myLabel = AspectBasedValue<String>(
    port = "the view is portrait",
    land = "the view is landscape",
    squarish = "the view is approximately square"
)
Enter fullscreen mode Exit fullscreen mode

Lastly we need to be able to select a Composable based on a WindowSize class in the event that we want a completely different layout:

private val MyAdaptiveLayout = MinDimBasedComposable(
    s = { SomeSmallLayout() },
    l = { SomeLargeLayout() },
)
Enter fullscreen mode Exit fullscreen mode

That allows us to keep our Compose layout code a little clearer at the point of use:

Box {
    MyAdaptiveLayout(size)
}
Enter fullscreen mode Exit fullscreen mode

In fact all of the above should reduce the amount of branching you need to do in subsequent layout code (less if statements)

And of course, we want this to be recomposed whenever the screen dimensions change (for example when a user displays your app in split screen mode, or opens their foldable)

val size: WindowSize = rememberWindowSize()
Enter fullscreen mode Exit fullscreen mode

As a convenience for when we want to use the screen dimensions for calculating other dimensions (see above re BoxWithConstraints) I've left the original DpSize used to calculate WindowSize class inside of itself for easy access:

data class WindowSize(

    val width: Width,
    val height: Height,
    val minDim: MinDim,
    val aspect: Aspect,

    val dpSize: DpSize,
)
Enter fullscreen mode Exit fullscreen mode

It also might be useful to implement Comparable (as the Material 3 WindowSizeClass does) so that we can do things like this when we absolutely have to:

if (size.height >= Height.Medium) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

(IMO though, this is a bit of an anti-pattern because it introduces imperative style code into our layouts. I almost didn't support it, but no doubt you could make a case for doing it sometimes, for pragmatic reasons or whatever)

Finally, it's a bit of an edge case, but the WindowSize implementation in the sample app below can be combined like this:

val shape = MinDimBasedValue(
    xs = AspectBasedValue(
        port = CircleShape,
        land = CircleShape,
        squarish = RectangleShape
    ),
    m = FixedValue(CircleShape),
    l = FixedValue(CircleShape),
)
Enter fullscreen mode Exit fullscreen mode

That sets the shape to a circle unless: 1) the minimum dimension is xs and 2) window is squarish.

It's a bit clunky and the only thing I'm not delighted about to be honest, you'd need to use it like this: shape(size)(size), rather than just shape(size). (If you can think of a simple way to make that a little nicer, please send me a PR!)


BreakPoints

As a reminder, the dp values from Material3 are:

// width
COMPACT < 600 <= MEDIUM < 840 <= EXPANDED
// height
COMPACT < 480 <= MEDIUM < 900 <= EXPANDED
Enter fullscreen mode Exit fullscreen mode

These are the breakpoints I think will be more widely useful for an android UI:

// width
XS < 250 <= S < 400 <= M < 500 <= L < 900 <= XL
// height
XS < 250 <= S < 700 <= M < 900 <= L < 1280 <= XL
Enter fullscreen mode Exit fullscreen mode

Here's why I think that:

Table showing a range of android device screen sizes

And plotted on a graph

Android device screen sizes plotted on a chart showing clear groupings for watches, small phones, large phones, tablets, and desktops

It's a fairly small sample of devices, but enough to highlight some short comings of the Material3 breakpoints.

documentation screen shot saying that 99.96% of phones in portrait mode are less than 600dp in width

Ok 99.96% of phones in portrait mode are <600dp in width, but is that useful? Note: that's completely different to saying 99.96% of devices <600dp in width are phones in portrait mode.

(And what's the definition of a phone screen anyway? If you define a phone screen as something that has <600dp width, then it's 100% isn't it πŸ€”)

Here's the Material3 inspired COMPACT breakpoint expressed as a venn diagram:

venn diagram showing watches, small phones, and large phones all inside a <600dp set, except for large phones, which extend slightly outside as per Material3 breakpoints

So there are many android devices with screens much smaller than 600dp width that we might want to design for separately. But we also can't forget modes like "split-screen". Many of the larger phones when in split screen mode can give your app a window size equivalent to a very small phone. With "picture-in-picture" or "free-form" mode, all bets are off.

our sample app displayed in pop-up mode, which can be a very small overlay window

For instance I have a Samsung tablet with me right now which offers a "pop-up" mode that enables a user to resize the window of our sample counter app, to an arbitrary size.

Material3 design breakpoints don't consider any of these cases as it's a design system very focussed on the larger end of the screen size scale.

The data are here, please let me know if you come across any outlier devices that might be worth adding

Whichever breakpoints we chose, we want them to be configurable, just in case. That's certainly true if we want to build those values into a library. Having no way to change them would be asking for trouble.

But as long as we have the possibility to do something like this if we need to, things should be fine:

BreakPoints.overrideBreakPoints(
    ViewPortBreakPoints(
        widthSDpBelow = 400.dp,
        widthMDpBelow = 500.dp,
    )
)
Enter fullscreen mode Exit fullscreen mode

The code that supports all this is here (it's less than 500 lines of code, so there's not much to it). There is a note in BreakPoints.kt about how to override the values to recreate the behaviour of the Material3 WindowSize classes if you wanted to.

I'll probably end up committing this to the fore compose package but until I do (or in case I don't) if you want it, just go ahead and copy it into your project (and please let me know if you improve on it!)

Example app

I've put together a very basic counter app demonstrating the use of all the code we've discussed so far.

Something I've found very helpful is to have a single preview function that uses the windowSize breakpoints to generate all the edge case preview sizes for you in one view

@Preview(widthDp = wXS_low, heightDp = hXS_low)
@Preview(widthDp = wXS_high, heightDp = hXS_high)
@Preview(widthDp = wS_low, heightDp = hS_low)
@Preview(widthDp = wS_high, heightDp = hS_high)
@Preview(widthDp = wM_low, heightDp = hM_low)
@Preview(widthDp = wM_high, heightDp = hM_high)
@Preview(widthDp = wL_low, heightDp = hL_low)
@Preview(widthDp = wL_high, heightDp = hL_high)
@Preview(widthDp = wXL_low, heightDp = hXL_low)
@Preview(widthDp = wXL_high, heightDp = hXL_high)
@Composable
fun MyPreview() {
    PreviewWithWindowSize {
        MyLayout(size = it)
    }
}
Enter fullscreen mode Exit fullscreen mode

In the case above, our WindowSize class is being created directly from the width and height dimensions set on the Preview annotation, and then being passed into our compose layouts.

This gives me a view that looks like this so I can tell at a glance if there are likely to be any edge case issues with the design

The application UI displayed in preview form on many different example screen sizes

That probably looks a little strange, so what's actually happening here is that the colour for the shape is selected based on the width dimension of the screen

val color = WidthBasedValue(
    xs = Color.Red,
    s = Color.Green,
    m = Color.Blue,
    l = Color.Magenta,
    xl = Color.Gray
)
Enter fullscreen mode Exit fullscreen mode

The preview sizes have been deliberately picked so that they exist on the WindowSize class boundaries, because it's there we are more likely to uncover edge cases that don't work.

The 3 and 4 preview, which are almost identical in size, except preview 3 counts as S and is green, and preview 4 counts as M and is blue

For example the 4th preview is the largest possible screen we can have which still counts as S (that's why the shape is green). The 5th preview, which is only 1.dp larger than the 4th preview, is the smallest screen we can have that still counts as M (that's why the shape is blue).

So the first two previews are XS Width, the next two are S Width, and so on until we reach the gray shapes which are both XL Width.

You'll notice that the shape is sometimes oval, sometimes rectangular. That's because the shape is selected based on the Orientation of the screen as follows:

val shape = AspectBasedValue(
    port = CircleShape,
    land = CircleShape,
    squarish = RectangleShape
)
Enter fullscreen mode Exit fullscreen mode

The font sizes, buttons and thickness of the shape border are scaled directly from the screen dimensions. As our WindowSize class includes the DpSize of the window that was originally used to create it, we use that value to run our calculations (this avoids the gotcha we saw above with using BoxWithConstraints)

val minimumDimension = size.dpSize.minimumDimension()
val borderThickness = minimumDimension * 0.10f
val boxHeight = minimumDimension * 0.50f
val numberFontSize = (minimumDimension / 5f).value.sp
Enter fullscreen mode Exit fullscreen mode

Finally there are also two slightly different diagnostics composables which are selected based on the Width class of the screen

@Composable
fun BoxScope.DiagnosticInfo(size: WindowSize) {
    WidthBasedComposable(
        xs = { sz -> MiniDiagnostics(sz) },
        m = { sz -> MiniDiagnostics(sz) },
        l = { sz -> RegularDiagnostics(sz) },
    )(size)
}
Enter fullscreen mode Exit fullscreen mode

It's used like this in the layout:

Box {
  DiagnosticInfo(size)
}
Enter fullscreen mode Exit fullscreen mode

Well that was a pretty long one, thanks for sticking around till the end!


The sample app which includes the Preview annotations and all the WindowSize code is available on github git clone git@github.com:erdo/compose-windowsize.git

Top comments (5)

Collapse
 
erdo profile image
Eric Donovan

This is covered in the android docs already, but when we say tablet sized screen, it does not exclude the possibility that it's a watch with a very large screen. And if we say watch size screen we understand that it could equally be a tiny tablet, or a small pop-up window displayed on a larger tablet.

We need a vocabulary to communicate those sizes, and that's the easiest way to express them. But that's why it's xs (and not watch). It's why the code comments say "Typically indicates the width of a ..." (instead of "Indicates the width of")

It's a WindowSize class, not a DeviceType class

Collapse
 
erdo profile image
Eric Donovan

Hi, the WindowSize classes discussed here are now available by adding this to your gradle file:

implementation("co.early.fore:fore-kt-android-compose:1.4.0")

and there's now a sample app (similar to the one for this article) in the fore repo itself: github.com/erdo/android-fore/tree/...

Collapse
 
davidibrahim profile image
David

Nice article

Collapse
 
pranaypatel_ profile image
pRaNaY

What a amazing example driven blog it is. Really appreciate your efforts @erdo . Keep it up.

Collapse
 
erdo profile image
Eric Donovan

Thanks! I really appreciate that :)