Skip to content

Commit 82bea9f

Browse files
committed
Add UIKitNavigationContentSwipeTest test suite
1 parent 11767c0 commit 82bea9f

2 files changed

Lines changed: 275 additions & 27 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.interop
18+
19+
import androidx.compose.foundation.layout.Box
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.fillMaxSize
22+
import androidx.compose.foundation.layout.fillMaxWidth
23+
import androidx.compose.foundation.layout.height
24+
import androidx.compose.foundation.layout.systemBarsPadding
25+
import androidx.compose.foundation.pager.HorizontalPager
26+
import androidx.compose.foundation.pager.rememberPagerState
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.MutableIntState
29+
import androidx.compose.runtime.mutableIntStateOf
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.background
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.platform.testTag
34+
import androidx.compose.ui.test.AccessibilityTestNode
35+
import androidx.compose.ui.test.UIKitInstrumentedTest
36+
import androidx.compose.ui.test.UIKitInstrumentedTestBlock
37+
import androidx.compose.ui.test.findNodeWithTag
38+
import androidx.compose.ui.test.findNodeWithTagOrNull
39+
import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView
40+
import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController
41+
import androidx.compose.ui.test.utils.up
42+
import androidx.compose.ui.uikit.embedSubview
43+
import androidx.compose.ui.unit.dp
44+
import kotlin.test.Test
45+
import kotlin.test.assertEquals
46+
import kotlin.test.assertNotNull
47+
import kotlin.test.assertNull
48+
import kotlin.time.Duration.Companion.milliseconds
49+
import kotlinx.cinterop.ExperimentalForeignApi
50+
import platform.UIKit.UINavigationController
51+
import platform.UIKit.UIViewController
52+
53+
@OptIn(ExperimentalForeignApi::class)
54+
internal abstract class UIKitNavigationContentSwipeTest(
55+
private val runUIKitInstrumentedTest: (UIKitInstrumentedTestBlock) -> Unit
56+
) {
57+
private val SwipeDuration = 100.milliseconds
58+
59+
@Test
60+
fun testSwipeRightOnPagerDoesNotPopController() = runUIKitInstrumentedTest {
61+
val currentPage = mutableIntStateOf(0)
62+
63+
setNavigationControllerContent {
64+
TestContent(currentPage = currentPage)
65+
}
66+
67+
delay(10)
68+
69+
swipeRight(fromNode = findNodeWithTag("pager"))
70+
71+
assertEquals(0, currentPage.value)
72+
assertEquals(2, navigationController.viewControllers.size)
73+
}
74+
75+
@Test
76+
fun testSwipeLeftOnPagerChangesPage() = runUIKitInstrumentedTest {
77+
val currentPage = mutableIntStateOf(0)
78+
79+
setNavigationControllerContent {
80+
TestContent(currentPage = currentPage)
81+
}
82+
83+
delay(10)
84+
85+
swipeLeft(fromNode = findNodeWithTag("pager"))
86+
87+
assertEquals(1, currentPage.value)
88+
assertEquals(2, navigationController.viewControllers.size)
89+
}
90+
91+
@Test
92+
fun testSwipeLeftOutsidePagerNoChanges() = runUIKitInstrumentedTest {
93+
val initialPage = 1
94+
val currentPage = mutableIntStateOf(initialPage)
95+
96+
setNavigationControllerContent {
97+
TestContent(currentPage = currentPage)
98+
}
99+
100+
delay(10)
101+
102+
swipeLeft(fromNode = findNodeWithTag("outsideBox"))
103+
104+
assertEquals(initialPage, currentPage.value)
105+
assertEquals(2, navigationController.viewControllers.size)
106+
}
107+
108+
@Test
109+
fun testSwipeRightOutsidePagerPopsController() = runUIKitInstrumentedTest {
110+
val initialPage = 1
111+
val currentPage = mutableIntStateOf(initialPage)
112+
113+
setNavigationControllerContent {
114+
TestContent(currentPage = currentPage)
115+
}
116+
117+
delay(10)
118+
119+
swipeRight(fromNode = findNodeWithTag("outsideBox"))
120+
121+
// wait for pop animation to finish
122+
delay(500)
123+
124+
assertNull(findNodeWithTagOrNull("pager"))
125+
assertNull(findNodeWithTagOrNull("outsideBox"))
126+
assertEquals(1, navigationController.viewControllers.size)
127+
}
128+
129+
private fun UIKitInstrumentedTest.swipeRight(fromNode: AccessibilityTestNode) {
130+
fromNode.touchDown()
131+
.dragTo(x = screenSize.width - 16.dp, duration = SwipeDuration)
132+
.up()
133+
134+
waitForIdle()
135+
}
136+
137+
private fun UIKitInstrumentedTest.swipeLeft(fromNode: AccessibilityTestNode) {
138+
fromNode.touchDown()
139+
.dragTo(x = 16.dp, duration = SwipeDuration)
140+
.up()
141+
142+
waitForIdle()
143+
}
144+
145+
private val UIKitInstrumentedTest.navigationController: UINavigationController get() {
146+
return assertNotNull(appDelegate.window?.rootViewController as? UINavigationController)
147+
}
148+
149+
private fun UIKitInstrumentedTest.setNavigationControllerContent(
150+
content: @Composable () -> Unit = {}
151+
) {
152+
val firstViewController = UIViewController()
153+
val secondViewController =
154+
if (useHostingView) {
155+
UIViewController().also {
156+
it.view.embedSubview(createHostingView(content = content))
157+
}
158+
} else {
159+
createHostingViewController(content = content)
160+
}
161+
val navigationController = UINavigationController()
162+
163+
navigationController.setViewControllers(
164+
listOf(firstViewController, secondViewController), false
165+
)
166+
167+
appDelegate.setUpWindow(navigationController)
168+
169+
waitForIdle()
170+
}
171+
}
172+
173+
@Composable
174+
private fun TestContent(
175+
currentPage: MutableIntState
176+
) {
177+
val pagerColors = listOf(Color.Red, Color.Green, Color.Blue)
178+
val pagerState = rememberPagerState(initialPage = currentPage.value) { 3 }
179+
180+
Column(
181+
modifier = Modifier
182+
.fillMaxSize()
183+
.systemBarsPadding()
184+
) {
185+
HorizontalPager(
186+
state = pagerState,
187+
modifier = Modifier
188+
.fillMaxWidth()
189+
.weight(1f)
190+
.testTag("pager")
191+
) { page ->
192+
currentPage.value = page
193+
Box(modifier = Modifier
194+
.fillMaxSize()
195+
.background(pagerColors[page])
196+
)
197+
}
198+
199+
Box(
200+
modifier = Modifier
201+
.fillMaxWidth()
202+
.height(150.dp)
203+
.testTag("outsideBox")
204+
)
205+
}
206+
}
207+
208+
internal class UIKitNavigationContentSwipeInHostingViewTest : UIKitNavigationContentSwipeTest(
209+
runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingView
210+
)
211+
212+
internal class UIKitNavigationContentSwipeInHostingViewControllerTest : UIKitNavigationContentSwipeTest(
213+
runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingViewController
214+
)

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ import platform.darwin.NSObject
107107
import platform.darwin.dispatch_async
108108
import platform.darwin.dispatch_get_main_queue
109109

