Skip to content

Commit 42c5502

Browse files
committed
fix(reader): smooth immersive exit via app-wide edge-to-edge + reachable settings
Leaving the immersive reader showed a visible resize/jump. Two causes, both on the shared single-Activity window: (1) toggling decorFitsSystemWindows back on at reader teardown re-inset the window; (2) immersive hides the system bars, so WindowInsets.systemBars collapses to zero and the incoming screen re-pads the instant the bars reappear at the end of the pop. - Go edge-to-edge once in MainActivity (setDecorFitsSystemWindows(false)) and stop toggling it in the reader, so window geometry is stable across navigation. Android 15 forces this anyway. - Reveal the system bars at back-press (BackHandler + top-bar back) so their insets settle while the destination is still fading in, rather than snapping in at the end of the pop. - Reader insets its content by systemBars when immersive is off; full-bleed when on. - Wrap the few bare screens (welcome, connect-server, cloud-coming-soon, catalog-detail) in a safeDrawing inset; Scaffold screens already handle insets. - Make the reader settings sheet scrollable + nav-bar padded so the "Full-screen reading" toggle (last item) is reachable.
1 parent a3bda73 commit 42c5502

4 files changed

Lines changed: 174 additions & 90 deletions

File tree

app/src/main/java/io/theficos/ereader/MainActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.theficos.ereader
33
import android.content.res.Configuration
44
import android.os.Bundle
55
import androidx.activity.compose.setContent
6+
import androidx.core.view.WindowCompat
67
import androidx.fragment.app.FragmentActivity
78
import io.theficos.ereader.ui.AppNavGraph
89
import io.theficos.ereader.ui.theme.EReaderTheme
@@ -14,6 +15,13 @@ class MainActivity : FragmentActivity() {
1415

1516
override fun onCreate(savedInstanceState: Bundle?) {
1617
super.onCreate(savedInstanceState)
18+
// Go edge-to-edge for the whole app: the single Activity window keeps a stable
19+
// geometry, so entering/leaving the immersive reader never flips decorFits and
20+
// never resizes the shared window (which used to show a visible jump on Back).
21+
// Screens own their insets: Material3 Scaffold/TopAppBar/NavigationBar handle it
22+
// automatically; the few bare screens are wrapped in a safeDrawing inset in
23+
// AppNavGraph. Android 15 forces this anyway.
24+
WindowCompat.setDecorFitsSystemWindows(window, false)
1725
setContent {
1826
EReaderTheme {
1927
AppNavGraph(container = (application as EReaderApp).container)

app/src/main/java/io/theficos/ereader/ui/AppNavGraph.kt

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.theficos.ereader.ui
22

33
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
45
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.WindowInsets
57
import androidx.compose.foundation.layout.fillMaxSize
68
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.layout.safeDrawing
10+
import androidx.compose.foundation.layout.windowInsetsPadding
711
import androidx.compose.material3.MaterialTheme
812
import androidx.compose.material3.Surface
913
import androidx.compose.material3.Text
@@ -78,34 +82,40 @@ fun AppNavGraph(container: AppContainer) {
7882
}
7983
NavHost(navController = nav, startDestination = startDestination) {
8084
composable("welcome") {
81-
WelcomeScreen(
82-
onConnectServer = { nav.navigate("connect-server") },
83-
onCloudSignIn = { nav.navigate("cloud-coming-soon") },
84-
onSkipOffline = {
85-
container.welcomePreferencesStore.markCompleted()
86-
nav.navigate("home") {
87-
popUpTo("welcome") { inclusive = true }
88-
}
89-
},
90-
)
85+
InsetSurface {
86+
WelcomeScreen(
87+
onConnectServer = { nav.navigate("connect-server") },
88+
onCloudSignIn = { nav.navigate("cloud-coming-soon") },
89+
onSkipOffline = {
90+
container.welcomePreferencesStore.markCompleted()
91+
nav.navigate("home") {
92+
popUpTo("welcome") { inclusive = true }
93+
}
94+
},
95+
)
96+
}
9197
}
9298
composable("connect-server") {
9399
val vm = remember {
94100
ConnectServerViewModel(credentialStore = container.credentialStore)
95101
}
96-
ConnectServerScreen(
97-
viewModel = vm,
98-
onBack = { nav.popBackStack() },
99-
onCompleted = {
100-
container.welcomePreferencesStore.markCompleted()
101-
nav.navigate("home") {
102-
popUpTo("welcome") { inclusive = true }
103-
}
104-
},
105-
)
102+
InsetSurface {
103+
ConnectServerScreen(
104+
viewModel = vm,
105+
onBack = { nav.popBackStack() },
106+
onCompleted = {
107+
container.welcomePreferencesStore.markCompleted()
108+
nav.navigate("home") {
109+
popUpTo("welcome") { inclusive = true }
110+
}
111+
},
112+
)
113+
}
106114
}
107115
composable("cloud-coming-soon") {
108-
CloudComingSoonScreen(onBack = { nav.popBackStack() })
116+
InsetSurface {
117+
CloudComingSoonScreen(onBack = { nav.popBackStack() })
118+
}
109119
}
110120
composable("home") {
111121
val libVm = remember {
@@ -247,10 +257,12 @@ fun AppNavGraph(container: AppContainer) {
247257
) { backStack ->
248258
val key = backStack.arguments!!.getString("key")!!
249259
val vm = remember(key) { container.catalogDetailViewModelFactory.create(key) }
250-
if (vm == null) {
251-
CatalogDetailUnavailable(onBack = { nav.popBackStack() })
252-
} else {
253-
CatalogDetailScreen(viewModel = vm, onBack = { nav.popBackStack() })
260+
InsetSurface {
261+
if (vm == null) {
262+
CatalogDetailUnavailable(onBack = { nav.popBackStack() })
263+
} else {
264+
CatalogDetailScreen(viewModel = vm, onBack = { nav.popBackStack() })
265+
}
254266
}
255267
}
256268
composable("scan") {
@@ -277,7 +289,7 @@ fun AppNavGraph(container: AppContainer) {
277289
val key = backStack.arguments!!.getString("key")!!
278290
val data = remember(key) { container.scanResultRegistry.get(key) }
279291
if (data == null) {
280-
CatalogDetailUnavailable(onBack = { nav.popBackStack() })
292+
InsetSurface { CatalogDetailUnavailable(onBack = { nav.popBackStack() }) }
281293
} else {
282294
val vm = remember(key) {
283295
ScanResultViewModel(data = data, ai = container.aiRepository)
@@ -325,6 +337,22 @@ fun AppNavGraph(container: AppContainer) {
325337
}
326338
}
327339

340+
/**
341+
* Full-bleed surface that insets its content by [WindowInsets.safeDrawing] (system bars +
342+
* display cutout + IME). The app is edge-to-edge (see MainActivity), so screens that don't
343+
* already manage their own insets via a Material3 Scaffold/TopAppBar are wrapped in this to
344+
* keep content clear of the system bars and the keyboard. The outer Surface paints behind
345+
* the (opaque) bars so there's no window-background seam.
346+
*/
347+
@Composable
348+
private fun InsetSurface(content: @Composable () -> Unit) {
349+
Surface(modifier = Modifier.fillMaxSize()) {
350+
Box(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
351+
content()
352+
}
353+
}
354+
}
355+
328356
@Composable
329357
private fun CatalogDetailUnavailable(onBack: () -> Unit) {
330358
Surface(modifier = Modifier.fillMaxSize()) {

app/src/main/java/io/theficos/ereader/ui/reader/FontSettingsSheet.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Column
55
import androidx.compose.foundation.layout.Row
66
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.navigationBarsPadding
78
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.rememberScrollState
10+
import androidx.compose.foundation.verticalScroll
811
import androidx.compose.material3.ExperimentalMaterial3Api
912
import androidx.compose.material3.HorizontalDivider
1013
import androidx.compose.material3.MaterialTheme
@@ -34,7 +37,14 @@ fun FontSettingsSheet(
3437
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
3538
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
3639
Column(
37-
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
40+
// Scrollable + nav-bar inset so every control stays reachable — the sheet is
41+
// taller than the screen once all sliders are shown, and the last item
42+
// ("Full-screen reading") otherwise falls off the bottom / under the nav bar.
43+
modifier = Modifier
44+
.fillMaxWidth()
45+
.navigationBarsPadding()
46+
.verticalScroll(rememberScrollState())
47+
.padding(horizontal = 16.dp, vertical = 8.dp),
3848
verticalArrangement = Arrangement.spacedBy(16.dp),
3949
) {
4050
Text("Font size: ${"%.1fx".format(prefs.fontScale)}", style = MaterialTheme.typography.bodyMedium)

app/src/main/java/io/theficos/ereader/ui/reader/ReaderScreen.kt

Lines changed: 101 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import android.view.Window
1212
import android.view.WindowManager
1313
import android.view.accessibility.AccessibilityManager
1414
import android.widget.FrameLayout
15+
import androidx.activity.compose.BackHandler
1516
import androidx.compose.foundation.layout.Box
17+
import androidx.compose.foundation.layout.WindowInsets
1618
import androidx.compose.foundation.layout.fillMaxSize
19+
import androidx.compose.foundation.layout.systemBars
20+
import androidx.compose.foundation.layout.windowInsetsPadding
1721
import androidx.compose.material3.CircularProgressIndicator
1822
import androidx.compose.material3.Text
1923
import androidx.compose.runtime.Composable
@@ -88,6 +92,22 @@ fun ReaderScreen(viewModel: ReaderViewModel, onClose: () -> Unit) {
8892
onDispose { mainActivity.onBeforeReaderConfigChange = null }
8993
}
9094

95+
// Reveal the system bars the instant the user leaves the reader (top-bar back button
96+
// or system/predictive back), before the pop animation starts. Immersive reading hides
97+
// them, which drops WindowInsets.systemBars to zero; if they're only re-shown when the
98+
// reader finishes disposing at the END of the pop, the incoming screen's Scaffold
99+
// re-pads the moment they reappear — a visible resize/jump. Showing them up-front lets
100+
// the insets settle while the destination is still fading in.
101+
val readerView = LocalView.current
102+
val insetsController = remember(activity.window, readerView) {
103+
WindowCompat.getInsetsController(activity.window, readerView)
104+
}
105+
val leaveReader: () -> Unit = {
106+
insetsController.show(WindowInsetsCompat.Type.systemBars())
107+
onClose()
108+
}
109+
BackHandler { leaveReader() }
110+
91111
LaunchedEffect(Unit) { viewModel.load() }
92112

93113
LaunchedEffect(chromeVisible, isDragging, showFontSheet) {
@@ -107,8 +127,6 @@ fun ReaderScreen(viewModel: ReaderViewModel, onClose: () -> Unit) {
107127
ReaderUiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
108128
is ReaderUiState.Error -> Text(s.message, Modifier.align(Alignment.Center))
109129
is ReaderUiState.Open -> {
110-
// Declared before ReaderContent so decor-fits is applied before the navigator
111-
// fragment is created (cold start paginates at final edge-to-edge geometry).
112130
// Scoped to the Open state so Loading/Error keep normal, themed system bars.
113131
ImmersiveWindowEffects(
114132
immersive = preferences.immersiveReading,
@@ -119,64 +137,80 @@ fun ReaderScreen(viewModel: ReaderViewModel, onClose: () -> Unit) {
119137
onExit = viewModel::clearPendingResize,
120138
)
121139

122-
ReaderContent(
123-
publication = s.publication,
124-
initialLocator = s.initialLocator,
125-
preferences = preferences,
126-
onLocator = viewModel::publishLocator,
127-
onNavigatorReady = viewModel::bindNavigator,
128-
onPrev = viewModel::pageBackward,
129-
onNext = viewModel::pageForward,
130-
onToggleChrome = viewModel::toggleChrome,
131-
onPageLoaded = viewModel::completeViewportResize,
132-
)
140+
// The app is edge-to-edge (MainActivity). Immersive reading uses that full
141+
// bleed: content draws behind the (hidden) bars and the chrome self-insets.
142+
// With immersive off, the reader behaves like any normal screen — inset the
143+
// whole subtree (content + chrome) clear of the opaque system bars. Toggling
144+
// this padding is what resizes the WebView on an immersive flip; the
145+
// re-anchor machinery in ImmersiveWindowEffects handles that.
146+
val immersive = preferences.immersiveReading
147+
Box(
148+
modifier = Modifier
149+
.fillMaxSize()
150+
.then(
151+
if (immersive) Modifier
152+
else Modifier.windowInsetsPadding(WindowInsets.systemBars),
153+
),
154+
) {
155+
ReaderContent(
156+
publication = s.publication,
157+
initialLocator = s.initialLocator,
158+
preferences = preferences,
159+
onLocator = viewModel::publishLocator,
160+
onNavigatorReady = viewModel::bindNavigator,
161+
onPrev = viewModel::pageBackward,
162+
onNext = viewModel::pageForward,
163+
onToggleChrome = viewModel::toggleChrome,
164+
onPageLoaded = viewModel::completeViewportResize,
165+
)
133166

134-
ReaderTopBar(
135-
visible = chromeVisible,
136-
title = s.document.title,
137-
onBack = onClose,
138-
onOverflow = { showFontSheet = true },
139-
modifier = Modifier.align(Alignment.TopCenter),
140-
edgeToEdge = preferences.immersiveReading,
141-
)
142-
val positionsList = positions
143-
val locationTotal = positionsList?.size?.takeIf { it > 0 }
144-
val locationIndex = dragPercent?.let { p ->
145-
locationTotal?.let { total ->
146-
(p.coerceIn(0.0, 1.0) * (total - 1)).toInt().coerceIn(0, total - 1) + 1
167+
ReaderTopBar(
168+
visible = chromeVisible,
169+
title = s.document.title,
170+
onBack = leaveReader,
171+
onOverflow = { showFontSheet = true },
172+
modifier = Modifier.align(Alignment.TopCenter),
173+
edgeToEdge = immersive,
174+
)
175+
val positionsList = positions
176+
val locationTotal = positionsList?.size?.takeIf { it > 0 }
177+
val locationIndex = dragPercent?.let { p ->
178+
locationTotal?.let { total ->
179+
(p.coerceIn(0.0, 1.0) * (total - 1)).toInt().coerceIn(0, total - 1) + 1
180+
}
147181
}
148-
}
149-
ReaderBottomBar(
150-
visible = chromeVisible,
151-
chapterTitle = dragPreview?.title
152-
?: liveLocator?.title
153-
?: s.initialLocator?.title,
154-
percent = dragPreview?.locations?.let { it.totalProgression ?: it.progression }
155-
?: liveLocator?.locations?.let { it.totalProgression ?: it.progression }
156-
?: s.savedProgress?.percent ?: 0.0,
157-
enabled = positionsList?.isNotEmpty() == true,
158-
isDragging = isDragging,
159-
locationIndex = locationIndex,
160-
locationTotal = locationTotal,
161-
onSeekChange = { p ->
162-
dragPercent = p
163-
dragPreview = viewModel.previewLocator(p)
164-
},
165-
onSeekFinished = {
166-
dragPercent?.let { viewModel.seek(it) }
167-
dragPercent = null
168-
dragPreview = null
169-
},
170-
modifier = Modifier.align(Alignment.BottomCenter),
171-
edgeToEdge = preferences.immersiveReading,
172-
)
173-
174-
if (showFontSheet) {
175-
FontSettingsSheet(
176-
prefs = preferences,
177-
onChange = { next -> viewModel.updatePreferences(next) },
178-
onDismiss = { showFontSheet = false },
182+
ReaderBottomBar(
183+
visible = chromeVisible,
184+
chapterTitle = dragPreview?.title
185+
?: liveLocator?.title
186+
?: s.initialLocator?.title,
187+
percent = dragPreview?.locations?.let { it.totalProgression ?: it.progression }
188+
?: liveLocator?.locations?.let { it.totalProgression ?: it.progression }
189+
?: s.savedProgress?.percent ?: 0.0,
190+
enabled = positionsList?.isNotEmpty() == true,
191+
isDragging = isDragging,
192+
locationIndex = locationIndex,
193+
locationTotal = locationTotal,
194+
onSeekChange = { p ->
195+
dragPercent = p
196+
dragPreview = viewModel.previewLocator(p)
197+
},
198+
onSeekFinished = {
199+
dragPercent?.let { viewModel.seek(it) }
200+
dragPercent = null
201+
dragPreview = null
202+
},
203+
modifier = Modifier.align(Alignment.BottomCenter),
204+
edgeToEdge = immersive,
179205
)
206+
207+
if (showFontSheet) {
208+
FontSettingsSheet(
209+
prefs = preferences,
210+
onChange = { next -> viewModel.updatePreferences(next) },
211+
onDismiss = { showFontSheet = false },
212+
)
213+
}
180214
}
181215
}
182216
}
@@ -319,10 +353,11 @@ private fun ImmersiveWindowEffects(
319353
// edge-to-edge from the start, so there's nothing to re-anchor.
320354
val firstRun = remember { booleanArrayOf(true) }
321355

322-
// Apply the window mode whenever immersive flips. Arm the re-anchor BEFORE the
323-
// decor-fits change (which resizes the WebView) so Readium's drifted emissions are
324-
// suppressed until onPageChanged (or the fallback below) restores the anchor. Skipped
325-
// on first run — the fragment is created at the final geometry, so no resize occurs.
356+
// Apply the bar cosmetics whenever immersive flips, and arm the re-anchor BEFORE the
357+
// recomposition toggles the reader content's systemBars inset (which resizes the
358+
// WebView) so Readium's drifted emissions are suppressed until onPageChanged (or the
359+
// fallback below) restores the anchor. Skipped on first run — the fragment is created
360+
// at the final geometry, so no resize occurs.
326361
DisposableEffect(immersive) {
327362
if (!firstRun[0]) onBeforeResize()
328363
if (immersive) applyImmersive(window, controller) else restoreBars(window, controller, original)
@@ -395,7 +430,9 @@ private data class OriginalBarState(
395430
)
396431

397432
private fun applyImmersive(window: Window, controller: WindowInsetsControllerCompat) {
398-
WindowCompat.setDecorFitsSystemWindows(window, false)
433+
// NB: decorFitsSystemWindows is set false once, app-wide, in MainActivity — never
434+
// toggled here. Toggling it on the shared single-Activity window resized the window
435+
// on reader exit and showed a visible jump. This only tweaks bar cosmetics/behavior.
399436
window.statusBarColor = AndroidColor.TRANSPARENT
400437
window.navigationBarColor = AndroidColor.TRANSPARENT
401438
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -416,7 +453,8 @@ private fun restoreBars(
416453
controller: WindowInsetsControllerCompat,
417454
original: OriginalBarState,
418455
) {
419-
WindowCompat.setDecorFitsSystemWindows(window, true)
456+
// Colors/appearance/contrast/cutout only — decorFitsSystemWindows stays false
457+
// (owned by MainActivity), so restoring bars never resizes the window.
420458
window.statusBarColor = original.statusColor
421459
window.navigationBarColor = original.navColor
422460
controller.isAppearanceLightStatusBars = original.lightStatus

0 commit comments

Comments
 (0)