From 43c1c46b560b8a88d5043eb0ba8cf79d18dee2f5 Mon Sep 17 00:00:00 2001 From: stslex Date: Wed, 30 Aug 2023 21:15:44 +0300 Subject: [PATCH] refactor auth animation --- .../feature/auth/ui/AuthScreen.kt | 28 +++-- .../feature/auth/ui/AuthViewModel.kt | 2 +- .../auth/ui/components/AuthBottomText.kt | 34 ------ .../feature/auth/ui/components/AuthTitle.kt | 101 +++++++++++++++--- .../auth/ui/model/screen/AuthScreenState.kt | 33 ++++-- .../feature/auth/ui/navigation/AuthRouter.kt | 6 +- .../feature/auth/ui/store/AuthStore.kt | 10 +- .../feature/auth/ui/store/AuthStoreImpl.kt | 6 +- feature/auth/src/main/res/values/strings.xml | 4 +- .../home/ui/store/HomeScreenStoreImpl.kt | 29 ++--- 10 files changed, 162 insertions(+), 91 deletions(-) delete mode 100644 feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt index e14dbd9..3e65fe2 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthScreen.kt @@ -3,11 +3,14 @@ package com.stslex.aproselection.feature.auth.ui import android.annotation.SuppressLint import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.swipeable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost @@ -17,16 +20,18 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.stslex.aproselection.core.ui.components.ErrorSnackbar import com.stslex.aproselection.core.ui.components.SuccessSnackbar +import com.stslex.aproselection.core.ui.ext.toPx import com.stslex.aproselection.core.ui.theme.AppDimens import com.stslex.aproselection.core.ui.theme.AppTheme import com.stslex.aproselection.feature.auth.R -import com.stslex.aproselection.feature.auth.ui.components.AuthBottomText import com.stslex.aproselection.feature.auth.ui.components.AuthFieldsColumn import com.stslex.aproselection.feature.auth.ui.components.AuthTitle import com.stslex.aproselection.feature.auth.ui.model.SnackbarActionType @@ -35,16 +40,26 @@ import com.stslex.aproselection.feature.auth.ui.model.screen.rememberAuthScreenS import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.UsernameTextFieldState import com.stslex.aproselection.feature.auth.ui.store.AuthStore +@OptIn(ExperimentalMaterialApi::class) @SuppressLint("CoroutineCreationDuringComposition") @Composable fun AuthScreen( state: AuthScreenState, modifier: Modifier = Modifier, ) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp.toPx Box( modifier = modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .swipeable( + state = state.swipeableState, + orientation = Orientation.Horizontal, + anchors = mapOf( + screenWidth to AuthStore.AuthFieldsState.AUTH, + 0f to AuthStore.AuthFieldsState.REGISTER + ) + ), contentAlignment = Alignment.Center, ) { AuthScreenContent(state) @@ -71,6 +86,7 @@ fun AuthScreen( } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun AuthScreenContent( state: AuthScreenState, @@ -83,18 +99,12 @@ fun AuthScreenContent( ) { AuthTitle( modifier = Modifier.align(Alignment.TopCenter), - state = state.authFieldsState + swipeableState = state.swipeableState, ) AuthFieldsColumn( modifier = Modifier.align(Alignment.Center), state = state ) - AuthBottomText( - modifier = Modifier - .align(Alignment.BottomCenter), - authFieldsState = state.authFieldsState, - onClick = state::onAuthFieldChange - ) } } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt index 74ddfa9..1f44969 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/AuthViewModel.kt @@ -9,7 +9,7 @@ import com.stslex.aproselection.feature.auth.ui.store.AuthStore.Event import com.stslex.aproselection.feature.auth.ui.store.AuthStore.State class AuthViewModel( - private val store: AuthStore, + store: AuthStore, private val navigator: Navigator ) : BaseViewModel(store) { diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt deleted file mode 100644 index dc98f99..0000000 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthBottomText.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.stslex.aproselection.feature.auth.ui.components - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import com.stslex.aproselection.feature.auth.ui.store.AuthStore - -@Composable -fun AuthBottomText( - authFieldsState: AuthStore.AuthFieldsState, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val haptic = LocalHapticFeedback.current - TextButton( - modifier = modifier, - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onClick() - } - ) { - Text( - text = stringResource( - id = authFieldsState.inverse.buttonResId - ), - style = MaterialTheme.typography.titleMedium - ) - } -} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt index 4d1dfa9..991547a 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/components/AuthTitle.kt @@ -1,37 +1,108 @@ package com.stslex.aproselection.feature.auth.ui.components import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SwipeableState +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.stslex.aproselection.core.ui.ext.noRippleClick import com.stslex.aproselection.core.ui.theme.AppDimens import com.stslex.aproselection.core.ui.theme.AppTheme -import com.stslex.aproselection.feature.auth.ui.store.AuthStore +import com.stslex.aproselection.feature.auth.ui.store.AuthStore.AuthFieldsState +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterialApi::class) @Composable fun AuthTitle( - state: AuthStore.AuthFieldsState, + swipeableState: SwipeableState, modifier: Modifier = Modifier ) { - Text( - modifier = modifier.padding( - top = AppDimens.Padding.big - ), - text = stringResource(id = state.titleResId), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1 - ) + val progress = swipeableState.progress.fraction + Row( + modifier = modifier + .fillMaxWidth() + .padding(AppDimens.Padding.medium), + verticalAlignment = Alignment.CenterVertically + ) { + AuthFieldsState.entries.forEach { item -> + val scaleValue by animateFloatAsState( + targetValue = if (item == swipeableState.progress.to) { + (progress * 1.5f) + } else { + (1 - progress) + }.coerceIn(.6f, 1f) + ) + val scaleDividerValue by animateFloatAsState( + targetValue = if (item == swipeableState.progress.to) { + (progress * 1.5f) + } else { + (1 - progress) + }.coerceIn(.1f, 1f) + ) + val coroutineScope = rememberCoroutineScope() + Column( + modifier = Modifier + .noRippleClick { + coroutineScope.launch { + swipeableState.animateTo(item) + } + } + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.scale(scaleValue), + text = stringResource(id = item.titleResId), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground.copy( + alpha = scaleValue + ), + textAlign = TextAlign.Center, + maxLines = 1 + ) + Divider( + modifier = Modifier + .padding(top = AppDimens.Padding.small) + .width(100.dp * scaleDividerValue) + .clip(RoundedCornerShape(AppDimens.Padding.smallest)), + thickness = 4.dp * scaleValue, + color = MaterialTheme.colorScheme.onBackground.copy( + alpha = scaleValue + ) + ) + } + + } + } } +@OptIn(ExperimentalMaterialApi::class) @Preview( device = "id:pixel_6", showSystemUi = true, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL @@ -39,6 +110,12 @@ fun AuthTitle( @Composable fun AuthScreenPreview() { AppTheme { + var currentItem by remember { + mutableStateOf(AuthFieldsState.AUTH) + } + val swipeableState = rememberSwipeableState( + initialValue = AuthFieldsState.AUTH + ) Box( modifier = Modifier .fillMaxSize() @@ -46,7 +123,7 @@ fun AuthScreenPreview() { ) { AuthTitle( modifier = Modifier.align(Alignment.TopCenter), - state = AuthStore.AuthFieldsState.AUTH + swipeableState = swipeableState, ) } } diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt index bf690d8..8455d05 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/model/screen/AuthScreenState.kt @@ -1,10 +1,18 @@ package com.stslex.aproselection.feature.auth.ui.model.screen +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SwipeableState +import androidx.compose.material.rememberSwipeableState import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import com.stslex.aproselection.feature.auth.ui.model.screen.text_field.PasswordInputTextFieldState @@ -20,13 +28,14 @@ import com.stslex.aproselection.feature.auth.ui.store.AuthStore.ScreenLoadingSta @OptIn(ExperimentalComposeUiApi::class) @Stable -data class AuthScreenState( +data class AuthScreenState @OptIn(ExperimentalMaterialApi::class) constructor( val screenLoadingState: ScreenLoadingState = ScreenLoadingState.Content, val usernameState: UsernameTextFieldState, val passwordEnterState: PasswordInputTextFieldState, val passwordSubmitState: PasswordSubmitTextFieldState, val authFieldsState: AuthFieldsState = AuthFieldsState.AUTH, val snackbarHostState: SnackbarHostState, + val swipeableState: SwipeableState, private val processAction: (Action) -> Unit, private val keyboardController: SoftwareKeyboardController? = null ) { @@ -46,13 +55,9 @@ data class AuthScreenState( keyboardController?.hide() processAction(Action.OnSubmitClicked) } - - fun onAuthFieldChange() { - processAction(Action.OnAuthFieldChange) - } } -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class) @Composable fun rememberAuthScreenState( screenState: AuthStore.State, @@ -60,6 +65,7 @@ fun rememberAuthScreenState( processAction: (Action) -> Unit, ): AuthScreenState { val keyboardController = LocalSoftwareKeyboardController.current + val haptic = LocalHapticFeedback.current val usernameTextFieldState = rememberUsernameTextFieldState( text = screenState.username, @@ -76,6 +82,20 @@ fun rememberAuthScreenState( text = screenState.passwordSubmit ) + val swipeableState = rememberSwipeableState( + initialValue = AuthFieldsState.AUTH, + animationSpec = spring( + dampingRatio = Spring.DampingRatioHighBouncy, + stiffness = Spring.StiffnessHigh, + visibilityThreshold = Spring.DefaultDisplacementThreshold + ) + ) + + LaunchedEffect(key1 = swipeableState.currentValue) { + processAction(Action.OnAuthFieldChange(swipeableState.currentValue)) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + return remember(screenState) { AuthScreenState( screenLoadingState = screenState.screenLoadingState, @@ -84,6 +104,7 @@ fun rememberAuthScreenState( authFieldsState = screenState.authFieldsState, snackbarHostState = snackbarHostState, processAction = processAction, + swipeableState = swipeableState, keyboardController = keyboardController, usernameState = usernameTextFieldState ) diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt index e870e04..f748809 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/navigation/AuthRouter.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.stslex.aproselection.core.navigation.destination.AppDestination import com.stslex.aproselection.core.ui.ext.CollectAsEvent import com.stslex.aproselection.feature.auth.ui.AuthScreen import com.stslex.aproselection.feature.auth.ui.AuthViewModel @@ -20,7 +21,7 @@ fun NavGraphBuilder.authRouter( modifier: Modifier = Modifier, ) { composable( - route = com.stslex.aproselection.core.navigation.destination.AppDestination.AUTH.navigationRoute + route = AppDestination.AUTH.navigationRoute ) { val viewModel: AuthViewModel = koinViewModel() @@ -29,8 +30,7 @@ fun NavGraphBuilder.authRouter( viewModel.event.CollectAsEvent { event -> when (event) { - is AuthStore.Event.Navigation.AuthFeature -> TODO() - + is AuthStore.Event.Navigation -> viewModel.processNavigation(event) is AuthStore.Event.ShowSnackbar -> { val actionType = when (event) { is AuthStore.Event.ShowSnackbar.SuccessRegister -> SnackbarActionType.SUCCESS diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStore.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStore.kt index 429ded9..14bacf5 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStore.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStore.kt @@ -40,7 +40,9 @@ interface AuthStore : Store { data object OnSubmitClicked : Action - data object OnAuthFieldChange : Action + data class OnAuthFieldChange( + val targetState: AuthFieldsState + ) : Action sealed class InputAction( open val value: String @@ -72,12 +74,6 @@ interface AuthStore : Store { buttonResId = R.string.auth_button_choose_register, titleResId = R.string.auth_title_register ); - - val inverse: AuthFieldsState - get() = when (this) { - AUTH -> REGISTER - REGISTER -> AUTH - } } sealed interface ScreenLoadingState { diff --git a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStoreImpl.kt b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStoreImpl.kt index 81cc9d2..99e5643 100644 --- a/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStoreImpl.kt +++ b/feature/auth/src/main/java/com/stslex/aproselection/feature/auth/ui/store/AuthStoreImpl.kt @@ -36,7 +36,7 @@ class AuthStoreImpl( is Action.OnSubmitClicked -> processSubmitClicked() is Action.InputAction.PasswordInput -> processPasswordInput(action) is Action.InputAction.PasswordSubmitInput -> processPasswordSubmitInput(action) - is Action.OnAuthFieldChange -> processAuthFieldChange() + is Action.OnAuthFieldChange -> processAuthFieldChange(action) } } @@ -64,10 +64,10 @@ class AuthStoreImpl( } } - private fun processAuthFieldChange() { + private fun processAuthFieldChange(action: Action.OnAuthFieldChange) { updateState { currentValue -> currentValue.copy( - authFieldsState = currentValue.authFieldsState.inverse + authFieldsState = action.targetState ) } } diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml index ddc7bec..16c937f 100644 --- a/feature/auth/src/main/res/values/strings.xml +++ b/feature/auth/src/main/res/values/strings.xml @@ -2,8 +2,8 @@ log in (if not have account) register (if not have account) - Authentication - Registration + Auth + Register username password enter password diff --git a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/store/HomeScreenStoreImpl.kt b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/store/HomeScreenStoreImpl.kt index 7d0d72e..4edb5db 100644 --- a/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/store/HomeScreenStoreImpl.kt +++ b/feature/home/src/main/java/com/stslex/aproselection/feature/home/ui/store/HomeScreenStoreImpl.kt @@ -28,21 +28,22 @@ class HomeScreenStoreImpl( override val state: MutableStateFlow = MutableStateFlow(initialState) - private val users = BasePager - .makePager(interactor::getAllUsers) - .flow - .map { pagingData -> - pagingData.map { user -> - user.toPresentation() + private val users + get() = BasePager + .makePager(interactor::getAllUsers) + .flow + .map { pagingData -> + pagingData.map { user -> + user.toPresentation() + } } - } - .flowOn(Dispatchers.IO) - .cachedIn(scope) - .stateIn( - scope = scope, - started = SharingStarted.Lazily, - initialValue = PagingData.empty() - ) + .flowOn(Dispatchers.IO) + .cachedIn(scope) + .stateIn( + scope = scope, + started = SharingStarted.Lazily, + initialValue = PagingData.empty() + ) override fun processAction(action: Action) { when (action) {