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
|
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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user