110+
internal typealias UIKitInstrumentedTestBlock = UIKitInstrumentedTest.() -> Unit
111+
110112
/**
111113
* Sets up the test environment for iOS instrumented tests, runs the given [test][testBlock] against
112114
* UIView- and UIViewController-based Compose Container.
@@ -115,7 +117,12 @@ import platform.darwin.dispatch_get_main_queue
115117
* assertions on it.
116118
* @param [testBlock] The test function.
117119
*/
118-
internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) {
120+
internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTestBlock) {
121+
runUIKitInstrumentedTestInHostingView(testBlock)
122+
runUIKitInstrumentedTestInHostingViewController(testBlock)
123+
}
124+
125+
internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTestBlock) {
119126
println("Debug: Running test with ComposeHostingView")
120127
with(UIKitInstrumentedTest(useHostingView = true)) {
121128
try {
@@ -124,7 +131,9 @@ internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Uni
124131
tearDown()
125132
}
126133
}
134+
}
127135

136+
internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTestBlock) {
128137
println("Debug: Running test with ComposeHostingViewController")
129138
with(UIKitInstrumentedTest(useHostingView = false)) {
130139
try {
@@ -187,7 +196,7 @@ internal fun <T> runUIKitInstrumentedTest(
187196
internal fun runUIKitInstrumentedTest(
188197
ignoreIf: Boolean,
189198
ignoreNotes: String,
190-
testBlock: UIKitInstrumentedTest.() -> Unit
199+
testBlock: UIKitInstrumentedTestBlock
191200
) {
192201
if (ignoreIf) {
193202
println("Debug: Ignored test: $ignoreNotes")
@@ -208,7 +217,7 @@ internal fun runUIKitInstrumentedTest(
208217
*/
209218
@OptIn(ExperimentalForeignApi::class)
210219
internal class UIKitInstrumentedTest(
211-
private val useHostingView: Boolean
220+
val useHostingView: Boolean
212221
) {
213222
companion object {
214223
fun delay(timeoutMillis: Long) {
@@ -283,38 +292,63 @@ internal class UIKitInstrumentedTest(
283292
AccessibilityNotification.onNotificationPostedForTests = {
284293
accessibilityNotifications.add(it)
285294
}
286-
val innerConfigure: ComposeContainerConfiguration.() -> Unit = {
287-
enforceStrictPlistSanityCheck = false
288-
configure()
289-
}
290295

291-
val rootViewController: UIViewController = if (useHostingView) {
292-
hostingView = ComposeHostingView(
293-
configuration = ComposeUIViewConfiguration().apply(innerConfigure),
294-
content = content,
295-
coroutineContext = coroutineContext
296-
)
297-
UIViewController().also {
298-
it.view.embedSubview(hostingView!!)
299-
}
300-
} else {
301-
ComposeHostingViewController(
302-
configuration = ComposeUIViewControllerConfiguration().apply(innerConfigure),
303-
content = content,
304-
coroutineContext = coroutineContext
305-
).also {
306-
hostingViewController = it
307-
}
308-
}
296+
appDelegate.setUpWindow(
297+
createRootViewController(configure, content)
298+
)
309299

310-
appDelegate.setUpWindow(rootViewController)
311300
waitForIdle()
312301

313302
if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) {
314303
delay(700)
315304
}
316305
}
317306

307+
fun createRootViewController(
308+
configure: ComposeContainerConfiguration.() -> Unit = {},
309+
content: @Composable () -> Unit
310+
): UIViewController = if (useHostingView) {
311+
UIViewController().also {
312+
it.view.embedSubview(createHostingView(configure, content))
313+
}
314+
} else {
315+
createHostingViewController(configure, content)
316+
}
317+
318+
fun createHostingView(
319+
configure: ComposeUIViewConfiguration.() -> Unit = {},
320+
content: @Composable () -> Unit
321+
): ComposeHostingView {
322+
val configuration = ComposeUIViewConfiguration()
323+
.apply({ enforceStrictPlistSanityCheck = false })
324+
.apply(configure)
325+
326+
return ComposeHostingView(
327+
configuration = configuration,
328+
content = content,
329+
coroutineContext = coroutineContext
330+
).also {
331+
hostingView = it
332+
}
333+
}
334+
335+
fun createHostingViewController(
336+
configure: ComposeUIViewControllerConfiguration.() -> Unit = {},
337+
content: @Composable () -> Unit
338+
): ComposeHostingViewController {
339+
val configuration = ComposeUIViewControllerConfiguration()
340+
.apply({ enforceStrictPlistSanityCheck = false })
341+
.apply(configure)
342+
343+
return ComposeHostingViewController(
344+
configuration = configuration,
345+
content = content,
346+
coroutineContext = coroutineContext
347+
).also {
348+
this.hostingViewController = it
349+
}
350+
}
351+
318352
fun tearDown() {
319353
// Stop text editing and hide keyboard if any
320354
viewController.view.endEditing(force = true)
@@ -756,4 +790,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() {
756790
} != null
757791
}
758792
delay(500) // wait for toolbar animation
759-
}
793+
}

0 commit comments

Comments
 (0)