feat(ui): smooth screen transitions and list entrances

This commit is contained in:
OneWay committed 2026-09-18 02:11:00 +08:00
1 parent 058330dd34
commit 4ce39d0e4f
5 files changed
+138 -17

No files matched your search

@@ -1,19 +1,25 @@
package com.example.research.ui.about
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -25,13 +31,17 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
@@ -45,6 +55,7 @@ import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -112,6 +123,7 @@ fun AboutScreen(
contentType = { "about_library" },
) { library ->
AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library,
licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId,
@@ -176,6 +188,7 @@ private fun AboutHeader(versionName: String) {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary,
licenses: Map<String, AboutLicense>,
expanded: Boolean,
@@ -196,7 +209,7 @@ private fun AboutLibraryRow(
)
Surface(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
@@ -301,32 +314,79 @@ private fun AboutLibraryRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LicenseDialog(
license: AboutLicense,
onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(license.name) },
text = {
var open by remember { mutableStateOf(false) }
var wasOpen by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { open = true }
val scale by animateFloatAsState(
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
snapshotFlow { alpha }.first { it == 0f }
onDismiss()
}
}
BasicAlertDialog(
onDismissRequest = { open = false },
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
}
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
Text(
text = license.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState())
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
}
}
}
}
)
}
@@ -1,5 +1,6 @@
package com.example.research.ui.main.components
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -45,6 +46,13 @@ fun SearchResultsList(
contentType = results.itemContentType { "search_result" }
) { index ->
results[index]?.let { entry ->
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
placementSpec = null,
fadeOutSpec = null
)
) {
SearchResultItem(
entry = entry,
resultIndex = index,
@@ -53,6 +61,7 @@ fun SearchResultsList(
)
}
}
}
when (results.loadState.append) {
is LoadState.Error -> {
@@ -1,6 +1,13 @@
package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.*
@@ -92,7 +99,21 @@ fun AppNavigation(
shouldShowSettings -> Screen.Settings
else -> currentScreen
}
when (effectiveScreen) {
AnimatedContent(
targetState = effectiveScreen,
transitionSpec = {
val forward = initialState.ordinal < targetState.ordinal
(
fadeIn(tween(180)) +
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
).togetherWith(
fadeOut(tween(180)) +
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
)
},
label = "screenTransition",
) { screen ->
when (screen) {
Screen.Home -> {
MainRoute(
searchViewModel = searchViewModel,
@@ -144,3 +165,4 @@ fun AppNavigation(
}
}
}
}
@@ -3,6 +3,11 @@ package com.example.research.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -266,7 +271,11 @@ private fun CollapsibleSettingsSection(
testTag = testTag
)
if (isExpanded) {
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -1,6 +1,9 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -15,8 +18,10 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -260,12 +265,27 @@ private fun DictionaryListSection(
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dictionaries.forEach { dictionary ->
key(dictionary.path) {
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
@@ -276,3 +296,4 @@ private fun DictionaryListSection(
}
}
}
}