feat(ui): smooth screen transitions and list entrances
This commit is contained in:
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,
|
||||
@@ -143,4 +164,5 @@ 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) },
|
||||
@@ -275,4 +295,5 @@ private fun DictionaryListSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user