diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt index a581884..f615b43 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt @@ -3,8 +3,10 @@ package dev.sharingan.ui import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.Scaffold @@ -17,6 +19,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection @@ -27,6 +30,15 @@ import dev.sharingan.SharinganEvent import dev.sharingan.SharinganStore import kotlinx.coroutines.delay +// Sheet hosts (iOS page sheet) already sit below the status bar, so the top +// safe-area inset would be paid twice (#42). Full-screen/embedded hosts still +// need it. internal -> not part of the checked public API surface. +internal val LocalStripTopInset = staticCompositionLocalOf { false } + +internal fun sharinganContentInsets(safeDrawing: WindowInsets, stripTop: Boolean): WindowInsets = + if (stripTop) safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + else safeDrawing + /** * The Sharingan log browser: home (three protocol tabs), event detail, and * the share sheet — the whole flow from the design, in one composable. @@ -147,7 +159,7 @@ internal fun SharinganScreenContent( Scaffold( modifier = modifier, containerColor = colors.bg, - contentWindowInsets = WindowInsets.safeDrawing, + contentWindowInsets = sharinganContentInsets(WindowInsets.safeDrawing, LocalStripTopInset.current), ) { innerPadding -> Box( Modifier diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt new file mode 100644 index 0000000..aa6c272 --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt @@ -0,0 +1,24 @@ +package dev.sharingan.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class ScreenInsetsTest { + + @Test + fun `Given strip=true When content insets built Then top side absent`() { + val density = Density(1f) + val source = WindowInsets(left = 7, top = 13, right = 17, bottom = 23) + + val stripped = sharinganContentInsets(source, stripTop = true) + + assertEquals(0, stripped.getTop(density)) + assertEquals(7, stripped.getLeft(density, LayoutDirection.Ltr)) + assertEquals(17, stripped.getRight(density, LayoutDirection.Ltr)) + assertEquals(23, stripped.getBottom(density)) + assertEquals(13, sharinganContentInsets(source, stripTop = false).getTop(density)) + } +} diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt index 99836ac..ffa64da 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt @@ -1,7 +1,9 @@ package dev.sharingan +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.window.ComposeUIViewController import dev.sharingan.internal.topmostViewController +import dev.sharingan.ui.LocalStripTopInset import dev.sharingan.ui.SharinganScreen import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName @@ -13,9 +15,11 @@ import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue /** - * The Sharingan log browser as a `UIViewController` — embed or present it - * however your app likes (sheet, push, debug menu). For the common case, - * call [presentSharingan] instead. + * The Sharingan log browser as a `UIViewController` — embed or push it + * however your app likes (full-screen, navigation stack, debug menu). For + * sheet presentation use [presentSharingan], which compensates for a + * Compose Multiplatform inset quirk (#42); presenting this raw controller + * as your own page sheet shows a phantom top gap. * * A host app whose Info.plist lacks `CADisableMinimumFrameDurationOnPhone` * must never crash (Compose Multiplatform 1.11 aborts on a strict plist @@ -23,11 +27,19 @@ import platform.darwin.dispatch_get_main_queue */ @ObjCName("SharinganViewController", swiftName = "SharinganViewController") @OptIn(ExperimentalObjCName::class) -public fun SharinganViewController(): UIViewController = ComposeUIViewController(configure = { - enforceStrictPlistSanityCheck = false -}) { - SharinganScreen() -} +public fun SharinganViewController(): UIViewController = composeVc(stripTopInset = false) + +// internal — presentSharingan() uses this; strips the phantom top inset (#42): +// a page sheet already sits below the status bar, but CMP 1.11 reads insets +// from the UIWindow, so safeDrawing would pay the top inset twice. +internal fun sharinganSheetViewController(): UIViewController = composeVc(stripTopInset = true) + +private fun composeVc(stripTopInset: Boolean): UIViewController = + ComposeUIViewController(configure = { enforceStrictPlistSanityCheck = false }) { + CompositionLocalProvider(LocalStripTopInset provides stripTopInset) { + SharinganScreen() + } + } /** * Presents the log browser over the topmost view controller of the key @@ -58,6 +70,6 @@ public fun presentSharingan(animated: Boolean = true) { // Guard: UIKit silently swallows a present() while another // presentation/dismissal is already in flight; make the no-op explicit. if (top.isBeingDismissed() || top.isBeingPresented()) return@dispatch_async - top.presentViewController(SharinganViewController(), animated = animated, completion = null) + top.presentViewController(sharinganSheetViewController(), animated = animated, completion = null) } }