Add "fake" chat list bitmap to fake transition.

This commit is contained in:
Alex Hart
2025-10-22 10:50:27 -03:00
committed by Greyson Parrelli
parent bd25447a8f
commit d4c266561f
8 changed files with 252 additions and 32 deletions

View File

@@ -106,6 +106,7 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.ChatNavGraphState
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
@@ -162,6 +163,7 @@ import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppPaneDragHandle
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.AppScaffoldAnimationStateFactory
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
@@ -367,8 +369,9 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
)
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val mutableInteractionSource = remember { MutableInteractionSource() }
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
@@ -377,7 +380,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder()
chatNavGraphBuilder(chatNavGraphState)
}
val callsNavHostController = rememberDetailNavHostController(
@@ -409,7 +412,11 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Chats -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
@@ -427,6 +434,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
listOnlyAnchor, detailOnlyAnchor -> {
true
}
else -> {
false
}
@@ -454,8 +462,17 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
val noEnterTransitionFactory = remember {
AppScaffoldAnimationStateFactory(
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
it == AppScaffoldNavigator.NavigationState.ENTER
}.toSet()
)
}
AppScaffold(
navigator = wrappedNavigator,
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
bottomNavContent = {
@@ -585,7 +602,14 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
mutableInteractionSource = mutableInteractionSource
)
}
} else null
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
}
)
}
}

View File

@@ -87,9 +87,12 @@ import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -596,6 +599,9 @@ class ConversationFragment :
private lateinit var voiceMessageRecordingDelegate: VoiceMessageRecordingDelegate
private val internalDidFirstFrameRender = MutableStateFlow(false)
val didFirstFrameRender: StateFlow<Boolean> = internalDidFirstFrameRender
//region Android Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
@@ -980,6 +986,7 @@ class ConversationFragment :
}
activity?.supportStartPostponedEnterTransition()
internalDidFirstFrameRender.update { true }
val backPressedDelegate = BackPressedDelegate()
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedDelegate)

View File

@@ -5,14 +5,31 @@
package org.thoughtcrime.securesms.main
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
@@ -21,14 +38,23 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass
import kotlin.reflect.typeOf
import kotlin.time.Duration.Companion.milliseconds
fun NavGraphBuilder.chatNavGraphBuilder() {
fun NavGraphBuilder.chatNavGraphBuilder(
chatNavGraphState: ChatNavGraphState
) {
composable<MainNavigationDetailLocation.Empty> {
EmptyDetailScreen()
}
@@ -44,11 +70,38 @@ fun NavGraphBuilder.chatNavGraphBuilder() {
val insets by rememberVerticalInsets()
val insetFlow = remember { snapshotFlow { insets } }
// Because it can take a long time to load content, we use a "fake" chat list image to delay displaying
// the fragment and prevent pop-in
var shouldDisplayFragment by remember { mutableStateOf(false) }
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
val bitmap = chatNavGraphState.chatBitmap
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
val chatAnimationState = transition.chatAnimationState(bitmap != null)
LaunchedEffect(transition.currentState, transition.isRunning) {
if (transition.currentState && !transition.isRunning) {
chatNavGraphState.clearBitmap()
}
}
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.then(fakeChatListAnimationState.toModifier())
.fillMaxSize()
)
}
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.then(chatAnimationState.toModifier())
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) { fragment ->
fragment.viewLifecycleOwner.lifecycleScope.launch {
@@ -58,6 +111,101 @@ fun NavGraphBuilder.chatNavGraphBuilder() {
}
}
}
fragment.viewLifecycleOwner.lifecycleScope.launch {
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
fragment.didFirstFrameRender.collectLatest {
shouldDisplayFragment = it
if (!it) {
delay(150.milliseconds)
shouldDisplayFragment = true
}
}
}
}
}
}
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha by animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset by animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
offset = offset,
alpha = alpha
)
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha by animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
return if (!hasFake) {
AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
offset = 0.dp,
alpha = alpha
)
} else {
val offset by animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
offset = offset,
alpha = alpha
)
}
}
/**
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
* in lieu of proper support for postponing enter transitions.
*/
@Stable
class ChatNavGraphState private constructor(
val windowSizeClass: WindowSizeClass,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(windowSizeClass: WindowSizeClass): ChatNavGraphState {
val graphicsLayer = rememberGraphicsLayer()
return remember(windowSizeClass) {
ChatNavGraphState(
windowSizeClass,
graphicsLayer
)
}
}
}
var chatBitmap: ImageBitmap? by mutableStateOf(null)
private set
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
suspend fun writeGraphicsLayerToBitmap() {
if (WindowSizeClass.isLargeScreenSupportEnabled() && !windowSizeClass.isSplitPane() && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
fun writeContentToGraphicsLayer(): Modifier {
return Modifier.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
hasWrittenToGraphicsLayer = true
}
drawLayer(graphicsLayer)
}
}
fun clearBitmap() {
chatBitmap = null
}
}

