tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 1cd52458c1354d28d6562045e64e2701e0f66848
parent 4fc528d3633d21e60c17cffdf95fae49a5a49ac2
Author: Noah Bond <nbond@mozilla.com>
Date:   Fri, 31 Oct 2025 03:32:04 +0000

Bug 1971415 - Add a shared element transition when transitioning from the Browser/Homescreen to the Tab Manager r=android-reviewers,boek

Differential Revision: https://phabricator.services.mozilla.com/D266263

Diffstat:
Mmobile/android/fenix/app/nimbus.fml.yaml | 9+++++++++
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelper.kt | 5+++++
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelperDelegate.kt | 4++++
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/HomeActivityTestRule.kt | 4++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/TabThumbnail.kt | 63+++++++++++----------------------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/ThumbnailImage.kt | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabGridItem.kt | 2+-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabListItem.kt | 2+-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt | 9++-------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt | 6++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt | 8++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/OpeningAnimation.kt | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt | 360++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationHelper.kt | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationScope.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabGridItem.kt | 22+++++++++++-----------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabListItem.kt | 17++++++++---------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt | 8++++++++
Mmobile/android/fenix/app/src/main/res/values/preference_keys.xml | 1+
Mmobile/android/fenix/app/src/main/res/values/static_strings.xml | 2++
Mmobile/android/fenix/app/src/main/res/xml/secret_settings_preferences.xml | 4++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ui/DefaultTabManagerAnimationHelperTest.kt | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
22 files changed, 1152 insertions(+), 253 deletions(-)

