Try making HeaderFooterView more robust (#7267)

* Try making `HeaderFooterView` more robust

Create `HeaderFooterLayout` to properly calculate the content size when the component is scrollable so it's at least the size of the available space.

* Make `DesktopNoticeView` scrollable

For this, `NumberedListOrganism` can't be a `LazyColumn`, since that's a lazy layout with no intrinsic height and Compose doesn't know how to size it.

* Fix the case where no header or content composables are supplied

* Fix insets in non-scrollable UI

* Remove `movableContentOf`, suppress the warning instead since the `isScrollable` parameter should never toggle this behavior

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-07-28 09:38:56 +02:00
committed by GitHub
parent 77edfec61a
commit cc3c68411e
12 changed files with 141 additions and 61 deletions
@@ -67,6 +67,7 @@ fun DesktopNoticeView(
title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
modifier = modifier,
isScrollable = true,
buttons = {
Button(
text = stringResource(R.string.screen_link_new_device_desktop_submit),
@@ -9,8 +9,7 @@
package io.element.android.libraries.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
@@ -23,11 +22,11 @@ fun NumberedListOrganism(
items: ImmutableList<AnnotatedString>,
modifier: Modifier = Modifier,
) {
LazyColumn(
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
itemsIndexed(items) { index, item ->
for ((index, item) in items.withIndex()) {
NumberedListMolecule(index = index + 1, text = item)
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.atomic.pages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.Constraints
import kotlin.math.max
/**
* A layout with a [header], a [footer] and a [content] composables.
* The component can be scrollable or not, which will modify which layout is used internally to arrange the UI.
* In both cases, the [content] will use all the available space between the [header] and the [footer].
* The [footer] is always visible, the [header] and the [content] can be scrolled if [isScrollable] is true.
*
* @param header the header composable.
* @param footer the footer composable.
* @param isScrollable if the content should be scrollable.
* @param contentInsetsPadding padding values to apply to the content. This is useful to handle system insets.
* @param modifier Compose modifier.
* @param content the main content composable.
*/
@Suppress("ContentSlotReused")
@Composable
fun HeaderFooterLayout(
header: @Composable () -> Unit,
footer: @Composable () -> Unit,
isScrollable: Boolean,
contentInsetsPadding: PaddingValues,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
if (isScrollable) {
// This is a hack to make the content at least 1px high, otherwise the layout will crash
var newHeight by remember { mutableIntStateOf(1) }
Box(
modifier = Modifier
.padding(contentInsetsPadding)
.weight(1f, fill = true)
.onSizeChanged {
newHeight = it.height
}
) {
Layout(
content = {
header()
content()
},
modifier = Modifier.verticalScroll(rememberScrollState()),
measurePolicy = { measurables, constraints ->
val actualConstraints = constraints.copy(minWidth = 0, minHeight = 0, maxHeight = newHeight)
val headerPlaceable = measurables.firstOrNull()?.measure(actualConstraints)
val contentPlaceable = if (headerPlaceable != null && measurables.size > 1) {
val availableContentHeight = max(1, actualConstraints.maxHeight - headerPlaceable.height)
val contentConstraints = actualConstraints.copy(minHeight = availableContentHeight, maxHeight = Constraints.Infinity)
measurables[1].measure(contentConstraints)
} else {
null
}
val headerHeight = headerPlaceable?.height ?: 0
val contentHeight = contentPlaceable?.height ?: 0
layout(actualConstraints.maxWidth, headerHeight + contentHeight) {
var yPosition = 0
headerPlaceable?.let {
it.placeRelative(0, yPosition)
yPosition += it.height
}
contentPlaceable?.placeRelative(0, yPosition)
}
}
)
}
} else {
Box(Modifier.padding(contentInsetsPadding)) {
header()
}
Box(Modifier.weight(1f, fill = true)) {
content()
}
}
footer()
}
}
@@ -9,19 +9,14 @@
package io.element.android.libraries.designsystem.atomic.pages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -82,48 +77,27 @@ fun HeaderFooterPage(
Box {
background()
// Render in a Column
Column(
HeaderFooterLayout(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = contentPadding)
.consumeWindowInsets(insetsPadding)
.imePadding(),
) {
// Content
Column(
modifier = Modifier
.fillMaxWidth()
.run {
if (isScrollable) {
verticalScroll(rememberScrollState())
// Make sure the scrollable content takes the full available height
.height(IntrinsicSize.Max)
} else {
Modifier
}
}
// Apply insets here so if the content is scrollable it can get below the top app bar if needed
.padding(contentInsetsPadding)
.weight(1f, fill = true),
) {
// Header
header()
Box {
content()
isScrollable = isScrollable,
contentInsetsPadding = contentInsetsPadding,
header = header,
content = content,
footer = {
Box(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.fillMaxWidth()
.padding(footerInsetsPadding)
) {
footer()
}
}
// Footer
Box(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.fillMaxWidth()
.padding(footerInsetsPadding)
) {
footer()
}
}
},
)
}
}
}