mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
fix(viewer): follow the system bars instead of reserving a strip for them
The chrome reserved `systemBarsIgnoringVisibility` -- the space the bars would occupy whether or not they were on screen. On a punch-hole device that is 142px (54dp, not the usual 24dp: the status bar is sized to clear the camera), so the controls sat ~64dp below the screen edge permanently, and the gap looked like a bug because most of the time nothing was in it. Reserving it was not gratuitous. `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE` paints a peeked bar OVER the content and dispatches no insets at all: measured on a Pixel-class emulator, `statusBars` reads 0 and `isVisible` reads false for the entire time the bar is on screen, byte-identical to the hidden state. With no signal to react to, permanently reserving the space is the only way to keep the buttons from being covered -- which is why the previous code was written that way, and why two attempts to shrink the inset while keeping transient bars both failed on device. So change the premise: ask for BEHAVIOR_DEFAULT. The bars then dispatch real insets (statusBars 0 -> 142, navigationBars 0 -> 63, both `isVisible` flipping), and the chrome can follow them: - `animatedViewerChromeInset()` takes `systemBars` for the relevant edge, floors it at 16dp, and animates. Hidden: the row sits 16dp in (measured top=69). Shown: it moves clear of the bar (142). The floor is not arbitrary -- this display has 132px rounded corners, and a button whose left edge is x=39 needs y >= 38 to stay inside the visible area. - The top display-cutout inset is dropped. Android reports it full-width, but the hole is `Rect(485,0,595,142)` -- 110px of 1080, dead centre. The edge-anchored buttons never overlap it; honouring it pushed them down by the height of a camera they are nowhere near. Horizontal cutout insets stay, for a landscape notch. The animation snaps for 350ms after the chrome appears. Opening moves the inset twice for reasons the user did not cause -- the window has not been told its insets yet (they read 0, indistinguishable from "hidden"), and the immersive effect hides the bars from a DisposableEffect that runs after composition -- and animating either played a slide on open. Two things had to move because they were riding the same inset: - The PDF page counter sat dead centre, which on a punch-hole device put it *under the camera*: measured overlap 56x36px against the lens circle. It now lives along the bottom edge, clear of the cutout, still screen-centred, and tracking the navigation bar. - The image dialog's page dots used `navigationBarsPadding()`. That tracks the bar correctly but moves in a single frame, which read as a jump next to the top controls sliding. They now share the same animated inset. `ViewerControlsRow`'s KDoc described the transient-bar behaviour and the touch-swallowing it worked around. Neither is true of this code any more, so it is rewritten rather than left to mislead. One measurement that did NOT support this change, recorded so it is not rediscovered as evidence: probing the reserved strip with injected taps found 12/12 points from y=8 to y=165 reaching the app, at all three button columns -- the "system swallows touches there" premise did not reproduce. But `tappableElement` reports 142px, injected events are not a finger, and the overlap problem above is reason enough on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
This commit is contained in:
co-authored by
Claude Opus 5
parent
1c25bcb8d3
commit
f4c130d7fe
+81
-14
@@ -24,6 +24,9 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
@@ -31,12 +34,13 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsIgnoringVisibility
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,13 +49,18 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -69,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -77,6 +87,17 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
// viewer. Both are opened the same way (tap a media card in a feed), so they immerse, auto-hide,
|
||||
// and lay their controls out identically.
|
||||
|
||||
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
|
||||
// gesture, so the row snaps through it and only animates afterwards.
|
||||
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
|
||||
|
||||
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
|
||||
// trailing after they have already arrived.
|
||||
private const val CONTROLS_SLIDE_MS = 200
|
||||
|
||||
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
|
||||
private val VIEWER_CHROME_EDGE_GAP = 16.dp
|
||||
|
||||
// How long the controls stay up before the viewer fades them out on its own.
|
||||
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
|
||||
|
||||
@@ -90,7 +111,7 @@ fun ImmersiveSystemBarsEffect(window: Window?) {
|
||||
DisposableEffect(window, view) {
|
||||
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
|
||||
controller?.apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
@@ -133,31 +154,77 @@ fun rememberViewerControlsVisibility(
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the viewer's control row along the top edge.
|
||||
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
|
||||
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
|
||||
* reserving space for one that is not there.
|
||||
*
|
||||
* The viewers hide the system bars, which drops statusBarsPadding() to zero and lands the controls
|
||||
* against the screen edge. That strip stays owned by the system while
|
||||
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE is set -- it is the area watching for the swipe that peeks
|
||||
* the bars back, and there is no API to turn it off -- so touches there never reach the buttons and
|
||||
* only their lower halves respond. Reserving the space the bars would take even while they are
|
||||
* hidden keeps the whole button out of that strip, and keeps the controls from jumping when the
|
||||
* user swipes the bars back in.
|
||||
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
|
||||
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
|
||||
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
|
||||
* -- so nothing here could react to it.
|
||||
*
|
||||
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
|
||||
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
|
||||
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
|
||||
* DisposableEffect that runs after composition. Animating either would play a slide on open.
|
||||
*/
|
||||
@Composable
|
||||
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
|
||||
val density = LocalDensity.current
|
||||
val bars = WindowInsets.systemBars
|
||||
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
|
||||
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
|
||||
|
||||
var settled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
|
||||
settled = true
|
||||
}
|
||||
|
||||
val animated by animateDpAsState(
|
||||
targetValue = target,
|
||||
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
|
||||
label = "viewerChromeInset",
|
||||
)
|
||||
return animated
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a viewer control row along a screen edge -- the top by default, the bottom when [atBottom].
|
||||
*
|
||||
* The viewer hides the system bars, so the row would otherwise sit against the screen edge. It
|
||||
* takes its distance from [animatedViewerChromeInset], which follows the bar on and off screen
|
||||
* rather than permanently reserving room for it.
|
||||
*
|
||||
* Horizontal display-cutout insets are still applied outright: a landscape notch eats into the
|
||||
* sides whatever the bars are doing. The top cutout is deliberately not applied, because on a
|
||||
* punch-hole device it is a centred hole that the edge-anchored buttons are nowhere near -- and
|
||||
* honouring it as a full-width top inset would push them down by the height of a camera they do
|
||||
* not overlap.
|
||||
*
|
||||
* The row also holds a button's height whatever it carries, so content that outlives the buttons
|
||||
* (a page counter) doesn't shift as they come and go.
|
||||
* doesn't shift as they come and go.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun ViewerControlsRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = spacedBy(Size10dp),
|
||||
atBottom: Boolean = false,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
// systemBars is visibility-aware under BEHAVIOR_DEFAULT: 0 while the bars are hidden, and the
|
||||
// real bar size once the user swipes them in -- so the controls follow them instead of sitting
|
||||
// under a bar or reserving space for one that is not there. The 16dp floor keeps them clear of
|
||||
// the rounded corners while hidden. Animated so the row slides rather than jumps.
|
||||
val animatedInset = animatedViewerChromeInset(atBottom)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout),
|
||||
.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
|
||||
.padding(
|
||||
top = if (atBottom) 0.dp else animatedInset,
|
||||
bottom = if (atBottom) animatedInset else 0.dp,
|
||||
).padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = ButtonDefaults.MinHeight),
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
@@ -61,14 +60,15 @@ fun SlidingCarousel(
|
||||
) {
|
||||
HorizontalPager(state = pagerState) { page -> itemContent(page) }
|
||||
|
||||
// you can remove the surface in case you don't want
|
||||
// the transparent bacground
|
||||
// The dots ride the same animated inset as the rest of the viewer chrome, so they slide
|
||||
// with the navigation bar instead of snapping the moment it appears. navigationBarsPadding()
|
||||
// tracks the bar correctly but moves in one frame, which reads as a jump next to the
|
||||
// controls sliding at the top.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 8.dp),
|
||||
.padding(bottom = animatedViewerChromeInset(atBottom = true) + 8.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
) {
|
||||
|
||||
+7
-4
@@ -279,9 +279,11 @@ private fun PdfViewerContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Two rows over the same strip: the buttons keep the edges, and the counter stays centred
|
||||
// on the screen rather than on whatever space the buttons leave -- otherwise it slides
|
||||
// sideways every time the asymmetric button groups fade out from under it.
|
||||
// The buttons hold the top edge; the page counter lives along the bottom. Keeping them on
|
||||
// one strip meant the counter sat dead centre under the display cutout on punch-hole
|
||||
// devices, which is exactly where a front camera is. Down here it is clear of the cutout,
|
||||
// stays centred on the screen rather than on whatever space the asymmetric button groups
|
||||
// leave behind, and reads as wayfinding rather than as another control.
|
||||
ViewerControlsRow(modifier = Modifier.align(Alignment.TopCenter)) {
|
||||
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
|
||||
ViewerBackButton(onDismiss)
|
||||
@@ -302,8 +304,9 @@ private fun PdfViewerContent(
|
||||
|
||||
if (handle != null && handle.pageCount > 0) {
|
||||
ViewerControlsRow(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
atBottom = true,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible.value || pageJustChanged,
|
||||
|
||||
Reference in New Issue
Block a user