Blog Infos
Author
Published
Topics
Published

Aspect ratio with reference based on parent dimension: width, height, min, max. Overload the aspect ratio Modifier.

Unspash image by Komarov Egor 🇺🇦

 

If you are an Android, desktop, web or iOS developer working with Jetpack Compose for UI design, knowing how to resize your views is essential to support freeform windows or every form factor.

In this article, we will explore how to achieve desired visual effects using Jetpack Compose Modifier overload of the aspect ratio. The goal will be to use the parent’s dimensions as reference to resize Composable function.

The solution is on GitHub and JitPack.

GitHub – Aspect Ratio with Reference based on parent.

Context

With Jetpack Compose, the Modifier extension aspectRatio allows you to change the size of your composable functions based on a ratio.

If you want a square view with a dynamic size based on the parent size, put an aspectRatio with a value of 1f.

Box { // Parent
Box(modifier = Modifier.aspectRatio(ratio = 1f).fillMaxWidth()) { } // Child
}

If you want to display a thumbnail of a YouTube video, put ratio = 16f / 9f.

What is great about the aspectRatio is that it let you choose the matchHeightConstraintsFirst.

By default, it’s false. If you put it to true, the child height will match the parent and the child width will be adjusted accordingly to the aspectRatio.

Problem
  • What if you want to have the min(parentWidth, parentHeight) as reference in order to have the child fit the parent like ImageView’s centerInside.
  • What if you want to have the max(parentWidth, parentHeight) as reference in order to have the child centred and cropped inside the parent like ImageView’s centerCrop.
Solution: API

The goal will be to be able to write:

Box { // Parent
Box( // Child
modifier = Modifier
.aspectRatioReference(
ratioWidth = 1f,
ratioHeight = 1f,
AspectRatioReference.MIN_PARENT_WIDTH_PARENT_HEIGHT
)
.align(Alignment.Center)
) {}
}

with the following AspectRatioReference:

