Hide toolbar and FAB on scrollable screenshots

Prevent toolbar glitches when taking extended screenshots

ET-6002
This commit is contained in:
Niccolò Forlini
2026-04-30 15:54:27 +02:00
committed by MargeBot
parent 4fcbbe2cb6
commit 625f763d75
3 changed files with 106 additions and 43 deletions
@@ -57,6 +57,7 @@ fun FloatingBottomToolbar(
viewActionCallbacks: BottomActionBar.Actions,
modifier: Modifier = Modifier
) {
val hasWindowFocus by rememberWindowFocusState()
val isVisible = state is BottomBarState.Data.Shown
val lastShownState = remember { mutableStateOf<BottomBarState.Data.Shown?>(null) }
@@ -82,6 +83,11 @@ fun FloatingBottomToolbar(
label = "contentAlpha"
)
// Skip the whole surface while the window is unfocused so an OEM extended
// screenshot can't capture the toolbar. A normal hide (e.g. selection cleared)
// still goes through the existing fade-out via isVisible.
if (!hasWindowFocus) return
Surface(
modifier = modifier.height(FloatingToolbarHeight),
shape = RoundedCornerShape(percent = 50),
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Mail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcommon.presentation.ui
import android.view.ViewTreeObserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
/**
* Tracks whether the hosting window currently has focus.
*
* Why: when the OS triggers a screenshot session, including OEM "extended/scrolling"
* screenshots that programmatically scroll the underlying list, the activity loses
* window focus. Floating overlays that gate their visibility on this signal will be
* hidden for the duration of the capture, preventing them from being stitched into
* every frame.
*/
@Composable
fun rememberWindowFocusState(): State<Boolean> {
val view = LocalView.current
val state = remember { mutableStateOf(view.hasWindowFocus()) }
DisposableEffect(view) {
val listener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
state.value = hasFocus
}
view.viewTreeObserver.addOnWindowFocusChangeListener(listener)
onDispose { view.viewTreeObserver.removeOnWindowFocusChangeListener(listener) }
}
return state
}
@@ -56,6 +56,7 @@ import ch.protonmail.android.mailcommon.presentation.compose.MailDimens
import ch.protonmail.android.mailcommon.presentation.model.BottomBarState
import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBar
import ch.protonmail.android.mailcommon.presentation.ui.FloatingToolbarActionIcons
import ch.protonmail.android.mailcommon.presentation.ui.rememberWindowFocusState
import ch.protonmail.android.mailmailbox.presentation.R
import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilterState
@@ -111,6 +112,8 @@ internal fun MailboxFabToolbarMorph(
label = "horizontalBias"
) { inSelection -> if (inSelection) 0f else 1f }
val hasWindowFocus by rememberWindowFocusState()
Box(
modifier = modifier
.padding(bottom = snackbarOffset)
@@ -141,7 +144,9 @@ internal fun MailboxFabToolbarMorph(
animationSpec = unreadSpring,
label = "unreadTranslationY"
)
if (showUnreadFilter || unreadAlpha > 0f) {
// Skip drawing the floating overlays as soon as the window loses focus,
// so an OEM extended screenshot can't capture them.
if (hasWindowFocus && (showUnreadFilter || unreadAlpha > 0f)) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
@@ -162,52 +167,54 @@ internal fun MailboxFabToolbarMorph(
}
// FAB / Toolbar morph animates from bottom end (FAB) to center (toolbar)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(ShadowClipGuard),
contentAlignment = BiasAlignment(horizontalBias = horizontalBias, verticalBias = 0f)
) {
Surface(
if (hasWindowFocus) {
Box(
modifier = Modifier
.width(containerWidth)
.height(FabSize),
shape = RoundedCornerShape(percent = 50),
shadowElevation = ProtonDimens.ShadowElevation.Mini,
color = ProtonTheme.colors.interactionFabNorm
.fillMaxWidth()
.padding(ShadowClipGuard),
contentAlignment = BiasAlignment(horizontalBias = horizontalBias, verticalBias = 0f)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.clickable(enabled = !isInSelectionMode) { onComposeClick() }
Surface(
modifier = Modifier
.width(containerWidth)
.height(FabSize),
shape = RoundedCornerShape(percent = 50),
shadowElevation = ProtonDimens.ShadowElevation.Mini,
color = ProtonTheme.colors.interactionFabNorm
) {
// FAB icon
Icon(
painter = painterResource(id = R.drawable.ic_proton_pen_square),
contentDescription = stringResource(
id = R.string.mailbox_fab_compose_button_content_description
),
tint = ProtonTheme.colors.textNorm,
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.clickable(enabled = !isInSelectionMode) { onComposeClick() }
) {
// FAB icon
Icon(
painter = painterResource(id = R.drawable.ic_proton_pen_square),
contentDescription = stringResource(
id = R.string.mailbox_fab_compose_button_content_description
),
tint = ProtonTheme.colors.textNorm,
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
)
// Toolbar actions keep in composition while animating, remove once done
// so invisible IconButtons don't steal hits from the FAB.
val shownData = lastShownState.value
val isToolbarActive = isInSelectionMode || transition.currentState != transition.targetState
if (shownData != null && isToolbarActive) {
Row(
modifier = Modifier
.graphicsLayer { alpha = toolbarAlpha }
.fillMaxWidth()
.padding(horizontal = ToolbarHorizontalPadding),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
FloatingToolbarActionIcons(
actions = shownData.actions,
target = shownData.target,
viewActionCallbacks = bottomBarActions
)
// Toolbar actions keep in composition while animating, remove once done
// so invisible IconButtons don't steal hits from the FAB.
val shownData = lastShownState.value
val isToolbarActive = isInSelectionMode || transition.currentState != transition.targetState
if (shownData != null && isToolbarActive) {
Row(
modifier = Modifier
.graphicsLayer { alpha = toolbarAlpha }
.fillMaxWidth()
.padding(horizontal = ToolbarHorizontalPadding),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
FloatingToolbarActionIcons(
actions = shownData.actions,
target = shownData.target,
viewActionCallbacks = bottomBarActions
)
}
}
}
}