Skip to content

Commit 12f442b

Browse files
committed
Add UIKitNavigationContentSwipeTest test suite
1 parent 11767c0 commit 12f442b

2 files changed

Lines changed: 278 additions & 27 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
delay(500)
72+
73+
assertEquals(2, navigationController.viewControllers.size)
74+
assertNotNull(findNodeWithTagOrNull("pager"))
75+
assertEquals(0, currentPage.value)
76+
}
77+
78+
@Test
79+
fun testSwipeLeftOnPagerChangesPage() = runUIKitInstrumentedTest {
80+
val currentPage = mutableIntStateOf(0)
81+
82+
setNavigationControllerContent {
83+
TestContent(currentPage = currentPage)
84+
}
85+
86+
delay(10)
87+
88+
swipeLeft(fromNode = findNodeWithTag("pager"))
89+
90+
assertEquals(1, currentPage.value)
91+
assertEquals(2, navigationController.viewControllers.size)
92+
}
93+
94+
@Test
95+
fun testSwipeLeftOutsidePagerNoChanges() = runUIKitInstrumentedTest {
96+
val initialPage = 1
97+
val currentPage = mutableIntStateOf(initialPage)
98+
99+
setNavigationControllerContent {
100+
TestContent(currentPage = currentPage)
101+
}
102+
103+
delay(10)
104+
105+
swipeLeft(fromNode = findNodeWithTag("outsideBox"))
106+
107+
assertEquals(initialPage, currentPage.value)
108+
assertEquals(2, navigationController.viewControllers.size)
109+
}
110+
111+
@Test
112+
fun testSwipeRightOutsidePagerPopsController() = runUIKitInstrumentedTest {
113+
val initialPage = 1
114+
val currentPage = mutableIntStateOf(initialPage)
115+
116+
setNavigationControllerContent {
117+
TestContent(currentPage = currentPage)
118+
}
119+
120+
delay(10)
121+
122+
swipeRight(fromNode = findNodeWithTag("outsideBox"))
123+
124+
// wait for pop animation to finish
125+
delay(500)
126+
127+
assertNull(findNodeWithTagOrNull("pager"))
128+
assertNull(findNodeWithTagOrNull("outsideBox"))
129+
assertEquals(1, navigationController.viewControllers.size)
130+
}
131+
132+
private fun UIKitInstrumentedTest.swipeRight(fromNode: AccessibilityTestNode) {
133+
fromNode.touchDown()
134+
.dragTo(x = screenSize.width - 16.dp, duration = SwipeDuration)
135+
.up()
136+
137+
waitForIdle()
138+
}
139+
140+
private fun UIKitInstrumentedTest.swipeLeft(fromNode: AccessibilityTestNode) {
141+
fromNode.touchDown()
142+
.dragTo(x = 16.dp, duration = SwipeDuration)
143+
.up()
144+
145+
waitForIdle()
146+
}
147+
148+
private val UIKitInstrumentedTest.navigationController: UINavigationController get() {
149+
return assertNotNull(appDelegate.window?.rootViewController as? UINavigationController)
150+
}
151+
152+
private fun UIKitInstrumentedTest.setNavigationControllerContent(
153+
content: @Composable () -> Unit = {}
154+
) {
155+
val firstViewController = UIViewController()
156+
val secondViewController =
157+
if (useHostingView) {
158+
UIViewController().also {
159+
it.view.embedSubview(createHostingView(content = content))
160+
}
161+
} else {
162+
createHostingViewController(content = content)
163+
}
164+
val navigationController = UINavigationController()
165+
166+
navigationController.setViewControllers(
167+
listOf(firstViewController, secondViewController), false
168+
)
169+
170+
appDelegate.setUpWindow(navigationController)
171+
172+
waitForIdle()
173+
}
174+
}
175+
176+
@Composable
177+
private fun TestContent(
178+
currentPage: MutableIntState
179+
) {
180+
val pagerColors = listOf(Color.Red, Color.Green, Color.Blue)
181+
val pagerState = rememberPagerState(initialPage = currentPage.value) { 3 }
182+
183+
Column(
184+
modifier = Modifier
185+
.fillMaxSize()
186+
.systemBarsPadding()
187+
) {
188+
HorizontalPager(
189+
state = pagerState,
190+
modifier = Modifier
191+
.fillMaxWidth()
192+
.weight(1f)
193+
.testTag("pager")
194+
) { page ->
195+
currentPage.value = page
196+
Box(modifier = Modifier
197+
.fillMaxSize()
198+
.background(pagerColors[page])
199+
)
200+
}
201+
202+
Box(
203+
modifier = Modifier
204+
.fillMaxWidth()
205+
.height(150.dp)
206+
.testTag("outsideBox")
207+
)
208+
}
209+
}
210+
211+
internal class UIKitNavigationContentSwipeInHostingViewTest : UIKitNavigationContentSwipeTest(
212+
runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingView
213+
)
214+
215+
internal class UIKitNavigationContentSwipeInHostingViewControllerTest : UIKitNavigationContentSwipeTest(
216+
runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingViewController
217+
)

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)