View File

@@ -60,7 +60,8 @@ fun EmptyDetailScreen() {
*/
@Composable
fun rememberMainNavigationDetailLocation(
mainNavigationViewModel: MainNavigationViewModel
mainNavigationViewModel: MainNavigationViewModel,
onWillFocusPrimary: suspend () -> Unit = {}
): State<MainNavigationDetailLocation> {
val state = rememberSaveable(
stateSaver = MainNavigationDetailLocation.Saver()
@@ -75,6 +76,7 @@ fun rememberMainNavigationDetailLocation(
if (it == MainNavigationDetailLocation.Empty) {
ThreePaneScaffoldRole.Secondary
} else {
onWillFocusPrimary()
ThreePaneScaffoldRole.Primary
}
)

View File

@@ -221,6 +221,7 @@ enum class WindowSizeClass(
@Composable
fun AppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
@@ -242,7 +243,8 @@ fun AppScaffold(
navRailContent = navRailContent,
bottomNavContent = bottomNavContent,
windowSizeClass = windowSizeClass,
contentWindowInsets = contentWindowInsets
contentWindowInsets = contentWindowInsets,
modifier = modifier
)
return
@@ -255,7 +257,8 @@ fun AppScaffold(
containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets,
topBar = topBarContent,
snackbarHost = snackbarHost
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
NavigableListDetailPaneScaffold(
navigator = navigator,
@@ -353,13 +356,15 @@ private fun ListAndNavigation(
bottomNavContent: @Composable () -> Unit,
snackbarHost: @Composable () -> Unit = {},
windowSizeClass: WindowSizeClass,
contentWindowInsets: WindowInsets
contentWindowInsets: WindowInsets,
modifier: Modifier = Modifier
) {
Scaffold(
containerColor = Color.Transparent,
topBar = topBarContent,
contentWindowInsets = contentWindowInsets,
snackbarHost = snackbarHost
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
Row(
modifier = Modifier

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.window
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
@@ -18,6 +20,16 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Default animation settings for app-scaffold animations.
*/
object AppScaffoldAnimationDefaults {
val TweenEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1f)
val InitAnimationOffset = 48.dp
fun <T> tween() = tween<T>(durationMillis = 200, easing = TweenEasing)
}
/**
* Produces modifier that can be composed into another modifier chain.
* This object allows us to store "latest state" as we transition.
@@ -47,17 +59,31 @@ data class AppScaffoldAnimationState(
* Allows for the customization of the AppScaffold Animators.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
abstract class AppScaffoldAnimationStateFactory {
class AppScaffoldAnimationStateFactory(
val enabledStates: Set<AppScaffoldNavigator.NavigationState> = AppScaffoldNavigator.NavigationState.entries.toSet()
) {
object Default : AppScaffoldAnimationStateFactory()
companion object {
val Default = AppScaffoldAnimationStateFactory()
protected var latestListSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
protected var latestDetailSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
private val EMPTY_STATE = AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
alpha = 1f
)
}
private var latestListSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
private var latestDetailSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
@Composable
fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
if (navigationState !in enabledStates) {
return EMPTY_STATE
}
return when (navigationState) {
AppScaffoldNavigator.NavigationState.INIT -> defaultListInitAnimationState()
AppScaffoldNavigator.NavigationState.ENTER -> defaultListInitAnimationState()
AppScaffoldNavigator.NavigationState.EXIT -> defaultListInitAnimationState()
AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also {
latestListSeekState = it
}
@@ -67,8 +93,13 @@ abstract class AppScaffoldAnimationStateFactory {
@Composable
fun ThreePaneScaffoldPaneScope.getDetailAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
if (navigationState !in enabledStates) {
return EMPTY_STATE
}
return when (navigationState) {
AppScaffoldNavigator.NavigationState.INIT -> defaultDetailInitAnimationState()
AppScaffoldNavigator.NavigationState.ENTER -> defaultDetailInitAnimationState()
AppScaffoldNavigator.NavigationState.EXIT -> defaultDetailInitAnimationState()
AppScaffoldNavigator.NavigationState.SEEK -> defaultDetailSeekAnimationState().also {
latestDetailSeekState = it
}

View File

@@ -5,14 +5,12 @@
package org.thoughtcrime.securesms.window
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
@@ -38,12 +36,10 @@ fun <T> appScaffoldSeekSpring(): FiniteAnimationSpec<T> = spring(
stiffness = SEEK_STIFFNESS
)
private val easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1f)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.animateDp(
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Dp> = { tween(durationMillis = 200, easing = easing) },
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Dp> = { AppScaffoldAnimationDefaults.tween() },
targetWhenHiding: () -> Dp = { 0.dp },
targetWhenShowing: () -> Dp
): State<Dp> {
@@ -63,7 +59,7 @@ fun ThreePaneScaffoldPaneScope.animateDp(
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.animateFloat(
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Float> = { tween(durationMillis = 200, easing = easing) },
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Float> = { AppScaffoldAnimationDefaults.tween() },
targetWhenHiding: () -> Float = { 0f },
targetWhenShowing: () -> Float
): State<Float> {
@@ -85,7 +81,7 @@ fun ThreePaneScaffoldPaneScope.animateFloat(
fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState {
val offset by animateDp(
targetWhenHiding = {
(-48).dp
-AppScaffoldAnimationDefaults.InitAnimationOffset
},
targetWhenShowing = {
0.dp
@@ -97,7 +93,7 @@ fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnima
}
return AppScaffoldAnimationState(
AppScaffoldNavigator.NavigationState.INIT,
AppScaffoldNavigator.NavigationState.ENTER,
alpha = alpha,
offset = offset
)
@@ -169,7 +165,7 @@ fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffol
fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState {
val offset by animateDp(
targetWhenHiding = {
48.dp
AppScaffoldAnimationDefaults.InitAnimationOffset
},
targetWhenShowing = {
0.dp
@@ -181,7 +177,7 @@ fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAni
}
return AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.INIT,
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
alpha = alpha,
offset = offset
)

View File

@@ -31,11 +31,11 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
open class AppScaffoldNavigator<T> @RememberInComposition constructor(private val delegate: ThreePaneScaffoldNavigator<T>) : ThreePaneScaffoldNavigator<T> by delegate {
var state: NavigationState by mutableStateOf(NavigationState.INIT)
var state: NavigationState by mutableStateOf(NavigationState.ENTER)
private set
override suspend fun navigateTo(pane: ThreePaneScaffoldRole, contentKey: T?) {
state = NavigationState.INIT
state = NavigationState.ENTER
return delegate.navigateTo(pane, contentKey)
}
@@ -44,6 +44,10 @@ open class AppScaffoldNavigator<T> @RememberInComposition constructor(private va
state = NavigationState.RELEASE
}
if (state == NavigationState.ENTER) {
state = NavigationState.EXIT
}
return delegate.navigateBack(backNavigationBehavior)
}
@@ -60,11 +64,14 @@ open class AppScaffoldNavigator<T> @RememberInComposition constructor(private va
*/
enum class NavigationState {
/**
* We've navigated to a new pane. This animation is used for both immediate
* pane entry and exit (such as tapping a back button instead of using a
* gesture)
* We've navigated to a new pane.
*/
INIT,
ENTER,
/**
* We've navigated back from a pane without using seek.
*/
EXIT,
/**
* The user is performing a back gesture seek action.