enum class AspectRatioReference {
/** Child width matches parent width. Child height adjusted to keep the ratio */
PARENT_WIDTH,
/** Child height matches parent height. Child height adjusted to keep the ratio */
PARENT_HEIGHT,
/** Child fits parent smallest width or height */
MIN_PARENT_WIDTH_PARENT_HEIGHT,
/** Child fits parent biggest width or height */
MAX_PARENT_WIDTH_PARENT_HEIGHT
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

With this API, every scenario are possible on the client side. Parent’s alignement can still be use, and we can choose the reference.

Note that there is no more one ratio in float, but 2 floats to easily represent ratio. Instead of doing aspectRatio = 16f / 9f it’s aspectRatioWidth = 16f, aspectRatioHeight = 9f in order to explicit numerator from denominator.

Solution: Implementation

Here, the fun begins.

Based on the existing aspectRatio inside Jetpack Compose androidx.compose.foundation:foundation-layout:1.4.0, we will write our own extension function to overload Modifier.

The main difference will be inside the function Constraints.findSize(). This is where the logic of “resizing” is done.

Here is the method we want:

private fun Constraints.findSize(): IntSize {
val matchWidth = when (aspectRatioReference) {
AspectRatioReference.PARENT_WIDTH -> true
AspectRatioReference.PARENT_HEIGHT -> false
AspectRatioReference.MIN_PARENT_WIDTH_PARENT_HEIGHT -> maxWidth < maxHeight
AspectRatioReference.MAX_PARENT_WIDTH_PARENT_HEIGHT -> maxWidth > maxHeight
}
return if (matchWidth) {
IntSize(maxWidth, (maxWidth * aspectRatioHeight / aspectRatioWidth).roundToInt())
} else {
IntSize((maxHeight * aspectRatioWidth / aspectRatioHeight).roundToInt(), maxHeight)
}
}

And here, the full implementation of the solution:

**
* Size the content to match a specified aspect ratio.
*
* @param ratioWidth the desired width positive ratio
* @param ratioHeight the desired height positive ratio
*/
// Adapted from "Modifier.aspectRatio" in the file "androidx.compose.foundation.layout.AspectRatio.kt"
// inside the library "androidx.compose.foundation:foundation-layout:1.4.0"
@Stable
fun Modifier.aspectRatioReference(
ratioWidth: Float,
ratioHeight: Float,
reference: AspectRatioReference = AspectRatioReference.MIN_PARENT_WIDTH_PARENT_HEIGHT
) = then(
AspectRatioReferenceModifier(
ratioWidth = ratioWidth,
ratioHeight = ratioHeight,
reference = reference,
debugInspectorInfo {
name = "aspectRatioReference"
properties["ratioWidth"] = ratioWidth
properties["ratioHeight"] = ratioHeight
properties["reference"] = reference
}
)
)
private class AspectRatioReferenceModifier(
private val ratioWidth: Float,
private val ratioHeight: Float,
private val reference: AspectRatioReference,
inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
init {
require(ratioWidth > 0) { "ratioWidth $ratioWidth must be > 0" }
require(ratioHeight > 0) { "ratioHeight $ratioHeight must be > 0" }
}
private val ratio = ratioWidth / ratioHeight
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val size = constraints.findSize()
val wrappedConstraints = if (size != IntSize.Zero) {
Constraints.fixed(size.width, size.height)
} else {
constraints
}
val placeable = measurable.measure(wrappedConstraints)
return layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = if (height != Constraints.Infinity) {
(height * ratio).roundToInt()
} else {
measurable.minIntrinsicWidth(height)
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = if (height != Constraints.Infinity) {
(height * ratio).roundToInt()
} else {
measurable.maxIntrinsicWidth(height)
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = if (width != Constraints.Infinity) {
(width / ratio).roundToInt()
} else {
measurable.minIntrinsicHeight(width)
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = if (width != Constraints.Infinity) {
(width / ratio).roundToInt()
} else {
measurable.maxIntrinsicHeight(width)
}
private fun Constraints.findSize(): IntSize {
val matchPARENTWidth = when (reference) {
AspectRatioReference.PARENT_WIDTH -> true
AspectRatioReference.PARENT_HEIGHT -> false
AspectRatioReference.MIN_PARENT_WIDTH_PARENT_HEIGHT -> maxWidth < maxHeight
AspectRatioReference.MAX_PARENT_WIDTH_PARENT_HEIGHT -> maxWidth > maxHeight
}
return if (matchPARENTWidth) {
IntSize(maxWidth, (maxWidth * ratioHeight / ratioWidth).roundToInt())
} else {
IntSize((maxHeight * ratioWidth / ratioHeight).roundToInt(), maxHeight)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
val otherModifier = other as? AspectRatioReferenceModifier ?: return false
return ratio == otherModifier.ratio &&
reference == other.reference
}
override fun hashCode(): Int =
ratio.hashCode() * 31 + reference.hashCode()
override fun toString(): String = "AspectRatioReferenceModifier(" +
"ratioWidth=$ratioWidth, " +
"ratioHeight=$ratioHeight, " +
"reference=$reference" +
")"
}
Solution — Preview

To be sure the code is working, the Android Studio preview is great.
Here, the code to have the following preview.

Jetpack Compose preview of the solution by Jonathan

@Composable
@Preview(widthDp = 360, heightDp = 320)
private fun Parent_200_200_Child_w3_h2_ref_height() {
AspectRatioReferencePreviewParent(200.dp, 200.dp) { // Parent
Surface( // Child
color = ratioViewPreviewChildColor,
border = BorderStroke(1.dp, ratioViewPreviewChildStrokeColor),
modifier = Modifier
.aspectRatioReference(
ratioWidth = 3f,
ratioHeight = 2f,
AspectRatioReference.PARENT_HEIGHT
)
.align(Alignment.Center)
) {}
}
}
@Composable
@Preview(widthDp = 360, heightDp = 320)
private fun Parent_200_200_Child_w1_h2_ref_height() {
AspectRatioReferencePreviewParent(200.dp, 200.dp) { // Parent
Surface( // Child
color = ratioViewPreviewChildColor,
border = BorderStroke(1.dp, ratioViewPreviewChildStrokeColor),
modifier = Modifier
.aspectRatioReference(
ratioWidth = 1f,
ratioHeight = 2f,
AspectRatioReference.PARENT_HEIGHT
)
.align(Alignment.Center)
) {}
}
}
@Composable
@Preview(widthDp = 360, heightDp = 320)
private fun Parent_200_300_Child_w1_h1_ref_min() {
AspectRatioReferencePreviewParent(150.dp, 200.dp) { // Parent
Surface( // Child
color = ratioViewPreviewChildColor,
border = BorderStroke(1.dp, ratioViewPreviewChildStrokeColor),
modifier = Modifier
.aspectRatioReference(
ratioWidth = 1f,
ratioHeight = 1f,
AspectRatioReference.MIN_PARENT_WIDTH_PARENT_HEIGHT
)
.align(Alignment.Center)
) {}
}
}
@Composable
@Preview(widthDp = 360, heightDp = 320)
private fun Parent_200_300_Child_w1_h1_ref_max() {
AspectRatioReferencePreviewParent(150.dp, 200.dp) { // Parent
Surface( // Child
color = ratioViewPreviewChildColor,
border = BorderStroke(1.dp, ratioViewPreviewChildStrokeColor),
modifier = Modifier
.aspectRatioReference(
ratioWidth = 1f,
ratioHeight = 1f,
AspectRatioReference.MAX_PARENT_WIDTH_PARENT_HEIGHT
)
.align(Alignment.Center)
) {}
}
}
private val ratioViewPreviewBackgroundColor = Color.White
private val ratioViewPreviewChildColor = Color(0xFFE7E6E6)
private val ratioViewPreviewChildTextColor = Color(0xFF979797)
private val ratioViewPreviewChildStrokeColor = Color.Black
private val ratioViewPreviewParentColor = Color(0x63EC6D6D)
private val ratioViewPreviewParentTextColor = Color(0xFFEC6D6D)
private val ratioViewPreviewParentStrokeColor = Color.Red
private val ratioViewPreviewStrokeWidth = 12.dp
@Composable
private fun AspectRatioReferencePreviewParent(
parentWidth: Dp,
parentHeight: Dp,
content: @Composable BoxScope.() -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
Surface(color = ratioViewPreviewBackgroundColor, modifier = Modifier.fillMaxSize()) {}
Text(
text = "Child",
color = ratioViewPreviewChildTextColor,
fontSize = 30.sp,
fontWeight = FontWeight(600),
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopCenter)
)
Text(
text = "Parent",
color = ratioViewPreviewParentTextColor,
fontSize = 30.sp,
fontWeight = FontWeight(600),
modifier = Modifier
.padding(4.dp)
.align(Alignment.BottomCenter)
)
Box(
modifier = Modifier
.size(width = parentWidth, height = parentHeight)
.align(Alignment.Center)
) {
Box(
modifier = Modifier
.padding(ratioViewPreviewStrokeWidth.div(2))
.align(Alignment.Center)
) {
content()
}
Surface(
color = Color.Transparent,
border = BorderStroke(ratioViewPreviewStrokeWidth, ratioViewPreviewParentColor),
modifier = Modifier.fillMaxSize()
) {}
Surface(
color = Color.Transparent,
border = BorderStroke(1.dp, ratioViewPreviewParentStrokeColor),
modifier = Modifier
.fillMaxSize()
.padding(
ratioViewPreviewStrokeWidth
.div(2)
.plus(0.5.dp)
)
) {}
}
}
}
Conclusion

Jetpack Compose is a great tool because we can easily add new behaviours. Checking how does it work behind the scene is a great way to be aware of “How” we can “do better” or at least, “make something filling our needs”.

Do not hesitate to comment or open issues on the github project.

How to contact me, checkout mercandalli.com.

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu