Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions resources/android/BottomSheetRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ object BottomSheetRenderer {
val p = node.props
val visible = p.getBool("visible")
val onDismissCb = p.getCallbackId("on_dismiss")
val detentsStr = p.getString("detents", "medium,large")
// A closing sheet keeps the detents it opened with: new ones would get
// it a new SheetState, and ModalBottomSheet shows every new state.
val requestedDetents = p.getString("detents", "medium,large")
var detentsStr by remember { mutableStateOf(requestedDetents) }
if (visible) detentsStr = requestedDetents
val a11yLabel = p.getString("a11y_label")
// Counterpart of iOS `.interactiveDismissDisabled`: block drag-to-hide
// and back-press, and swallow scrim-tap dismiss requests. NOTE:
Expand All @@ -75,8 +79,12 @@ object BottomSheetRenderer {
if (visible) {
shown = true
} else if (shown) {
if (!permanent) sheetState.hide()
shown = false
// A drag or M3's own show() cancels hide(); the sheet still goes.
try {
if (!permanent) sheetState.hide()
} finally {
shown = false
}
}
}

Expand Down
19 changes: 18 additions & 1 deletion tests/BottomSheetRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,27 @@
expect($renderer)
->toBeString()
->not->toMatch('/if \(!visible\) return/')
->toMatch('/LaunchedEffect\(visible\) \{.*?sheetState\.hide\(\)\s*shown = false/s')
->toMatch('/LaunchedEffect\(visible\) \{.*?sheetState\.hide\(\).*?shown = false/s')
->toMatch('/if \(!shown\) return/');
});

test('takes an Android bottom sheet away even when hiding it is cancelled', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/android/BottomSheetRenderer.kt');

expect($renderer)
->toBeString()
->toMatch('/try \{\s*if \(!permanent\) sheetState\.hide\(\)\s*\} finally \{\s*shown = false\s*\}/');
});

test('keeps the detents an Android bottom sheet opened with while it closes', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/android/BottomSheetRenderer.kt');

expect($renderer)
->toBeString()
->toMatch('/var detentsStr by remember \{ mutableStateOf\(requestedDetents\) \}\s*if \(visible\) detentsStr = requestedDetents/')
->not->toMatch('/val detentsStr = p\.getString/');
});

test('wraps an Android fit sheet around its content, opened in full', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/android/BottomSheetRenderer.kt');

Expand Down
7 changes: 6 additions & 1 deletion tests/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This standalone Robolectric/Compose module compiles the production filled and
outlined editors, their shared dispatcher, the production Button renderer and
the production gesture area, list, virtual list and pager renderers.
the production gesture area, list, virtual list, pager and bottom sheet renderers.
It records the bridge event boundary; it does not load PHP/JNI, render icons or
menus, or prove device IME behaviour. The Mobile policy and node models are read
from an explicit checkout, not substituted by test implementations.
Expand Down Expand Up @@ -48,4 +48,9 @@ the list doesn't repeat an applied request, disabling then restoring an index
re-arms it, and saved-state restoration keeps the reader's position unless the
request changed while it was saved. Assertions check which row is at the
viewport's top, not just that a scroll was invoked.

`BottomSheetCloseTest` closes an open sheet and checks its content leaves
composition: when its detents change in the same render, and when a finger
grabs it while it slides away. `RenderNode` here draws each child as
`NodeView` does.
These are JVM checks; SwiftUI and native device behaviour need separate testing.
2 changes: 1 addition & 1 deletion tests/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
}
val stageSources = tasks.register<Sync>("stageSources") {
from(layout.projectDirectory.dir("../../resources/android")) {
include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "VirtualListRenderer.kt", "PagerRenderer.kt")
include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "VirtualListRenderer.kt", "PagerRenderer.kt", "BottomSheetRenderer.kt")
}
from(providers.gradleProperty("mobileRoot").map { "$it/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender" }) {
include("KeyboardFocusPolicy.kt", "NativeUINode.kt", "SharedValueStore.kt")
Expand Down
1 change: 1 addition & 0 deletions tests/android/src/main/kotlin/Bridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ object NativeUIBridge {
fun sendPressEvent(callbackId: Int, nodeId: Int) { events += "press:$callbackId" }
fun sendSliderChangeEvent(callbackId: Int, nodeId: Int, value: Float) { events += "slider:$callbackId:$value" }
fun sendTabChangeEvent(callbackId: Int, nodeId: Int, index: Int) { events += "tab:$callbackId:$index" }
fun sendSheetDismissEvent(callbackId: Int, nodeId: Int) { events += "dismiss:$callbackId" }
}
fun argbToComposeColor(argb: Int) = Color(argb)
4 changes: 4 additions & 0 deletions tests/android/src/main/kotlin/NodeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) {
}
Box(modifier)
}

/** Stands in for core's `RenderNode`, which the bottom sheet draws its children with. */
@Composable
fun RenderNode(node: NativeUINode, modifier: Modifier? = null) = NodeView(node, modifier)
47 changes: 47 additions & 0 deletions tests/android/src/test/kotlin/BottomSheetCloseTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.nativephp.plugins.native_ui.ui

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.nativephp.mobile.ui.nativerender.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BottomSheetCloseTest {
@get:Rule val compose = createComposeRule()

private val sheet = mutableStateOf(sheet(visible = true, detents = "large"))

@Before fun open() {
compose.setContent { MaterialTheme { BottomSheetRenderer.Render(sheet.value, Modifier) } }
compose.onNodeWithTag("node-2").assertExists()
}

@Test fun aSheetClosedWithNewDetentsDoesNotOpenAgain() {
compose.runOnIdle { sheet.value = sheet(visible = false, detents = "medium") }
compose.waitForIdle()
compose.onNodeWithTag("node-2").assertDoesNotExist()
}

@Test fun aSheetGrabbedWhileSlidingAwayStillLeaves() {
compose.mainClock.autoAdvance = false
compose.runOnIdle { sheet.value = sheet(visible = false, detents = "large") }
compose.mainClock.advanceTimeBy(100)
compose.onNodeWithTag("node-2").performTouchInput { down(center); moveBy(Offset(0f, -100f)) }
compose.mainClock.advanceTimeBy(1000)
compose.onNodeWithTag("node-2").assertDoesNotExist()
}

private fun sheet(visible: Boolean, detents: String) = NativeUINode(
1, "bottom_sheet", null, null, GenericProps(mapOf("visible" to visible, "detents" to detents, "on_dismiss" to 9)), 0, 0,
listOf(NativeUINode(2, "", null, null, GenericProps(), 0, 0, emptyList())))
}
Loading