diff --git a/mobile/android/fenix/app/nimbus.fml.yaml b/mobile/android/fenix/app/nimbus.fml.yaml @@ -939,16 +939,25 @@ features: Whether or not to enable the tab management enhancements. type: Boolean default: false + opening_animation_enabled: + description: Whether or not the Tab Manager opening animation is enabled. When enabled, + a shared element transition will initiate, taking the currently-opened tab's thumbnail + and shrinking it into its spot in the Tab Manager. + type: Boolean + default: false defaults: - channel: release value: enabled: false + opening_animation_enabled: false - channel: beta value: enabled: false + opening_animation_enabled: false - channel: nightly value: enabled: true + opening_animation_enabled: true suppress-sponsored-top-sites: description: Suppress sponsored top sites for new users for 14 days. diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelper.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelper.kt @@ -130,6 +130,11 @@ interface FeatureSettingsHelper { var openLinksInExternalApp: OpenLinksInApp /** + * Enable or disable the Tab Manager's opening animation. + */ + var tabManagerOpeningAnimationEnabled: Boolean + + /** * Enable or disable the translations prompt after a page that can be translated is loaded. */ fun enableOrDisablePageLoadTranslationsPrompt(enableTranslationsPrompt: Boolean) { diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelperDelegate.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/FeatureSettingsHelperDelegate.kt @@ -50,6 +50,7 @@ class FeatureSettingsHelperDelegate : FeatureSettingsHelper { isTermsOfServiceAccepted = settings.hasAcceptedTermsOfService, isComposeLoginsEnabled = settings.enableComposeLogins, openLinksInApp = getOpenLinksInApp(settings), + tabManagerOpeningAnimationEnabled = settings.tabManagerOpeningAnimationEnabled, ) /** @@ -78,6 +79,7 @@ class FeatureSettingsHelperDelegate : FeatureSettingsHelper { override var isTermsOfServiceAccepted: Boolean by updatedFeatureFlags::isTermsOfServiceAccepted override var isComposeLoginsEnabled: Boolean by updatedFeatureFlags::isComposeLoginsEnabled override var openLinksInExternalApp: OpenLinksInApp by updatedFeatureFlags::openLinksInApp + override var tabManagerOpeningAnimationEnabled: Boolean by updatedFeatureFlags::tabManagerOpeningAnimationEnabled override fun applyFlagUpdates() { Log.i(TAG, "applyFlagUpdates: Trying to apply the updated feature flags: $updatedFeatureFlags") @@ -116,6 +118,7 @@ class FeatureSettingsHelperDelegate : FeatureSettingsHelper { settings.hasAcceptedTermsOfService = featureFlags.isTermsOfServiceAccepted settings.enableComposeLogins = featureFlags.isComposeLoginsEnabled setOpenLinksInApp(featureFlags.openLinksInApp) + settings.tabManagerOpeningAnimationEnabled = featureFlags.tabManagerOpeningAnimationEnabled } } @@ -142,6 +145,7 @@ private data class FeatureFlags( var isTermsOfServiceAccepted: Boolean, var isComposeLoginsEnabled: Boolean, var openLinksInApp: OpenLinksInApp, + var tabManagerOpeningAnimationEnabled: Boolean, ) internal fun getETPPolicy(settings: Settings): ETPPolicy { diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/HomeActivityTestRule.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/HomeActivityTestRule.kt @@ -208,6 +208,7 @@ class HomeActivityIntentTestRule internal constructor( isTermsOfServiceAccepted: Boolean = true, isComposeLoginsEnabled: Boolean = false, openLinksInExternalApp: OpenLinksInApp = getOpenLinksInApp(settings), + tabManagerOpeningAnimationEnabled: Boolean = false, ) : this(initialTouchMode, launchActivity, skipOnboarding) { this.isHomepageHeaderEnabled = isHomepageHeaderEnabled this.isPocketEnabled = isPocketEnabled @@ -231,6 +232,7 @@ class HomeActivityIntentTestRule internal constructor( this.isTermsOfServiceAccepted = isTermsOfServiceAccepted this.isComposeLoginsEnabled = isComposeLoginsEnabled this.openLinksInExternalApp = openLinksInExternalApp + this.tabManagerOpeningAnimationEnabled = tabManagerOpeningAnimationEnabled } private val longTapUserPreference = getLongPressTimeout() @@ -306,6 +308,7 @@ class HomeActivityIntentTestRule internal constructor( isTermsOfServiceAccepted = settings.hasAcceptedTermsOfService isComposeLoginsEnabled = settings.enableComposeLogins openLinksInExternalApp = getOpenLinksInApp(settings) + tabManagerOpeningAnimationEnabled = settings.tabManagerOpeningAnimationEnabled } companion object { @@ -337,6 +340,7 @@ class HomeActivityIntentTestRule internal constructor( isTabSwipeCFREnabled = true, isTermsOfServiceAccepted = true, isComposeLoginsEnabled = false, + tabManagerOpeningAnimationEnabled = false, ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/TabThumbnail.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/TabThumbnail.kt @@ -5,96 +5,57 @@ package org.mozilla.fenix.compose import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab -import mozilla.components.concept.base.images.ImageLoadRequest import org.mozilla.fenix.theme.FirefoxTheme -private const val FALLBACK_ICON_SIZE = 36 - /** * Thumbnail belonging to a [tab]. If a thumbnail is not available, the favicon * will be displayed until the thumbnail is loaded. * * @param tab The given [TabSessionState] to render a thumbnail for. - * @param size Size of the thumbnail. + * @param thumbnailSizePx Size of the thumbnail in pixels. * @param modifier [Modifier] used to draw the image content. * @param shape [Shape] to be applied to the thumbnail card. - * @param backgroundColor [Color] used for the background of the favicon. * @param border [BorderStroke] to be applied around the thumbnail card. * @param contentDescription Text used by accessibility services * to describe what this image represents. - * @param contentScale [ContentScale] used to draw image content. * @param alignment [Alignment] used to draw the image content. */ @Composable fun TabThumbnail( tab: TabSessionState, - size: Int, + thumbnailSizePx: Int, modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, - backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLowest, border: BorderStroke? = null, contentDescription: String? = null, - contentScale: ContentScale = ContentScale.FillWidth, alignment: Alignment = Alignment.TopCenter, ) { Card( modifier = modifier, shape = shape, - colors = CardDefaults.cardColors(containerColor = backgroundColor), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), border = border, ) { ThumbnailImage( - request = ImageLoadRequest( - id = tab.id, - size = size, - isPrivate = tab.content.private, - ), - contentScale = contentScale, + tab = tab, + thumbnailSizePx = thumbnailSizePx, alignment = alignment, - modifier = modifier, - ) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - val icon = tab.content.icon - if (icon != null) { - icon.prepareToDraw() - Image( - bitmap = icon.asImageBitmap(), - contentDescription = contentDescription, - modifier = Modifier - .size(FALLBACK_ICON_SIZE.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = contentScale, - ) - } else { - Favicon( - url = tab.content.url, - size = FALLBACK_ICON_SIZE.dp, - ) - } - } - } + modifier = Modifier.fillMaxSize(), + contentDescription = contentDescription, + ) } } @@ -104,10 +65,8 @@ private fun ThumbnailCardPreview() { FirefoxTheme { TabThumbnail( tab = createTab(url = "www.mozilla.com", title = "Mozilla"), - size = 108, - modifier = Modifier - .size(108.dp, 80.dp) - .clip(RoundedCornerShape(8.dp)), + thumbnailSizePx = 108, + modifier = Modifier.size(108.dp, 80.dp), ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/ThumbnailImage.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/ThumbnailImage.kt @@ -6,10 +6,16 @@ package org.mozilla.fenix.compose import android.graphics.Bitmap import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,12 +23,15 @@ 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.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab +import mozilla.components.compose.base.theme.information import mozilla.components.compose.base.utils.inComposePreview import mozilla.components.concept.base.images.ImageLoadRequest import org.mozilla.fenix.components.components @@ -31,32 +40,62 @@ import org.mozilla.fenix.theme.FirefoxTheme private val FallbackIconSize = 36.dp /** + * Thumbnail belonging to a [TabSessionState]. Asynchronously fetches the bitmap from storage. + * + * @param tab The [TabSessionState] of the thumbnail to fetch. + * @param thumbnailSizePx The requested size of the thumbnail in pixels. + * @param alignment [Alignment] used to draw the image content. + * @param modifier [Modifier] used to draw the image content. + * @param contentDescription Optional text used by accessibility services to describe what this image + * represents. + */ +@Composable +fun ThumbnailImage( + tab: TabSessionState, + thumbnailSizePx: Int, + alignment: Alignment, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val request = ImageLoadRequest( + id = tab.id, + size = thumbnailSizePx, + isPrivate = tab.content.private, + ) + + ThumbnailImage( + request = request, + modifier = modifier, + alignment = alignment, + fallbackContent = { + FallbackContent( + tab = tab, + modifier = modifier, + contentDescription = contentDescription, + ) + }, + ) +} + +/** * Thumbnail belonging to a [ImageLoadRequest]. Asynchronously fetches the bitmap from storage. * * @param request [ImageLoadRequest] used to fetch the thumbnail bitmap. + * @param modifier [Modifier] used to draw the image content. * @param contentScale [ContentScale] used to draw image content. * @param alignment [Alignment] used to draw the image content. - * @param modifier [Modifier] used to draw the image content. * @param fallbackContent The content to display with a thumbnail is unable to be loaded. */ @Composable fun ThumbnailImage( request: ImageLoadRequest, - contentScale: ContentScale, - alignment: Alignment, modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + alignment: Alignment = Alignment.Center, fallbackContent: @Composable () -> Unit, ) { if (inComposePreview) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Favicon( - url = "", - size = FallbackIconSize, - ) - } + Box(modifier = modifier) } else { var state by remember { mutableStateOf(ThumbnailImageState(null, false)) } val scope = rememberCoroutineScope() @@ -91,7 +130,7 @@ fun ThumbnailImage( } else { state.bitmap?.let { bitmap -> Image( - painter = BitmapPainter(bitmap.asImageBitmap()), + bitmap = bitmap.asImageBitmap(), contentDescription = null, modifier = modifier, contentScale = contentScale, @@ -103,6 +142,51 @@ fun ThumbnailImage( } /** + * The fallback content when a tab thumbnail bitmap is unavailable. + * + * If a favicon is available through [tab], this icon will be used. Otherwise, a new favicon will be fetched. + * + * @param tab [TabSessionState] containing the tab data and potential fallback favicon. + * @param modifier [Modifier] used to draw the image content. + * @param contentDescription Optional text used by accessibility services to describe what this content + * represents. + */ +@Composable +private fun FallbackContent( + tab: TabSessionState, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + Box( + modifier = modifier + .background(color = MaterialTheme.colorScheme.surfaceContainerLowest) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val icon = tab.content.icon?.asImageBitmap() + if (icon != null) { + LaunchedEffect(icon) { + icon.prepareToDraw() + } + + Image( + bitmap = icon, + contentDescription = contentDescription, + modifier = Modifier + .size(FallbackIconSize) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.FillWidth, + ) + } else { + Favicon( + url = tab.content.url, + size = FallbackIconSize, + ) + } + } +} + +/** * State wrapper for [ThumbnailImage]. */ private data class ThumbnailImageState( @@ -117,12 +201,23 @@ private data class ThumbnailImageState( @Composable private fun ThumbnailImagePreview() { FirefoxTheme { - ThumbnailImage( - request = ImageLoadRequest("1", 1, false), - modifier = Modifier.size(50.dp), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - fallbackContent = {}, - ) + Column { + ThumbnailImage( + request = ImageLoadRequest("1", 1, false), + modifier = Modifier.size(50.dp), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + fallbackContent = {}, + ) + + ThumbnailImage( + tab = createTab(url = "www.mozilla.com", title = "Mozilla"), + thumbnailSizePx = 100, + alignment = Alignment.Center, + modifier = Modifier + .size(50.dp) + .background(color = MaterialTheme.colorScheme.information), + ) + } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabGridItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabGridItem.kt @@ -316,7 +316,7 @@ private fun Thumbnail( ) { TabThumbnail( tab = tab, - size = size, + thumbnailSizePx = size, modifier = Modifier.fillMaxSize(), ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabListItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/tabstray/TabListItem.kt @@ -259,7 +259,7 @@ private fun Thumbnail( Box { TabThumbnail( tab = tab, - size = size, + thumbnailSizePx = size, modifier = Modifier .size(width = 92.dp, height = 72.dp) .testTag(TabsTrayTestTag.TAB_ITEM_THUMBNAIL), diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt @@ -142,7 +142,6 @@ private fun RecentTabItem( modifier = Modifier .size(108.dp, 80.dp) .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop, ) Spacer(modifier = Modifier.width(16.dp)) @@ -209,13 +208,11 @@ private fun RecentTabItem( * * @param tab [RecentTab] that was recently viewed. * @param modifier [Modifier] used to draw the image content. - * @param contentScale [ContentScale] used to draw image content. */ @Composable fun RecentTabImage( tab: RecentTab.Tab, modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.FillWidth, ) { val previewImageUrl = tab.state.content.previewImageUrl @@ -229,18 +226,16 @@ fun RecentTabImage( fallback = { TabThumbnail( tab = tab.state, - size = LocalDensity.current.run { THUMBNAIL_SIZE.dp.toPx().toInt() }, + thumbnailSizePx = LocalDensity.current.run { THUMBNAIL_SIZE.dp.toPx().toInt() }, modifier = modifier, - contentScale = contentScale, ) }, ) } else -> TabThumbnail( tab = tab.state, - size = LocalDensity.current.run { THUMBNAIL_SIZE.dp.toPx().toInt() }, + thumbnailSizePx = LocalDensity.current.run { THUMBNAIL_SIZE.dp.toPx().toInt() }, modifier = modifier, - contentScale = contentScale, ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt @@ -325,6 +325,12 @@ class SecretSettingsFragment : PreferenceFragmentCompat() { onPreferenceChangeListener = SharedPreferenceUpdater() } + requirePreference<SwitchPreference>(R.string.pref_key_tab_manager_opening_animation).apply { + isVisible = true + isChecked = context.settings().tabManagerOpeningAnimationEnabled + onPreferenceChangeListener = SharedPreferenceUpdater() + } + requirePreference<SwitchPreference>(R.string.pref_key_terms_accepted).apply { isVisible = Config.channel.isNightlyOrDebug || Config.channel.isBeta isChecked = context.settings().hasAcceptedTermsOfService diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt @@ -31,6 +31,11 @@ interface TabManagementFeatureHelper { * Whether the Tabs Tray enhancements are enabled for the user. */ val enhancementsEnabled: Boolean + + /** + * Whether the Tab Manager opening animation is enabled. + */ + val openingAnimationEnabled: Boolean } /** @@ -55,4 +60,7 @@ data object DefaultTabManagementFeatureHelper : TabManagementFeatureHelper { Config.channel.isRelease -> enhancementsEnabledRelease else -> false } + + override val openingAnimationEnabled: Boolean + get() = Config.channel.isDebug || FxNimbus.features.tabManagementEnhancements.value().openingAnimationEnabled } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/OpeningAnimation.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/OpeningAnimation.kt @@ -0,0 +1,286 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package org.mozilla.fenix.tabstray.ui + +import android.graphics.Path +import android.view.animation.PathInterpolator +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.TabSessionState +import org.mozilla.fenix.compose.TabThumbnail +import kotlin.math.min + +private const val SHARED_ELEMENT_DURATION = 300 +private const val SHARED_ELEMENT_DELAY = 25 +private const val DURATION_ENTER = 400 +private const val DURATION_EXIT = 200 + +// These were largely inspired by the M3 animation docs +// https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#cbea5c6e-7b0d-47a0-98c3-767080a38d95 +private val EmphasizedDecelerateEasing = + Easing { fraction -> emphasizedDecelerate.getInterpolation(fraction) } +private val EmphasizedAccelerateEasing = + Easing { fraction -> emphasizedAccelerate.getInterpolation(fraction) } +private val emphasizedDecelerate = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) +private val emphasizedAccelerate = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) +private val emphasizedPath = Path().apply { + moveTo(0f, 0f) + cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f) + cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f) +} +private val emphasized = PathInterpolator(emphasizedPath) +private val EmphasizedEasing: Easing = Easing { fraction -> emphasized.getInterpolation(fraction) } + +/** + * The animation spec for the Tab Manager transitioning from the fullscreen tab thumbnail -> Tab Manager UI + * and the Tab Manager UI -> fullscreen tab thumbnail. + * + * Fullscreen tab thumbnail -> Tab Manager UI [TabManagerAnimationState.ThumbnailToTabManager] + * The thumbnail shrinks down and locks into its place in the tab grid/list. + * The Tab Manager UI is rendered behind the thumbnail and is revealed as the thumbnail shrinks down. + * + * + * Tab Manager UI -> Fullscreen tab thumbnail [TabManagerAnimationState.TabManagerToThumbnail] + * The thumbnail is enlarged to fit the screen (minus chrome padding). + * The Tab Manager UI fades out. + * + */ +private typealias TabManagerAnimationTransitionScope = AnimatedContentTransitionScope<TabManagerAnimationState> +private val TabManagerTransitionSpec: TabManagerAnimationTransitionScope.() -> ContentTransform = { + when (targetState) { + is TabManagerAnimationState.TabManagerToThumbnail -> scaleIn( // Thumbnail bounds enter spec + tween( + durationMillis = DURATION_ENTER, + easing = EmphasizedDecelerateEasing, + ), + ) togetherWith fadeOut( // Tab manager exit spec + tween( + durationMillis = DURATION_EXIT, + easing = EmphasizedAccelerateEasing, + ), + ) using SizeTransform { _, _ -> + tween( + durationMillis = SHARED_ELEMENT_DURATION, + easing = EmphasizedEasing, + ) + } + TabManagerAnimationState.ThumbnailToTabManager -> fadeIn( // Tab manager enter spec + tween( + durationMillis = DURATION_ENTER, + easing = EmphasizedDecelerateEasing, + delayMillis = SHARED_ELEMENT_DELAY + 50, + ), + ) togetherWith scaleOut( // Thumbnail exit spec + tween( + durationMillis = DURATION_EXIT, + easing = EmphasizedAccelerateEasing, + ), + ) using SizeTransform { _, _ -> + tween( + durationMillis = SHARED_ELEMENT_DURATION, + easing = EmphasizedEasing, + ) + } + } +} + +/** + * Wrapper layout which handles the opening animation for the Tab Manager. + * + * When first opened, the Tab Manager will perform a shared element transition to animate the tab + * thumbnail locking into place into the Tab Manager. + * + * @param tabManagerAnimationHelper The [TabManagerAnimationHelper] used to determine whether to perform + * the transition and what specs to use. + * @param onExitTransitionCompleted Callback invoked when the exit animation has completed. + * @param content The Tab Manager content, typically [org.mozilla.fenix.tabstray.ui.tabstray.TabsTray]. + */ +@Composable +internal fun TabManagerTransitionLayout( + tabManagerAnimationHelper: TabManagerAnimationHelper, + onExitTransitionCompleted: () -> Unit, + content: @Composable () -> Unit, +) { + LaunchedEffect(Unit) { + // On first run, kick-off the transition from the fullscreen thumbnail to the Tab Manager UI + tabManagerAnimationHelper.transitionToTabManager() + } + + SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { + AnimatedContent( + targetState = tabManagerAnimationHelper.state, + transitionSpec = TabManagerTransitionSpec, + label = "TabManagerSharedElement", + ) { targetState -> + CompositionLocalProvider( + LocalTabManagerAnimationScope provides TabManagerAnimationScopeImpl( + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@AnimatedContent, + ), + ) { + when (targetState) { + is TabManagerAnimationState.TabManagerToThumbnail -> { + TabManagerSharedElementThumbnail( + transitionTab = targetState.tab, + transitionPaddingValues = tabManagerAnimationHelper.transitionPaddingValues, + ) + } + TabManagerAnimationState.ThumbnailToTabManager -> { + DisposableEffect(Unit) { + onDispose { + onExitTransitionCompleted.invoke() + } + } + + content() + } + } + } + } + } +} + +/** + * The fullscreen tab thumbnail UI used for the shared element transition. + * + * @param transitionTab The [TabSessionState] of the tab being animated. + * @param transitionPaddingValues The [PaddingValues] to block the animated content and account for + * any app chrome, such as the Toolbar. + */ +@Composable +private fun TabManagerSharedElementThumbnail( + transitionTab: TabSessionState, + transitionPaddingValues: PaddingValues, +) { + BoxWithConstraints( + modifier = Modifier + .windowInsetsPadding(insets = WindowInsets.safeDrawing) + .padding(transitionPaddingValues) + .fillMaxSize(), + ) { + val thumbnailWidth = constraints.maxWidth + val thumbnailHeight = constraints.maxHeight + val thumbnailSize = min(thumbnailWidth, thumbnailHeight) + + TabThumbnail( + tab = transitionTab, + thumbnailSizePx = thumbnailSize, + modifier = Modifier + .sharedTabTransition( + tab = transitionTab, + boundsTransform = TabManagerToThumbnailTransform, + ) + .fillMaxSize(), + shape = RoundedCornerShape(size = 0.dp), + ) + } +} + +/** + * [Modifier] for linking the shared element transition UI between the fullscreen thumbnail and the + * thumbnails in the tab grid/list. + */ +@Composable +internal fun Modifier.sharedTabTransition( + tab: TabSessionState, + boundsTransform: BoundsTransform = ThumbnailToTabManagerTransform, +) = composed { + with(LocalTabManagerAnimationScope.current ?: return@composed Modifier) { + this@sharedTabTransition.then( + Modifier.sharedElement( + boundsTransform = boundsTransform, + sharedContentState = rememberSharedContentState(key = tab.id), + animatedVisibilityScope = this@with, + ), + ) + } +} + +/** + * [BoundsTransform] when transitioning from the Tab Manager to the fullscreen thumbnail. + * + * This will perform a simple size [tween] animation between the start and end bounds. + */ +private val TabManagerToThumbnailTransform = BoundsTransform { _, _ -> + tween( + durationMillis = SHARED_ELEMENT_DURATION, + easing = FastOutSlowInEasing, + ) +} + +/** + * [BoundsTransform] when transitioning from the fullscreen thumbnail to the Tab Manager. + * + * This will perform a shrink and translation so the thumbnail is roughly centered over its spot in + * the Tab Manager. + */ +private val ThumbnailToTabManagerTransform = BoundsTransform { initialBounds, targetBounds -> + keyframes { + fun flerp(a: Float, b: Float, t: Float) = a + (b - a) * t + + fun rectWithCenterAndSize(center: Offset, size: Size): Rect { + val tl = Offset(center.x - size.width / 2f, center.y - size.height / 2f) + return Rect(tl, size) + } + + durationMillis = SHARED_ELEMENT_DURATION +// durationMillis = 3000 + // Initial state + initialBounds at 0 using FastOutSlowInEasing + initialBounds at SHARED_ELEMENT_DELAY // render but delay 50ms +// initialBounds at 0 using EmphasizedAccelerateEasing +// initialBounds at 0 using FastOutSlowInEasing + + // Shrink to 70% of the initial size and move 80% of the way to the center of the target bounds + // by 60% of the animation time + val phase2Size = Size( + width = flerp(initialBounds.width, targetBounds.width, 0.7f), + height = flerp(initialBounds.height, targetBounds.height, 0.7f), + ) + val midCenter2 = Offset( + x = flerp(initialBounds.center.x, targetBounds.center.x, 0.8f), + y = flerp(initialBounds.center.y, targetBounds.center.y, 0.8f), + ) + val phase2Rect = rectWithCenterAndSize(midCenter2, phase2Size) + phase2Rect atFraction 0.6f + + // Finish shrinking the thumbnail to the target bounds + targetBounds atFraction 1.0f + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener @@ -35,6 +36,7 @@ import kotlinx.coroutines.launch import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs +import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.state.TabSessionState import mozilla.components.compose.base.snackbar.displaySnackbar import mozilla.components.concept.base.crash.Breadcrumb @@ -54,6 +56,8 @@ import org.mozilla.fenix.R import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.actualInactiveTabs import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.getBottomToolbarHeight +import org.mozilla.fenix.ext.getTopToolbarHeight import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.pixelSizeFor import org.mozilla.fenix.ext.registerForActivityResult @@ -210,170 +214,224 @@ class TabManagementFragment : DialogFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? = content { - val page by tabsTrayStore.observeAsState(tabsTrayStore.state.selectedPage) { it.selectedPage } - val isPbmLocked by requireComponents.appStore - .observeAsState(initialValue = requireComponents.appStore.state.isPrivateScreenLocked) { - it.isPrivateScreenLocked + ): View? { + // Remove the window dimming so the Toolbar UI from Home/Browser is still visible during the transition + dialog?.window?.setDimAmount(0f) + + return content { + val page by tabsTrayStore.observeAsState(tabsTrayStore.state.selectedPage) { it.selectedPage } + val isPbmLocked by requireComponents.appStore + .observeAsState(initialValue = requireComponents.appStore.state.isPrivateScreenLocked) { + it.isPrivateScreenLocked + } + val density = LocalDensity.current + val tabManagerAnimationHelper = remember { + DefaultTabManagerAnimationHelper( + selectedTab = requireComponents.core.store.state.selectedTab, + animationsEnabled = requireContext().settings().tabManagerOpeningAnimationEnabled, + initialPage = tabsTrayStore.state.selectedPage, + previousDestinationId = findNavController().previousBackStackEntry?.destination?.id, + homepageAsANewTabEnabled = requireContext().settings().enableHomepageAsNewTab, + topToolbarHeight = with(density) { getTopToolbarHeight().toDp() }, + bottomToolbarHeight = with(density) { getBottomToolbarHeight().toDp() }, + ) } - snackbarHostState = remember { SnackbarHostState() } + snackbarHostState = remember { SnackbarHostState() } - BackHandler { - if (tabsTrayStore.state.mode is TabsTrayState.Mode.Select) { - tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) - } else { - onTabsTrayDismissed() + BackHandler { + when { + tabsTrayStore.state.mode is TabsTrayState.Mode.Select -> { + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + tabManagerAnimationHelper.shouldAnimateOnTabManagerOpen -> { + // Perform the transition to return to the selected tab when the back button is pressed + // and the Tab Manager animated on entry. + tabManagerAnimationHelper.leaveTabManager() + } + else -> { + onTabsTrayDismissed() + } + } } - } - - FirefoxTheme(theme = getTabManagerTheme(page = page)) { - val navBarColor = MaterialTheme.colorScheme.surfaceContainerHigh.toArgb() - val statusBarColor = MaterialTheme.colorScheme.surface.toArgb() - LaunchedEffect(page) { - updateSystemBarColors( - navBarColor = navBarColor, - statusBarColor = statusBarColor, - ) - } + FirefoxTheme(theme = getTabManagerTheme(page = page)) { + val navBarColor = MaterialTheme.colorScheme.surfaceContainerHigh.toArgb() + val statusBarColor = MaterialTheme.colorScheme.surface.toArgb() - TabsTray( - tabsTrayStore = tabsTrayStore, - displayTabsInGrid = requireContext().settings().gridTabView, - isInDebugMode = Config.channel.isDebug || - requireComponents.settings.showSecretDebugMenuThisSession, - shouldShowTabAutoCloseBanner = requireContext().settings().shouldShowAutoCloseTabsBanner && - requireContext().settings().canShowCfr, - shouldShowLockPbmBanner = shouldShowLockPbmBanner( - isPrivateMode = (activity as HomeActivity).browsingModeManager.mode.isPrivate, - hasPrivateTabs = requireComponents.core.store.state.privateTabs.isNotEmpty(), - biometricAvailable = BiometricManager.from(requireContext()) - .isHardwareAvailable(), - privateLockEnabled = requireContext().settings().privateBrowsingModeLocked, - shouldShowBanner = shouldShowBanner(requireContext().settings()), - ), - snackbarHostState = snackbarHostState, - isSignedIn = requireContext().settings().signedInFxaAccount, - isPbmLocked = isPbmLocked, - shouldShowInactiveTabsAutoCloseDialog = - requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog, - onTabPageClick = { page -> - onTabPageClick( - tabsTrayInteractor = tabManagerInteractor, - page = page, + LaunchedEffect(page) { + updateSystemBarColors( + navBarColor = navBarColor, + statusBarColor = statusBarColor, ) - }, - onTabClose = { tab -> - tabManagerInteractor.onTabClosed(tab, TAB_MANAGER_FEATURE_NAME) - }, - onTabClick = { tab -> - run outer@{ - if (!requireContext().settings().hasShownTabSwipeCFR && - !requireContext().settings().isTabStripEnabled && - requireContext().settings().isSwipeToolbarToSwitchTabsEnabled - ) { - val normalTabs = tabsTrayStore.state.normalTabs - val currentTabId = tabsTrayStore.state.selectedTabId - - if (normalTabs.size >= 2) { - val currentTabPosition = currentTabId - ?.let { getTabPositionFromId(normalTabs, it) } - ?: return@outer - val newTabPosition = - getTabPositionFromId(normalTabs, tab.id) - - if (abs(currentTabPosition - newTabPosition) == 1) { - requireContext().settings().shouldShowTabSwipeCFR = true - } + } + + TabManagerTransitionLayout( + tabManagerAnimationHelper = tabManagerAnimationHelper, + onExitTransitionCompleted = onExitTransitionCompleted@{ + // The transition has finished so we initiate the tab selection logic. + // Safe check to confirm the state is [ThumbnailToTabManager] and the Tab Manager + // wasn't left to open a new tab or navigate to a screen other than browser/home. + val safeState = + tabManagerAnimationHelper.state as? TabManagerAnimationState.TabManagerToThumbnail + safeState ?: return@onExitTransitionCompleted + + onTabClick(tab = safeState.tab) + }, + ) { + TabsTray( + tabsTrayStore = tabsTrayStore, + displayTabsInGrid = requireContext().settings().gridTabView, + isInDebugMode = Config.channel.isDebug || + requireComponents.settings.showSecretDebugMenuThisSession, + shouldShowTabAutoCloseBanner = requireContext().settings().shouldShowAutoCloseTabsBanner && + requireContext().settings().canShowCfr, + shouldShowLockPbmBanner = shouldShowLockPbmBanner( + isPrivateMode = (activity as HomeActivity).browsingModeManager.mode.isPrivate, + hasPrivateTabs = requireComponents.core.store.state.privateTabs.isNotEmpty(), + biometricAvailable = BiometricManager.from(requireContext()) + .isHardwareAvailable(), + privateLockEnabled = requireContext().settings().privateBrowsingModeLocked, + shouldShowBanner = shouldShowBanner(requireContext().settings()), + ), + snackbarHostState = snackbarHostState, + isSignedIn = requireContext().settings().signedInFxaAccount, + isPbmLocked = isPbmLocked, + shouldShowInactiveTabsAutoCloseDialog = + requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog, + onTabPageClick = { page -> + onTabPageClick( + tabsTrayInteractor = tabManagerInteractor, + page = page, + ) + }, + onTabClose = { tab -> + tabManagerInteractor.onTabClosed(tab, TAB_MANAGER_FEATURE_NAME) + }, + onTabClick = { tab -> + if (tabManagerAnimationHelper.animationsEnabled && + tabsTrayStore.state.mode is TabsTrayState.Mode.Normal + ) { + tabManagerAnimationHelper.transitionToThumbnail(tab = tab) + } else { + onTabClick(tab = tab) + } + }, + onTabLongClick = tabManagerInteractor::onTabLongClicked, + onInactiveTabsHeaderClick = tabManagerInteractor::onInactiveTabsHeaderClicked, + onDeleteAllInactiveTabsClick = tabManagerInteractor::onDeleteAllInactiveTabsClicked, + onInactiveTabsAutoCloseDialogShown = { + tabsTrayStore.dispatch(TabsTrayAction.TabAutoCloseDialogShown) + }, + onInactiveTabAutoCloseDialogCloseButtonClick = + tabManagerInteractor::onAutoCloseDialogCloseButtonClicked, + onEnableInactiveTabAutoCloseClick = { + tabManagerInteractor.onEnableAutoCloseClicked() + showInactiveTabsAutoCloseConfirmationSnackbar() + }, + onInactiveTabClick = tabManagerInteractor::onInactiveTabClicked, + onInactiveTabClose = tabManagerInteractor::onInactiveTabClosed, + onSyncedTabClick = tabManagerInteractor::onSyncedTabClicked, + onSyncedTabClose = tabManagerInteractor::onSyncedTabClosed, + onSignInClick = tabManagerInteractor::onSignInClicked, + onSaveToCollectionClick = tabManagerInteractor::onAddSelectedTabsToCollectionClicked, + onShareSelectedTabsClick = tabManagerInteractor::onShareSelectedTabs, + + onTabSettingsClick = navigationInteractor::onTabSettingsClicked, + onRecentlyClosedClick = navigationInteractor::onOpenRecentlyClosedClicked, + onAccountSettingsClick = navigationInteractor::onAccountSettingsClicked, + onDeleteAllTabsClick = { + if (tabsTrayStore.state.selectedPage == Page.NormalTabs) { + tabsTrayStore.dispatch(TabsTrayAction.CloseAllNormalTabs) + } else if (tabsTrayStore.state.selectedPage == Page.PrivateTabs) { + tabsTrayStore.dispatch(TabsTrayAction.CloseAllPrivateTabs) } - } - } - - tabManagerInteractor.onTabSelected(tab, TAB_MANAGER_FEATURE_NAME) - }, - onTabLongClick = tabManagerInteractor::onTabLongClicked, - onInactiveTabsHeaderClick = tabManagerInteractor::onInactiveTabsHeaderClicked, - onDeleteAllInactiveTabsClick = tabManagerInteractor::onDeleteAllInactiveTabsClicked, - onInactiveTabsAutoCloseDialogShown = { - tabsTrayStore.dispatch(TabsTrayAction.TabAutoCloseDialogShown) - }, - onInactiveTabAutoCloseDialogCloseButtonClick = - tabManagerInteractor::onAutoCloseDialogCloseButtonClicked, - onEnableInactiveTabAutoCloseClick = { - tabManagerInteractor.onEnableAutoCloseClicked() - showInactiveTabsAutoCloseConfirmationSnackbar() - }, - onInactiveTabClick = tabManagerInteractor::onInactiveTabClicked, - onInactiveTabClose = tabManagerInteractor::onInactiveTabClosed, - onSyncedTabClick = tabManagerInteractor::onSyncedTabClicked, - onSyncedTabClose = tabManagerInteractor::onSyncedTabClosed, - onSignInClick = tabManagerInteractor::onSignInClicked, - onSaveToCollectionClick = tabManagerInteractor::onAddSelectedTabsToCollectionClicked, - onShareSelectedTabsClick = tabManagerInteractor::onShareSelectedTabs, - onTabSettingsClick = navigationInteractor::onTabSettingsClicked, - onRecentlyClosedClick = navigationInteractor::onOpenRecentlyClosedClicked, - onAccountSettingsClick = navigationInteractor::onAccountSettingsClicked, - onDeleteAllTabsClick = { - if (tabsTrayStore.state.selectedPage == Page.NormalTabs) { - tabsTrayStore.dispatch(TabsTrayAction.CloseAllNormalTabs) - } else if (tabsTrayStore.state.selectedPage == Page.PrivateTabs) { - tabsTrayStore.dispatch(TabsTrayAction.CloseAllPrivateTabs) - } - navigationInteractor.onCloseAllTabsClicked( - private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, + navigationInteractor.onCloseAllTabsClicked( + private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, + ) + }, + onDeleteSelectedTabsClick = tabManagerInteractor::onDeleteSelectedTabsClicked, + onBookmarkSelectedTabsClick = tabManagerInteractor::onBookmarkSelectedTabsClicked, + onForceSelectedTabsAsInactiveClick = tabManagerInteractor::onForceSelectedTabsAsInactiveClicked, + + onTabsTrayPbmLockedClick = ::onTabsTrayPbmLockedClick, + onTabsTrayPbmLockedDismiss = { + requireContext().settings().shouldShowLockPbmBanner = false + PrivateBrowsingLocked.bannerNegativeClicked.record() + }, + onTabAutoCloseBannerViewOptionsClick = { + navigationInteractor.onTabSettingsClicked() + requireContext().settings().shouldShowAutoCloseTabsBanner = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + }, + onTabAutoCloseBannerDismiss = { + requireContext().settings().shouldShowAutoCloseTabsBanner = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + }, + onTabAutoCloseBannerShown = {}, + onMove = tabManagerInteractor::onTabsMove, + shouldShowInactiveTabsCFR = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup && + requireContext().settings().canShowCfr + }, + onInactiveTabsCFRShown = { + TabsTray.inactiveTabsCfrVisible.record(NoExtras()) + }, + onInactiveTabsCFRClick = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + navigationInteractor.onTabSettingsClicked() + TabsTray.inactiveTabsCfrSettings.record(NoExtras()) + }, + onInactiveTabsCFRDismiss = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + TabsTray.inactiveTabsCfrDismissed.record(NoExtras()) + }, + onOpenNewNormalTabClicked = tabManagerInteractor::onNormalTabsFabClicked, + onOpenNewPrivateTabClicked = tabManagerInteractor::onPrivateTabsFabClicked, + onSyncedTabsFabClicked = tabManagerInteractor::onSyncedTabsFabClicked, + onUnlockPbmClick = { verifyUser(fallbackVerification = verificationResultLauncher) }, ) - }, - onDeleteSelectedTabsClick = tabManagerInteractor::onDeleteSelectedTabsClicked, - onBookmarkSelectedTabsClick = tabManagerInteractor::onBookmarkSelectedTabsClicked, - onForceSelectedTabsAsInactiveClick = tabManagerInteractor::onForceSelectedTabsAsInactiveClicked, - onTabsTrayPbmLockedClick = ::onTabsTrayPbmLockedClick, - onTabsTrayPbmLockedDismiss = { - requireContext().settings().shouldShowLockPbmBanner = false - PrivateBrowsingLocked.bannerNegativeClicked.record() - }, - onTabAutoCloseBannerViewOptionsClick = { - navigationInteractor.onTabSettingsClicked() - requireContext().settings().shouldShowAutoCloseTabsBanner = false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - }, - onTabAutoCloseBannerDismiss = { - requireContext().settings().shouldShowAutoCloseTabsBanner = false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - }, - onTabAutoCloseBannerShown = {}, - onMove = tabManagerInteractor::onTabsMove, - shouldShowInactiveTabsCFR = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup && - requireContext().settings().canShowCfr - }, - onInactiveTabsCFRShown = { - TabsTray.inactiveTabsCfrVisible.record(NoExtras()) - }, - onInactiveTabsCFRClick = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup = false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - navigationInteractor.onTabSettingsClicked() - TabsTray.inactiveTabsCfrSettings.record(NoExtras()) - }, - onInactiveTabsCFRDismiss = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup = false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - TabsTray.inactiveTabsCfrDismissed.record(NoExtras()) - }, - onOpenNewNormalTabClicked = tabManagerInteractor::onNormalTabsFabClicked, - onOpenNewPrivateTabClicked = tabManagerInteractor::onPrivateTabsFabClicked, - onSyncedTabsFabClicked = tabManagerInteractor::onSyncedTabsFabClicked, - onUnlockPbmClick = { verifyUser(fallbackVerification = verificationResultLauncher) }, - ) + } + } } } + private fun onTabClick(tab: TabSessionState) { + if (!requireContext().settings().hasShownTabSwipeCFR && + !requireContext().settings().isTabStripEnabled && + requireContext().settings().isSwipeToolbarToSwitchTabsEnabled + ) { + val normalTabs = tabsTrayStore.state.normalTabs + val currentTabId = tabsTrayStore.state.selectedTabId + + if (normalTabs.size >= 2 && currentTabId != null) { + val currentTabPosition = getTabPositionFromId(normalTabs, currentTabId) + val newTabPosition = getTabPositionFromId(normalTabs, tab.id) + + if (abs(currentTabPosition - newTabPosition) == 1) { + requireContext().settings().shouldShowTabSwipeCFR = + true + } + } + } + + tabManagerInteractor.onTabSelected( + tab = tab, + source = TAB_MANAGER_FEATURE_NAME, + ) + } + override fun onPause() { super.onPause() recordBreadcrumb("TabManagementFragment onPause") diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationHelper.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationHelper.kt @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.Dp +import mozilla.components.browser.state.state.TabSessionState +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.Page + +/** + * An abstraction for handling the shared element transition within the Tab Manager. + */ +internal interface TabManagerAnimationHelper { + + /** + * The currently-selected tab. Null otherwise. + */ + val selectedTab: TabSessionState? + + /** + * Whether the Tab Manager transition animations are enabled. + */ + val animationsEnabled: Boolean + + /** + * The current [TabManagerAnimationState]. + */ + var state: TabManagerAnimationState + + /** + * Whether the Tab Manager should perform the shared element transition when it's first opened. + */ + val shouldAnimateOnTabManagerOpen: Boolean + + /** + * The [PaddingValues] to use in the Tab Manager which account for the visual Chrome present in + * the screen where the Tab Manager was opened. + * + * Note: this should only be non-zero in the Homescreen + Browser. + */ + val transitionPaddingValues: PaddingValues + + /** + * Invoked to trigger the transition from the Tab Manager UI to the fullscreen thumbnail of the + * provided [tab]. + * + * @param tab The [TabSessionState] of the tab being transitioned to. + */ + fun transitionToThumbnail(tab: TabSessionState) + + /** + * Invoked to trigger the transition from the Tab Manager UI to the fullscreen thumbnail of the selected tab. + */ + fun leaveTabManager() + + /** + * Invoked to trigger the transition from the fullscreen thumbnail to the Tab Manager UI. + */ + fun transitionToTabManager() +} + +/** + * An abstraction of the Tab Manager's shared element transition animation states. + */ +sealed interface TabManagerAnimationState { + + /** + * [TabManagerAnimationState] indicating the transition from the Tab Manager to the fullscreen thumbnail + * during the shared element transition. + * + * @property tab The [TabSessionState] used to obtain the thumbnail bitmap. + */ + data class TabManagerToThumbnail(val tab: TabSessionState) : TabManagerAnimationState + + /** + * [TabManagerAnimationState] indicating the transition from the fullscreen thumbnail to the Tab Manager + * during the shared element transition. + */ + data object ThumbnailToTabManager : TabManagerAnimationState +} + +/** + * The default implementation of [TabManagerAnimationHelper]. + * + * @property selectedTab The currently-selected tab. Null otherwise. + * @property animationsEnabled Whether the Tab Manager transition animations are enabled. + * @param initialPage The [Page] the Tab Manager is being opened to when launched. + * @param previousDestinationId The ID of the screen that launched the Tab Manager. + * @param homepageAsANewTabEnabled Whether HNT is enabled and there are homepage tabs. + * @param topToolbarHeight The height of the top toolbar in [Dp]. + * @param bottomToolbarHeight The height of the bottom toolbar in [Dp]. + */ +@Suppress("LongParameterList") +class DefaultTabManagerAnimationHelper( + override val selectedTab: TabSessionState?, + override val animationsEnabled: Boolean, + private val initialPage: Page, + private val previousDestinationId: Int?, + private val homepageAsANewTabEnabled: Boolean, + private val topToolbarHeight: Dp, + private val bottomToolbarHeight: Dp, +) : TabManagerAnimationHelper { + + override val shouldAnimateOnTabManagerOpen: Boolean = when { + !animationsEnabled -> false + // Do not transition when the Tab Manager is opening to the Synced page. + initialPage == Page.SyncedTabs -> false + // Do not transition when there is no selected tab. + selectedTab == null -> false + // Do not transition when the selected tab is Private but the Tab Manager is opening to the Normal page. + selectedTab.content.private && initialPage == Page.NormalTabs -> false + // Do not transition when the selected tab is Normal but the Tab Manager is opening to the Private page. + !selectedTab.content.private && initialPage == Page.PrivateTabs -> false + // Do not transition when the Tab Manager is opened from the Homescreen and HNT is disabled. + previousDestinationId == R.id.homeFragment && homepageAsANewTabEnabled -> true + // Always transition from the Browser. + previousDestinationId == R.id.browserFragment -> true + else -> false + } + + /** + * The initial [TabManagerAnimationState]. + * + * [TabManagerAnimationState.TabManagerToThumbnail] when the animation should run, + * [TabManagerAnimationState.ThumbnailToTabManager] otherwise. + */ + private val initialAnimationState: TabManagerAnimationState = + if (shouldAnimateOnTabManagerOpen && selectedTab != null) { + TabManagerAnimationState.TabManagerToThumbnail(tab = selectedTab) + } else { + TabManagerAnimationState.ThumbnailToTabManager + } + + override var state: TabManagerAnimationState by mutableStateOf(initialAnimationState) + + override val transitionPaddingValues: PaddingValues + get() { + val isPreviousDestinationHomeOrBrowser = previousDestinationId == R.id.homeFragment || + previousDestinationId == R.id.browserFragment + return if (isPreviousDestinationHomeOrBrowser) { + PaddingValues(top = topToolbarHeight, bottom = bottomToolbarHeight) + } else { + PaddingValues() + } + } + + override fun leaveTabManager() { + selectedTab ?: return + transitionToThumbnail(tab = selectedTab) + } + + override fun transitionToTabManager() { + state = TabManagerAnimationState.ThumbnailToTabManager + } + + override fun transitionToThumbnail(tab: TabSessionState) { + state = TabManagerAnimationState.TabManagerToThumbnail(tab = tab) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationScope.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagerAnimationScope.kt @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package org.mozilla.fenix.tabstray.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.CompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import org.mozilla.fenix.tabstray.ui.tabstray.TabsTray + +/** + * Wrapper scope for handling the opening animation in the Tab manager. + * + * This wraps [SharedTransitionScope] and [AnimatedVisibilityScope] so only one scope property needs to be + * passed-down the Compose tree. + */ +internal interface TabManagerAnimationScope : SharedTransitionScope, AnimatedVisibilityScope + +/** + * [CompositionLocal] property for accessing the [TabManagerAnimationScope] anywhere within the + * [TabsTray] Compose tree. + */ +internal val LocalTabManagerAnimationScope = compositionLocalOf<TabManagerAnimationScope?> { null } + +/** + * Default [TabManagerAnimationScope] which wraps all scopes used to perform animations within the Tab Manager. + * + * @param sharedTransitionScope [SharedTransitionScope] to share in the [TabsTray] Compose tree. + * This gives access to [Modifier.sharedElement] provided by the wrapping [SharedTransitionLayout]. + * @param animatedContentScope [AnimatedContentScope] to share in the [TabsTray] Compose tree. + * This provides the wrapping [AnimatedContent]'s scope, which is a dependency of [Modifier.sharedElement]. + */ +internal class TabManagerAnimationScopeImpl( + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, +) : TabManagerAnimationScope, + SharedTransitionScope by sharedTransitionScope, + AnimatedVisibilityScope by animatedContentScope diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabGridItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabGridItem.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.tabstray.ui.tabitems +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -64,6 +65,7 @@ import org.mozilla.fenix.compose.SwipeToDismissState2 import org.mozilla.fenix.compose.TabThumbnail import org.mozilla.fenix.tabstray.TabsTrayTestTag import org.mozilla.fenix.tabstray.ext.toDisplayTitle +import org.mozilla.fenix.tabstray.ui.sharedTabTransition import org.mozilla.fenix.theme.FirefoxTheme import kotlin.math.max import mozilla.components.ui.icons.R as iconsR @@ -333,25 +335,23 @@ private fun clickableColor() = when (isSystemInDarkTheme()) { * @param tab Tab, containing the thumbnail to be displayed. * @param size Size of the thumbnail. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun Thumbnail( tab: TabSessionState, size: Int, ) { - Box( + TabThumbnail( + tab = tab, + thumbnailSizePx = size, modifier = Modifier - .fillMaxSize() .semantics(mergeDescendants = true) { testTag = TabsTrayTestTag.TAB_ITEM_THUMBNAIL - }, - ) { - TabThumbnail( - tab = tab, - size = size, - modifier = Modifier.fillMaxSize(), - shape = ThumbnailShape, - ) - } + } + .sharedTabTransition(tab = tab) + .fillMaxSize(), + shape = ThumbnailShape, + ) } private data class TabGridItemPreviewState( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabListItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabitems/TabListItem.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.tabstray.ui.tabitems +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.BorderStroke @@ -53,6 +54,7 @@ import org.mozilla.fenix.compose.TabThumbnail import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.tabstray.TabsTrayTestTag import org.mozilla.fenix.tabstray.ext.toDisplayTitle +import org.mozilla.fenix.tabstray.ui.sharedTabTransition import org.mozilla.fenix.theme.FirefoxTheme import mozilla.components.browser.tabstray.R as tabstrayR import mozilla.components.ui.icons.R as iconsR @@ -93,7 +95,6 @@ fun TabListItem( val decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay() val density = LocalDensity.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - val thumbnailSize = with(density) { ThumbnailWidth.toPx() }.toInt() val swipeState = remember(multiSelectionEnabled, swipingEnabled) { SwipeToDismissState2( @@ -118,7 +119,6 @@ fun TabListItem( ) { TabContent( tab = tab, - thumbnailSize = thumbnailSize, isSelected = isSelected, multiSelectionEnabled = multiSelectionEnabled, multiSelectionSelected = multiSelectionSelected, @@ -135,7 +135,6 @@ fun TabListItem( @Composable private fun TabContent( tab: TabSessionState, - thumbnailSize: Int, isSelected: Boolean, multiSelectionEnabled: Boolean, multiSelectionSelected: Boolean, @@ -189,10 +188,7 @@ private fun TabContent( }, verticalAlignment = Alignment.CenterVertically, ) { - Thumbnail( - tab = tab, - size = thumbnailSize, - ) + Thumbnail(tab = tab) Column( modifier = Modifier @@ -247,15 +243,18 @@ private fun clickableColor() = when (isSystemInDarkTheme()) { false -> PhotonColors.Black } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun Thumbnail( tab: TabSessionState, - size: Int, ) { + val density = LocalDensity.current + val thumbnailSize = with(density) { ThumbnailWidth.toPx() }.toInt() TabThumbnail( tab = tab, - size = size, + thumbnailSizePx = thumbnailSize, modifier = Modifier + .sharedTabTransition(tab = tab) .size( width = ThumbnailWidth, height = ThumbnailHeight, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -2722,6 +2722,14 @@ class Settings(private val appContext: Context) : PreferencesHolder { ) /** + * Whether the Tab Manager opening animation is enabled. + */ + var tabManagerOpeningAnimationEnabled by booleanPreference( + key = appContext.getPreferenceKey(R.string.pref_key_tab_manager_opening_animation), + default = { DefaultTabManagementFeatureHelper.openingAnimationEnabled }, + ) + + /** * Indicates whether the app should automatically clean up downloaded files. */ fun shouldCleanUpDownloadsAutomatically(): Boolean { diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml @@ -505,4 +505,5 @@ <!-- Tab Manager--> <string name="pref_key_tab_manager_enhancements" translatable="false">pref_key_tab_manager_enhancements</string> + <string name="pref_key_tab_manager_opening_animation" translatable="false">pref_key_tab_manager_opening_animation</string> </resources> diff --git a/mobile/android/fenix/app/src/main/res/values/static_strings.xml b/mobile/android/fenix/app/src/main/res/values/static_strings.xml @@ -120,6 +120,8 @@ <!-- Label for toggling the Tab Manager enhancements --> <string name="preferences_tab_manager_enhancements" translatable="false">Enable Tab Manager enhancements</string> + <!-- Label for toggling the Tab Manager opening animation --> + <string name="preferences_tab_manager_opening_animation" translatable="false">Enable Tab Manager opening animation</string> <string name="preferences_terms_of_use_accepted" translatable="false">Terms of Use accepted</string> <string name="preferences_terms_of_use_debug_timer" translatable="false">30s ToU timer enabled</string> diff --git a/mobile/android/fenix/app/src/main/res/xml/secret_settings_preferences.xml b/mobile/android/fenix/app/src/main/res/xml/secret_settings_preferences.xml @@ -141,6 +141,10 @@ android:title="@string/preferences_tab_manager_enhancements" app:iconSpaceReserved="false" /> <SwitchPreference + android:key="@string/pref_key_tab_manager_opening_animation" + android:title="@string/preferences_tab_manager_opening_animation" + app:iconSpaceReserved="false" /> + <SwitchPreference android:key="@string/pref_key_terms_accepted" android:title="@string/preferences_terms_of_use_accepted" app:iconSpaceReserved="false" /> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ui/DefaultTabManagerAnimationHelperTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/ui/DefaultTabManagerAnimationHelperTest.kt @@ -0,0 +1,244 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.Page + +private val ToolbarHeight = 10.dp +private val ToolbarPaddingValues = PaddingValues(vertical = ToolbarHeight) + +class DefaultTabManagerAnimationHelperTest { + + /** + * [DefaultTabManagerAnimationHelper.animationsEnabled] coverage + */ + + @Test + fun `WHEN the animation feature flag is disabled THEN the opening animation is disabled`() { + assertFalse(createHelper(animationEnabled = false).animationsEnabled) + } + + @Test + fun `WHEN the animation feature flag is enabled THEN the opening animation is enabled`() { + assertTrue(createHelper(animationEnabled = true).animationsEnabled) + } + + /** + * [DefaultTabManagerAnimationHelper.shouldAnimateOnTabManagerOpen] coverage + */ + + @Test + fun `GIVEN the previous destination is the Browser WHEN launching the Tab Manager THEN the opening animation should run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url"), + previousDestinationId = R.id.browserFragment, + ).shouldAnimateOnTabManagerOpen + assertTrue(actualShouldTransition) + } + + @Test + fun `GIVEN the previous destination is the Homescreen and HNT is enabled WHEN launching the Tab Manager THEN the opening animation should run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url"), + previousDestinationId = R.id.homeFragment, + homepageAsANewTabEnabled = true, + ).shouldAnimateOnTabManagerOpen + assertTrue(actualShouldTransition) + } + + @Test + fun `GIVEN the selected tab is null WHEN launching the Tab Manager THEN the opening animation should not run`() { + assertFalse(createHelper(selectedTab = null).shouldAnimateOnTabManagerOpen) + } + + @Test + fun `GIVEN the selected tab is private and the initial page is Normal WHEN launching the Tab Manager THEN the opening animation should not run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url", private = true), + initialPage = Page.NormalTabs, + ).shouldAnimateOnTabManagerOpen + assertFalse(actualShouldTransition) + } + + @Test + fun `GIVEN the selected tab is normal and the initial page is Private WHEN launching the Tab Manager THEN the opening animation should not run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url", private = false), + initialPage = Page.PrivateTabs, + ).shouldAnimateOnTabManagerOpen + assertFalse(actualShouldTransition) + } + + @Test + fun `GIVEN the initial page is Synced WHEN launching the Tab Manager THEN the opening animation should not run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url"), + initialPage = Page.SyncedTabs, + ).shouldAnimateOnTabManagerOpen + assertFalse(actualShouldTransition) + } + + @Test + fun `GIVEN the previous destination is neither the Browser nor Home WHEN launching the Tab Manager THEN the opening animation should not run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url"), + previousDestinationId = R.id.aboutFragment, + ).shouldAnimateOnTabManagerOpen + assertFalse(actualShouldTransition) + } + + @Test + fun `GIVEN the previous destination is Home and HNT is disabled WHEN launching the Tab Manager THEN the opening animation should not run`() { + val actualShouldTransition = createHelper( + selectedTab = createTab(url = "url"), + previousDestinationId = R.id.homeFragment, + homepageAsANewTabEnabled = false, + ).shouldAnimateOnTabManagerOpen + assertFalse(actualShouldTransition) + } + + /** + * [DefaultTabManagerAnimationHelper.state] coverage + */ + + @Test + fun `GIVEN the selected tab is null WHEN the helper is created THEN the initial state should be the thumbnail to tab manager transition`() { + val expectedInitialState = TabManagerAnimationState.ThumbnailToTabManager + val actualInitialState = createHelper(selectedTab = null).state + assertEquals(expectedInitialState, actualInitialState) + } + + @Test + fun `GIVEN there is to be no opening animation WHEN the helper is created THEN the initial state should thumbnail to tab manager transition`() { + val expectedInitialState = TabManagerAnimationState.ThumbnailToTabManager + val actualInitialState = createHelperThatDoesNotTransition().state + assertEquals(expectedInitialState, actualInitialState) + } + + @Test + fun `GIVEN there is to be an opening animation and the selected tab is not null WHEN the helper is created THEN the initial state should be the tab manager to thumbnail transition`() { + val expectedTab = createTab("expected.tab") + val expectedInitialState = TabManagerAnimationState.TabManagerToThumbnail(tab = expectedTab) + val actualInitialState = createHelperThatTransitions(selectedTab = expectedTab).state + assertEquals(expectedInitialState, actualInitialState) + } + + /** + * [DefaultTabManagerAnimationHelper.transitionPaddingValues] coverage + */ + + @Test + fun `GIVEN the previous destination was the Homescreen WHEN launching the Tab Manager THEN the padding values should incorporate the toolbar heights`() { + val actualPaddingValues = createHelperWithToolbar( + previousDestinationId = R.id.homeFragment, + ).transitionPaddingValues + assertEquals(ToolbarPaddingValues, actualPaddingValues) + } + + @Test + fun `GIVEN the previous destination was the Browser WHEN launching the Tab Manager THEN the padding values should incorporate the toolbar heights`() { + val actualPaddingValues = createHelperWithToolbar( + previousDestinationId = R.id.browserFragment, + ).transitionPaddingValues + assertEquals(ToolbarPaddingValues, actualPaddingValues) + } + + @Test + fun `GIVEN the previous destination was neither the Homescreen nor the Browser WHEN launching the Tab Manager THEN the padding values should be zero`() { + val expectedPaddingValues = PaddingValues() + val actualPaddingValues = createHelper( + previousDestinationId = R.id.bookmarkFragment, + ).transitionPaddingValues + assertEquals(expectedPaddingValues, actualPaddingValues) + } + + /** + * [DefaultTabManagerAnimationHelper.leaveTabManager] coverage + */ + + @Test + fun `GIVEN the the selected tab is null WHEN leaving the Tab Manager THEN the tab manager does not animate`() { + val helper = createHelper(selectedTab = null) + val expectedState = TabManagerAnimationState.ThumbnailToTabManager + helper.leaveTabManager() + assertEquals(expectedState, helper.state) + } + + @Test + fun `GIVEN the the selected tab is not null WHEN leaving the Tab Manager THEN the tab manager animates to the fullscreen thumbnail`() { + val tab = createTab(url = "url") + val helper = createHelper(selectedTab = tab) + val expectedState = TabManagerAnimationState.TabManagerToThumbnail(tab = tab) + helper.leaveTabManager() + assertEquals(expectedState, helper.state) + } + + /** + * [DefaultTabManagerAnimationHelper.transitionToTabManager] coverage + */ + + @Test + fun `WHEN requested to transition to the tab manager THEN the tab manager animates into view`() { + val helper = createHelper() + val expectedState = TabManagerAnimationState.ThumbnailToTabManager + helper.leaveTabManager() + assertEquals(expectedState, helper.state) + } + + /** + * [DefaultTabManagerAnimationHelper.transitionToThumbnail] coverage + */ + + @Test + fun `WHEN the tab manager is transitioning to the fullscreen thumbnail THEN the selected tab should be transitioned to`() { + val originalTab = createTab(url = "originalTab") + val transitionTab = createTab(url = "transitionTab") + val helper = createHelper(selectedTab = originalTab) + helper.transitionToThumbnail(tab = transitionTab) + val actualTab = (helper.state as TabManagerAnimationState.TabManagerToThumbnail).tab + assertEquals(transitionTab, actualTab) + } + + private fun createHelperThatTransitions(selectedTab: TabSessionState) = createHelper( + selectedTab = selectedTab, + previousDestinationId = R.id.browserFragment, + ) + + private fun createHelperThatDoesNotTransition() = createHelper(selectedTab = null) + + private fun createHelperWithToolbar(previousDestinationId: Int? = null) = createHelper( + previousDestinationId = previousDestinationId, + topToolbarHeight = ToolbarHeight, + bottomToolbarHeight = ToolbarHeight, + ) + + private fun createHelper( + selectedTab: TabSessionState? = null, + initialPage: Page = Page.NormalTabs, + previousDestinationId: Int? = null, + homepageAsANewTabEnabled: Boolean = false, + topToolbarHeight: Dp = 0.dp, + bottomToolbarHeight: Dp = 0.dp, + animationEnabled: Boolean = true, + ) = DefaultTabManagerAnimationHelper( + selectedTab = selectedTab, + animationsEnabled = animationEnabled, + initialPage = initialPage, + previousDestinationId = previousDestinationId, + homepageAsANewTabEnabled = homepageAsANewTabEnabled, + topToolbarHeight = topToolbarHeight, + bottomToolbarHeight = bottomToolbarHeight, + ) +}