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 package com.example.research.ui.about
import androidx.compose.animation.animateColorAsState 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -25,13 +31,17 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag 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.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -112,6 +123,7 @@ fun AboutScreen(
contentType = { "about_library" }, contentType = { "about_library" },
) { library -> ) { library ->
AboutLibraryRow( AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library, library = library,
licenses = librariesData?.licenses.orEmpty(), licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId, expanded = expandedLibraryId == library.uniqueId,
@@ -176,6 +188,7 @@ private fun AboutHeader(versionName: String) {
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun AboutLibraryRow( private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary, library: AboutLibrary,
licenses: Map<String, AboutLicense>, licenses: Map<String, AboutLicense>,
expanded: Boolean, expanded: Boolean,
@@ -196,7 +209,7 @@ private fun AboutLibraryRow(
) )
Surface( Surface(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("about_library_row") .testTag("about_library_row")
.semantics { testTagsAsResourceId = true }, .semantics { testTagsAsResourceId = true },
@@ -301,32 +314,79 @@ private fun AboutLibraryRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LicenseDialog( private fun LicenseDialog(
license: AboutLicense, license: AboutLicense,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit, onOpenWebsite: (String) -> Unit,
) { ) {
AlertDialog( var open by remember { mutableStateOf(false) }
onDismissRequest = onDismiss, var wasOpen by remember { mutableStateOf(false) }
title = { Text(license.name) }, LaunchedEffect(Unit) { open = true }
text = { 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(
text = license.content ?: stringResource(R.string.license_text_unavailable), 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())
) )
}, Spacer(modifier = Modifier.height(24.dp))
confirmButton = { Row(
TextButton(onClick = onDismiss) { modifier = Modifier.fillMaxWidth(),
Text(stringResource(R.string.action_close)) horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
} ) {
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url -> license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) { TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website)) 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 package com.example.research.ui.main.components
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
@@ -45,6 +46,13 @@ fun SearchResultsList(
contentType = results.itemContentType { "search_result" } contentType = results.itemContentType { "search_result" }
) { index -> ) { index ->
results[index]?.let { entry -> results[index]?.let { entry ->
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
placementSpec = null,
fadeOutSpec = null
)
) {
SearchResultItem( SearchResultItem(
entry = entry, entry = entry,
resultIndex = index, resultIndex = index,
@@ -53,6 +61,7 @@ fun SearchResultsList(
) )
} }
} }
}
when (results.loadState.append) { when (results.loadState.append) {
is LoadState.Error -> { is LoadState.Error -> {
@@ -1,6 +1,13 @@
package com.example.research.ui.navigation package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler 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.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -92,7 +99,21 @@ fun AppNavigation(
shouldShowSettings -> Screen.Settings shouldShowSettings -> Screen.Settings
else -> currentScreen 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 -> { Screen.Home -> {
MainRoute( MainRoute(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
@@ -143,4 +164,5 @@ fun AppNavigation(
) )
} }
} }
}
} }
@@ -3,6 +3,11 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -266,7 +271,11 @@ private fun CollapsibleSettingsSection(
testTag = testTag testTag = testTag
) )
if (isExpanded) { AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1,6 +1,9 @@
package com.example.research.ui.settings.components 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -15,8 +18,10 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -260,12 +265,27 @@ private fun DictionaryListSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false }
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
dictionaries.forEach { dictionary -> dictionaries.forEach { dictionary ->
key(dictionary.path) { 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( DictionaryListItem(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
@@ -275,4 +295,5 @@ private fun DictionaryListSection(
} }
} }
} }
}
} }