Show a loading indicator on the placeholders which can stay on screen forever

emptyNode() renders nothing at all, which is fine for a placeholder that is left
within a few frames, but makes the application look frozen when the wait can
last, or never end.

Add a delayBeforeShowingContent parameter to emptyNode(): when it is finite, a
progress indicator and a "Loading…" label fade in after that delay, reusing the
existing DelayedVisibility helper. It defaults to Duration.INFINITE, so the
splash screen keeps rendering an empty canvas.

Use a one second delay on the two placeholders which are not guaranteed to be
left: the FTUE one, rendered while waiting for the session verified status, and
the LoggedInFlowNode one, rendered while the FTUE state is unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-11 12:48:44 +02:00
parent 8101abf67d
commit 562e439447
4 changed files with 60 additions and 5 deletions
@@ -113,6 +113,7 @@ import java.time.Duration
import java.time.Instant
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
@@ -324,7 +325,9 @@ class LoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> emptyNode(buildContext)
// Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all,
// since there is no guarantee that this state will be left.
NavTarget.Placeholder -> emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds)
NavTarget.LoggedInPermanent -> {
val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() {
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -84,7 +85,10 @@ class FtueFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> {
emptyNode(buildContext)
// Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
// This can last for a while, or forever if the sync service never manages to run, so
// render a loading state rather than nothing at all.
emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds)
}
is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
+1
View File
@@ -17,4 +17,5 @@ android {
dependencies {
implementation(libs.appyx.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}
@@ -9,37 +9,84 @@
package io.element.android.libraries.ui.common.nodes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
*
* @param buildContext the [BuildContext] of the Node.
* @param delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for
* placeholders which can stay on screen for a long time, or forever, else the application looks frozen.
* Defaults to [Duration.INFINITE], i.e. the Node stays empty.
*/
fun emptyNode(
buildContext: BuildContext,
delayBeforeShowingContent: Duration = Duration.INFINITE,
): Node = node(buildContext) { modifier ->
EmptyView(modifier)
EmptyView(
delayBeforeShowingContent = delayBeforeShowingContent,
modifier = modifier,
)
}
@Composable
private fun EmptyView(
delayBeforeShowingContent: Duration,
modifier: Modifier = Modifier,
) = Box(
modifier = modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault),
)
contentAlignment = Alignment.Center,
) {
if (delayBeforeShowingContent.isFinite()) {
DelayedVisibility(duration = delayBeforeShowingContent) {
LoadingContent()
}
}
}
@Composable
private fun LoadingContent() = Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
text = stringResource(CommonStrings.common_loading),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
@PreviewsDayNight
@Composable
internal fun EmptyViewPreview() = ElementPreview {
EmptyView(Modifier)
EmptyView(delayBeforeShowingContent = Duration.INFINITE)
}
@PreviewsDayNight
@Composable
internal fun EmptyViewLoadingPreview() = ElementPreview {
EmptyView(delayBeforeShowingContent = 1.seconds)
}