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’scenterInside
. - 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’scenterCrop
.
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
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