From f74652b336d10054d44edea552741f8a21a36dc4 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 24 Apr 2026 15:21:05 +0200 Subject: [PATCH 1/8] Initial support of Pan and Zoom --- .../gestures/UikitScrollable.ios.kt | 7 +- .../demo/IosSpecificFeaturesExample.ios.kt | 1 + .../mpp/demo/PanPinchCircleExample.ios.kt | 97 ++++ .../CMPUIKitUtils.xcodeproj/project.pbxproj | 68 ++- .../CMPUIKitUtils/CMPPinchGestureRecognizer.h | 35 ++ .../CMPUIKitUtils/CMPPinchGestureRecognizer.m | 41 ++ .../CMPUIKitUtils/CMPUIKitUtils.h | 1 + .../ui/scene/ComposeSceneMediator.ios.kt | 67 ++- .../compose/ui/window/InputViews.ios.kt | 106 ++++ .../androidx/compose/ui/Configuration.kt | 2 + .../compose/ui/interaction/TrackpadPanTest.kt | 122 +++++ .../compose/ui/test/UIKitInstrumentedTest.kt | 48 ++ .../compose/ui/test/utils/UIEvent+Utils.kt | 65 +++ .../CMPTestUtils.xcodeproj/project.pbxproj | 349 +++++++++++- .../xcschemes/CMPTestUtils.xcscheme | 2 +- .../xcschemes/CMPTestUtilsApp.xcscheme | 95 ++++ .../xcschemes/CMPTestUtilsAppTests.xcscheme | 127 +++++ .../iosMain/objc/CMPTestUtils/CMPTestUtils.h | 1 + .../iosMain/objc/CMPTestUtils/UIEvent+Test.h | 92 ++++ .../iosMain/objc/CMPTestUtils/UIEvent+Test.m | 505 ++++++++++++++++++ .../CMPTestUtilsApp/CMPTestUtilsAppApp.swift | 26 + .../CMPTestUtilsAppTests-Bridging-Header.h | 17 + .../objc/CMPTestUtilsAppTests/HoverTest.swift | 114 ++++ .../MockAppDelegate.swift | 36 ++ .../objc/CMPTestUtilsAppTests/PanTest.swift | 169 ++++++ .../objc/CMPTestUtilsAppTests/PinchTest.swift | 167 ++++++ 26 files changed, 2328 insertions(+), 32 deletions(-) create mode 100644 compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/PanPinchCircleExample.ios.kt create mode 100644 compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.h create mode 100644 compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.m create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsApp.xcscheme create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsAppTests.xcscheme create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsApp/CMPTestUtilsAppApp.swift create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/MockAppDelegate.swift create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt index fe8153e24fdff..2da2650a0522e 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt @@ -30,10 +30,13 @@ internal actual fun CompositionLocalConsumerModifierNode.platformScrollConfig(): internal object UiKitScrollConfig : ScrollConfig { override fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset = event.changes.fastFold(Offset.Zero) { acc, c -> - if (event.type == PointerEventType.PanMove) { + if (event.type == PointerEventType.PanStart || + event.type == PointerEventType.PanMove || + event.type == PointerEventType.PanEnd + ) { acc + c.panOffset } else { acc + c.scrollDelta } - } * -64.dp.toPx() + } } diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt index be6dbc7ed3dde..2ee5721bd86a7 100644 --- a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt @@ -30,4 +30,5 @@ val IosSpecificFeatures = Screen.Selection( UpdatableInteropPropertiesExample, IosImeOptionsExample, NativeTextInputTextFields, + PanPinchCircleExample, ) \ No newline at end of file diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/PanPinchCircleExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/PanPinchCircleExample.ios.kt new file mode 100644 index 0000000000000..65f58bb3b0c33 --- /dev/null +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/PanPinchCircleExample.ios.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.mpp.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +val PanPinchCircleExample = Screen.Example("Pan & Pinch Circle") { + var offset by remember { mutableStateOf(Offset.Zero) } + var scale by remember { mutableStateOf(1f) } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + for (change in event.changes) { + scale *= change.scaleFactor + offset += change.panOffset + } + } + } + } + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + translationX = offset.x + translationY = offset.y + scaleX = scale + scaleY = scale + } + .size(120.dp) + .background(Color(0xFF2979FF), shape = CircleShape) + ) + + Column( + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text("Two-finger pan & pinch on a trackpad to move and scale the circle.") + Text("The pan and pinch pipelines are trackpad-only on iOS.") + Spacer(Modifier.height(8.dp)) + Text("scaleFactor (accumulated): ${(scale * 1000).roundToInt() / 1000.0}") + Text("panOffset (accumulated): (${offset.x.roundToInt()}, ${offset.y.roundToInt()})") + Spacer(Modifier.height(8.dp)) + Button(onClick = { + offset = Offset.Zero + scale = 1f + }) { + Text("Reset") + } + } + } +} diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj index ae6f7b272af9d..167d538ec4e16 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj @@ -13,9 +13,29 @@ 99293FF52F2B8A81001EC2A1 /* CMPDrawable.m in Sources */ = {isa = PBXBuildFile; fileRef = 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */; }; 99293FF82F2B8A81001EC2A1 /* CMPDrawable.m in Sources */ = {isa = PBXBuildFile; fileRef = 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */; }; 992EDDFB2E55EC8400FB44C5 /* CMPKeyValueObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */; }; - AABB11112F5A0000000000A3 /* CMPUIWindowSceneExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */; }; + 996392162F9A515D006EC6CE /* CMPScreenEdgePanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */; }; + 996392172F9A515D006EC6CE /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; + 996392182F9A515D006EC6CE /* CMPLayoutRegion.m in Sources */ = {isa = PBXBuildFile; fileRef = A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */; }; + 996392192F9A515D006EC6CE /* CMPGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */; }; + 9963921A2F9A515D006EC6CE /* CMPEditMenuCustomAction.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */; }; + 9963921B2F9A515D006EC6CE /* CMPDragInteractionProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = EADD028F2C9846D9003F66E8 /* CMPDragInteractionProxy.m */; }; + 9963921C2F9A515D006EC6CE /* CMPKeyValueObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */; }; + 9963921D2F9A515D006EC6CE /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; + 9963921E2F9A515D006EC6CE /* CMPPinchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C3712D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m */; }; + 9963921F2F9A515D006EC6CE /* CMPScrollView.m in Sources */ = {isa = PBXBuildFile; fileRef = 991A97F62E1FB99300B47130 /* CMPScrollView.m */; }; + 996392202F9A515D006EC6CE /* CMPTextInputView.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E882F57037300A9DC94 /* CMPTextInputView.m */; }; + 996392212F9A515D006EC6CE /* CMPHoverGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C3602D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m */; }; + 996392222F9A515D006EC6CE /* CMPTextInputStringTokenizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E862F57037300A9DC94 /* CMPTextInputStringTokenizer.m */; }; + 996392232F9A515D006EC6CE /* CMPMetalDrawablesHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = EAB33E172C12E746002CFF44 /* CMPMetalDrawablesHandler.m */; }; + 996392242F9A515D006EC6CE /* CMPDropInteractionProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = EADD02922C98484F003F66E8 /* CMPDropInteractionProxy.m */; }; + 996392252F9A515D006EC6CE /* CMPInteropWrappingView.m in Sources */ = {isa = PBXBuildFile; fileRef = EABD912A2BC02B5F00455279 /* CMPInteropWrappingView.m */; }; + 996392262F9A515D006EC6CE /* CMPPanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */; }; + 996392272F9A515D006EC6CE /* CMPUIWindowSceneExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */; }; + 9963922A2F9A517A006EC6CE /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 996392292F9A517A006EC6CE /* IOKit.framework */; }; + 996392742F9A5470006EC6CE /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 996392732F9A5470006EC6CE /* IOKit.framework */; }; 9968C35B2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */; }; 9968C3612D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C3602D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m */; }; + 9968C3722D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C3712D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m */; }; 9968C38B2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */; }; 997DFCDE2B18D135000B56B5 /* CMPViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCDD2B18D135000B56B5 /* CMPViewController.m */; }; 997DFCE62B18D99E000B56B5 /* CMPViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCE52B18D99E000B56B5 /* CMPViewControllerTests.swift */; }; @@ -32,6 +52,7 @@ 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; A01609822EB42A3300FB9790 /* CMPLayoutRegion.m in Sources */ = {isa = PBXBuildFile; fileRef = A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */; }; + AABB11112F5A0000000000A3 /* CMPUIWindowSceneExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */; }; C4C07E892F57037300A9DC94 /* CMPTextInputView.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E882F57037300A9DC94 /* CMPTextInputView.m */; }; C4C07E8A2F57037300A9DC94 /* CMPEditMenuCustomAction.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */; }; C4C07E8B2F57037300A9DC94 /* CMPTextInputStringTokenizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E862F57037300A9DC94 /* CMPTextInputStringTokenizer.m */; }; @@ -87,16 +108,18 @@ 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPDrawable.m; sourceTree = ""; }; 992EDDF92E55EC8400FB44C5 /* CMPKeyValueObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPKeyValueObserver.h; sourceTree = ""; }; 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPKeyValueObserver.m; sourceTree = ""; }; + 996392292F9A517A006EC6CE /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 996392732F9A5470006EC6CE /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.4.sdk/System/Library/Frameworks/IOKit.framework; sourceTree = DEVELOPER_DIR; }; 9968C3592D76FE16005E8DE4 /* CMPPanGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPPanGestureRecognizer.h; sourceTree = ""; }; 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPPanGestureRecognizer.m; sourceTree = ""; }; 9968C35F2D7746BD005E8DE4 /* CMPHoverGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPHoverGestureRecognizer.h; sourceTree = ""; }; 9968C3602D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPHoverGestureRecognizer.m; sourceTree = ""; }; + 9968C3702D76FE17005E8DE4 /* CMPPinchGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPPinchGestureRecognizer.h; sourceTree = ""; }; + 9968C3712D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPPinchGestureRecognizer.m; sourceTree = ""; }; 9968C3892D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPScreenEdgePanGestureRecognizer.h; sourceTree = ""; }; 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPScreenEdgePanGestureRecognizer.m; sourceTree = ""; }; 996EFEEA2B02CE5D0000FE0F /* libCMPUIKitUtils.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCMPUIKitUtils.a; sourceTree = BUILT_PRODUCTS_DIR; }; 996EFEF52B02CE8A0000FE0F /* CMPUIKitUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIKitUtils.h; sourceTree = ""; }; - AABB11112F5A0000000000A1 /* CMPUIWindowSceneExtensions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIWindowSceneExtensions.h; sourceTree = ""; }; - AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPUIWindowSceneExtensions.m; sourceTree = ""; }; 997DFCDC2B18D135000B56B5 /* CMPViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPViewController.h; sourceTree = ""; }; 997DFCDD2B18D135000B56B5 /* CMPViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPViewController.m; sourceTree = ""; }; 997DFCE32B18D99E000B56B5 /* CMPUIKitUtilsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CMPUIKitUtilsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -119,6 +142,8 @@ 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = ""; }; A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPLayoutRegion.m; sourceTree = ""; }; A0E69B242EB4227A0049B20F /* CMPLayoutRegion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPLayoutRegion.h; sourceTree = ""; }; + AABB11112F5A0000000000A1 /* CMPUIWindowSceneExtensions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIWindowSceneExtensions.h; sourceTree = ""; }; + AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPUIWindowSceneExtensions.m; sourceTree = ""; }; C4C07E832F57037300A9DC94 /* CMPEditMenuCustomAction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuCustomAction.h; sourceTree = ""; }; C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuCustomAction.m; sourceTree = ""; }; C4C07E852F57037300A9DC94 /* CMPTextInputStringTokenizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextInputStringTokenizer.h; sourceTree = ""; }; @@ -158,6 +183,7 @@ buildActionMask = 2147483647; files = ( 997DFCE72B18D99E000B56B5 /* libCMPUIKitUtils.a in Frameworks */, + 996392742F9A5470006EC6CE /* IOKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -165,12 +191,22 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9963922A2F9A517A006EC6CE /* IOKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 996392282F9A517A006EC6CE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 996392732F9A5470006EC6CE /* IOKit.framework */, + 996392292F9A517A006EC6CE /* IOKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 996EFEEB2B02CE5D0000FE0F /* CMPUIKitUtils */ = { isa = PBXGroup; children = ( @@ -211,6 +247,8 @@ EA82F4FB2B86184F00465418 /* CMPOSLoggerInterval.m */, 9968C3592D76FE16005E8DE4 /* CMPPanGestureRecognizer.h */, 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */, + 9968C3702D76FE17005E8DE4 /* CMPPinchGestureRecognizer.h */, + 9968C3712D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m */, 9968C3892D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.h */, 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */, 991A97F52E1FB99300B47130 /* CMPScrollView.h */, @@ -237,6 +275,7 @@ 996EFEEB2B02CE5D0000FE0F /* CMPUIKitUtils */, 997DFCE42B18D99E000B56B5 /* CMPUIKitUtilsTests */, 997DFCFB2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp */, + 996392282F9A517A006EC6CE /* Frameworks */, 9975AACB2AEABB5600AF155F /* Products */, ); sourceTree = ""; @@ -406,6 +445,7 @@ 9968C38B2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m in Sources */, EAB33E182C12E746002CFF44 /* CMPMetalDrawablesHandler.m in Sources */, 9968C35B2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m in Sources */, + 9968C3722D76FE17005E8DE4 /* CMPPinchGestureRecognizer.m in Sources */, 991A97F72E1FB99300B47130 /* CMPScrollView.m in Sources */, 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */, 99009B7B2F322B4700518C1F /* CMPMetalLayer.m in Sources */, @@ -447,6 +487,24 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 996392162F9A515D006EC6CE /* CMPScreenEdgePanGestureRecognizer.m in Sources */, + 996392172F9A515D006EC6CE /* CMPEditMenuView.m in Sources */, + 996392182F9A515D006EC6CE /* CMPLayoutRegion.m in Sources */, + 996392192F9A515D006EC6CE /* CMPGestureRecognizer.m in Sources */, + 9963921A2F9A515D006EC6CE /* CMPEditMenuCustomAction.m in Sources */, + 9963921B2F9A515D006EC6CE /* CMPDragInteractionProxy.m in Sources */, + 9963921C2F9A515D006EC6CE /* CMPKeyValueObserver.m in Sources */, + 9963921D2F9A515D006EC6CE /* CMPTextLoupeSession.m in Sources */, + 9963921E2F9A515D006EC6CE /* CMPPinchGestureRecognizer.m in Sources */, + 9963921F2F9A515D006EC6CE /* CMPScrollView.m in Sources */, + 996392202F9A515D006EC6CE /* CMPTextInputView.m in Sources */, + 996392212F9A515D006EC6CE /* CMPHoverGestureRecognizer.m in Sources */, + 996392222F9A515D006EC6CE /* CMPTextInputStringTokenizer.m in Sources */, + 996392232F9A515D006EC6CE /* CMPMetalDrawablesHandler.m in Sources */, + 996392242F9A515D006EC6CE /* CMPDropInteractionProxy.m in Sources */, + 996392252F9A515D006EC6CE /* CMPInteropWrappingView.m in Sources */, + 996392262F9A515D006EC6CE /* CMPPanGestureRecognizer.m in Sources */, + 996392272F9A515D006EC6CE /* CMPUIWindowSceneExtensions.m in Sources */, EAC703E32B8C826E001ECDA6 /* CMPAccessibilityElement.m in Sources */, EAC703E42B8C826E001ECDA6 /* CMPViewController.m in Sources */, EAC703E52B8C826E001ECDA6 /* CMPOSLogger.m in Sources */, @@ -681,7 +739,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 45226JTYHN; + DEVELOPMENT_TEAM = 8U9FYW5TMF; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -714,7 +772,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 45226JTYHN; + DEVELOPMENT_TEAM = 8U9FYW5TMF; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.h new file mode 100644 index 0000000000000..9ec9e5f9c587e --- /dev/null +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.h @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface CMPPinchGestureRecognizer : UIPinchGestureRecognizer + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event; + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event; + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event; + +- (BOOL)shouldReceiveEvent:(UIEvent *)event; + +@end + +NS_ASSUME_NONNULL_END diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.m new file mode 100644 index 0000000000000..2cf4cb16fef96 --- /dev/null +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPPinchGestureRecognizer.m @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPPinchGestureRecognizer.h" + +@implementation CMPPinchGestureRecognizer + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesBegan:touches withEvent:event]; +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesMoved:touches withEvent:event]; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesEnded:touches withEvent:event]; +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesCancelled:touches withEvent:event]; +} + +- (BOOL)shouldReceiveEvent:(UIEvent *)event { + return [super shouldReceiveEvent:event]; +} + +@end diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h index 7ece2b7487a3c..26352c87b13e1 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h @@ -36,6 +36,7 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPMetalLayer.h" #import "CMPOSLogger.h" #import "CMPPanGestureRecognizer.h" +#import "CMPPinchGestureRecognizer.h" #import "CMPScreenEdgePanGestureRecognizer.h" #import "CMPScrollView.h" #import "CMPTextInputStringTokenizer.h" diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 7abdda7e2f9d3..bac07f4dd7b7b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -286,6 +286,8 @@ internal class ComposeSceneMediator( onCancelAllTouches = ::onCancelAllTouches, onScrollEvent = ::onScrollEvent, onCancelScroll = ::onCancelScroll, + onPinchEvent = ::onPinchEvent, + onCancelPinch = ::onCancelPinch, onHoverEvent = ::onHoverEvent, onKeyboardPresses = ::onKeyboardPresses, ignoreTouchChanges = navigationEventInput::isBackGestureActive @@ -423,14 +425,22 @@ internal class ComposeSceneMediator( event: UIEvent?, eventKind: TouchesEventKind ) { - when (eventKind) { - TouchesEventKind.BEGAN -> redrawer.ongoingInteractionEventsCount += 1 - TouchesEventKind.MOVED -> {} - TouchesEventKind.ENDED -> redrawer.ongoingInteractionEventsCount -= 1 + val eventType = when (eventKind) { + TouchesEventKind.BEGAN -> { + redrawer.ongoingInteractionEventsCount += 1 + PointerEventType.PanStart + } + TouchesEventKind.MOVED -> { + PointerEventType.PanMove + } + TouchesEventKind.ENDED -> { + redrawer.ongoingInteractionEventsCount -= 1 + PointerEventType.PanEnd + } } scene.sendPointerEvent( - eventType = PointerEventType.Scroll, + eventType = eventType, pointers = listOf( ComposeScenePointer( id = PointerId(0), @@ -439,10 +449,47 @@ internal class ComposeSceneMediator( type = PointerType.Mouse, ) ), - scrollDelta = delta.toOffset(composeSceneDensity) * SCROLL_DELTA_MULTIPLIER, timeMillis = event.timeMillis, nativeEvent = event, - keyboardModifiers = PointerKeyboardModifiers(event.modifierFlagsOrZero) + keyboardModifiers = PointerKeyboardModifiers(event.modifierFlagsOrZero), + panGestureOffset = delta.toOffset(composeSceneDensity), + ) + } + + private fun onPinchEvent( + position: DpOffset, + scale: Float, + event: UIEvent?, + eventKind: TouchesEventKind + ) { + val eventType = when (eventKind) { + TouchesEventKind.BEGAN -> { + redrawer.ongoingInteractionEventsCount += 1 + PointerEventType.ScaleStart + } + TouchesEventKind.MOVED -> { + PointerEventType.ScaleChange + } + TouchesEventKind.ENDED -> { + redrawer.ongoingInteractionEventsCount -= 1 + PointerEventType.ScaleEnd + } + } + + scene.sendPointerEvent( + eventType = eventType, + pointers = listOf( + ComposeScenePointer( + id = PointerId(0), + position = position.toOffset(composeSceneDensity), + pressed = false, + type = PointerType.Mouse, + ) + ), + timeMillis = event.timeMillis, + nativeEvent = event, + keyboardModifiers = PointerKeyboardModifiers(event.modifierFlagsOrZero), + scaleGestureFactor = scale, ) } @@ -478,6 +525,11 @@ internal class ComposeSceneMediator( scene.cancelPointerInput() } + private fun onCancelPinch() { + redrawer.ongoingInteractionEventsCount -= 1 + scene.cancelPointerInput() + } + private fun onCancelAllTouches(touches: Set<*>) { redrawer.ongoingInteractionEventsCount -= touches.count() scene.cancelPointerInput() @@ -827,7 +879,6 @@ private val UIEvent?.timeMillis: Long get() { } private val FOCUS_CHANGE_ANIMATION_DURATION = 0.15.seconds -private val SCROLL_DELTA_MULTIPLIER = 0.01f private fun TouchesEventKind.toPointerEventType(): PointerEventType = when (this) { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 7c3946690f12e..d7bdce999f670 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.scene.PointerEventResult import androidx.compose.ui.uikit.utils.CMPGestureRecognizer import androidx.compose.ui.uikit.utils.CMPHoverGestureRecognizer import androidx.compose.ui.uikit.utils.CMPPanGestureRecognizer +import androidx.compose.ui.uikit.utils.CMPPinchGestureRecognizer import androidx.compose.ui.uikit.utils.CMPScrollView import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.asDpOffset @@ -58,6 +59,7 @@ import platform.UIKit.UIGestureRecognizerStateFailed import platform.UIKit.UIGestureRecognizerStatePossible import platform.UIKit.UIHoverGestureRecognizer import platform.UIKit.UIPanGestureRecognizer +import platform.UIKit.UIPinchGestureRecognizer import platform.UIKit.UIPressesEvent import platform.UIKit.UIScreenEdgePanGestureRecognizer import platform.UIKit.UIScrollTypeMaskAll @@ -498,6 +500,90 @@ private class ScrollGestureRecognizer( } } +private class PinchGestureRecognizer( + private var onPinchEvent: (position: DpOffset, scale: Float, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, + private var onCancelPinch: () -> Unit +) : CMPPinchGestureRecognizer(target = null, action = null) { + + init { + setDelaysTouchesBegan(false) + setDelaysTouchesEnded(false) + setCancelsTouchesInView(false) + addTarget(this, NSSelectorFromString(::onPinch.name + ":")) + } + + private var pinchCenter: DpOffset? = null + private var previousScale: Float = 1f + private var event: UIEvent? = null + + @OptIn(BetaInteropApi::class) + @ObjCAction + fun onPinch(gestureRecognizer: UIPinchGestureRecognizer) { + val position = gestureRecognizer.locationInView(view).asDpOffset() + + when (gestureRecognizer.state) { + UIGestureRecognizerStateBegan -> { + onPinchEvent(position, 1f, event, TouchesEventKind.BEGAN) + pinchCenter = position + previousScale = 1f + } + + UIGestureRecognizerStateChanged -> { + val scale = gestureRecognizer.scale.toFloat() + val delta = scale / previousScale + onPinchEvent(pinchCenter ?: position, delta, event, TouchesEventKind.MOVED) + previousScale = scale + } + + UIGestureRecognizerStateEnded -> { + val scale = gestureRecognizer.scale.toFloat() + val delta = scale / previousScale + onPinchEvent(pinchCenter ?: position, delta, event, TouchesEventKind.ENDED) + pinchCenter = null + previousScale = 1f + event = null + } + + UIGestureRecognizerStateCancelled, UIGestureRecognizerStateFailed -> { + onCancelPinch() + pinchCenter = null + previousScale = 1f + event = null + } + + else -> {} + } + } + + override fun shouldReceiveEvent(event: UIEvent): Boolean { + this.event = event + return super.shouldReceiveEvent(event) + } + + fun dispose() { + removeTarget(this, null) + onPinchEvent = { _, _, _, _ -> } + onCancelPinch = {} + } + + override fun touchesBegan(touches: Set<*>, withEvent: UIEvent) { + // Gesture recognizer only works with the trackpad. All touches should be cancelled. + setState(UIGestureRecognizerStateFailed) + } + + override fun touchesMoved(touches: Set<*>, withEvent: UIEvent) { + // Do nothing. No need to handle touches for pinch gesture + } + + override fun touchesEnded(touches: Set<*>, withEvent: UIEvent) { + // Do nothing. No need to handle touches for pinch gesture + } + + override fun touchesCancelled(touches: Set<*>, withEvent: UIEvent) { + // Do nothing. No need to handle touches for pinch gesture + } +} + /** * The application can place interop views above and below the rendering canvas which is implemented * by using [OverlayInputView] and [BackgroundInputView]. @@ -512,6 +598,8 @@ internal class OverlayInputView( private var onCancelAllTouches: (touches: Set<*>) -> Unit, onScrollEvent: (position: DpOffset, delta: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, onCancelScroll: () -> Unit, + onPinchEvent: (position: DpOffset, scale: Float, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, + onCancelPinch: () -> Unit, private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, ignoreTouchChanges: () -> Boolean, @@ -541,6 +629,17 @@ internal class OverlayInputView( } } + private val customPinchGestureRecognizer by lazy { + if (available(OS.Ios to OSVersion(major = 13, minor = 4))) { + PinchGestureRecognizer( + onPinchEvent = onPinchEvent, + onCancelPinch = onCancelPinch + ) + } else { + null + } + } + private val hoverGestureRecognizer by lazy { CMPHoverGestureRecognizer(this, NSSelectorFromString(::onHover.name + ":")).apply { delaysTouchesBegan = false @@ -564,6 +663,9 @@ internal class OverlayInputView( scrollGestureRecognizer?.let { addGestureRecognizer(it) } + customPinchGestureRecognizer?.let { + addGestureRecognizer(it) + } addGestureRecognizer(hoverGestureRecognizer) @@ -707,6 +809,10 @@ internal class OverlayInputView( removeGestureRecognizer(it) it.dispose() } + customPinchGestureRecognizer?.let { + removeGestureRecognizer(it) + it.dispose() + } removeGestureRecognizer(hoverGestureRecognizer) onHoverEvent = { _, _, _ -> } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt index 961cff994452e..b02196ecf292b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt @@ -16,6 +16,7 @@ package androidx.compose.ui +import androidx.compose.ui.interaction.TrackpadPanTest import androidx.compose.xctest.setupXCTestSuite import kotlinx.cinterop.ExperimentalForeignApi import platform.XCTest.XCTestSuite @@ -23,6 +24,7 @@ import platform.XCTest.XCTestSuite @Suppress("unused") @OptIn(ExperimentalForeignApi::class) fun testSuite(): XCTestSuite = setupXCTestSuite( + TrackpadPanTest::class // Run all test cases from the tests // BasicInteractionTest::class, // LayersAccessibilityTest::class, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt new file mode 100644 index 0000000000000..b0006a6fe224a --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.endScroll +import androidx.compose.ui.test.utils.scrollBy +import androidx.compose.ui.test.utils.scrollEventAt +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +internal class TrackpadPanTest { + + @Test + fun testPointerInputReceivesTrackpadPan() = runUIKitInstrumentedTest { + val panStartCount = mutableStateOf(0) + val panMoveCount = mutableStateOf(0) + val panEndCount = mutableStateOf(0) + var totalPan = Offset.Zero + + setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + println( + ">>> pointerInput got ${event.type}" + + " changes=${event.changes.size}" + + " | ${event.changes.map { it.panOffset }}" + ) + when (event.type) { + PointerEventType.PanStart -> panStartCount.value++ + PointerEventType.PanMove -> { + panMoveCount.value++ + totalPan = event.changes.fold(totalPan) { acc, c -> + acc + c.panOffset + } + } + PointerEventType.PanEnd -> panEndCount.value++ + } + } + } + } + ) + } + + val panDx = 120.dp + val panDy = 0.dp + val steps = 8 + val stepInterval = 16.milliseconds + val perStepDelta = DpOffset(panDx / steps.toFloat(), panDy / steps.toFloat()) + val center = DpOffset(screenSize.width / 2, screenSize.height / 2) + val window = appDelegate.window() + assertNotNull(window, "Host window must exist") + + val scrollEvent = window.scrollEventAt(location = center, delta = perStepDelta) + + // 2. Emit UIScrollPhaseChanged events for each subsequent step. + repeat(steps - 1) { + UIKitInstrumentedTest.delay(stepInterval.inWholeMilliseconds) + scrollEvent.scrollBy(delta = perStepDelta, window = window) + } + + // 3. Close the session — UIScrollPhaseEnded. + UIKitInstrumentedTest.delay(stepInterval.inWholeMilliseconds) + scrollEvent.endScroll(window = window) + + waitForIdle() + + assertTrue( + panStartCount.value >= 1, + "Expected at least one PanStart, received ${panStartCount.value}" + ) + assertTrue( + panMoveCount.value >= 1, + "Expected at least one PanMove, received ${panMoveCount.value}" + ) + assertTrue( + panEndCount.value >= 1, + "Expected at least one PanEnd, received ${panEndCount.value}" + ) + + val expectedTotalPxAbs = panDx.value * density.density + assertTrue( + totalPan.value.getDistance() > expectedTotalPxAbs / 2, + "Accumulated pan offset (${totalPan.value}) should be in the ballpark of " + + "the simulated delta (~${expectedTotalPxAbs}px along X)." + ) + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index b2359033044f3..158e37276f281 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -23,10 +23,13 @@ import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.scene.ComposeLayersViewController import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.endScroll import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow import androidx.compose.ui.test.utils.resetTouches +import androidx.compose.ui.test.utils.scrollBy +import androidx.compose.ui.test.utils.scrollEventAt import androidx.compose.ui.test.utils.toCGPoint import androidx.compose.ui.test.utils.touchDown import androidx.compose.ui.test.utils.up @@ -477,6 +480,51 @@ internal class UIKitInstrumentedTest( fun UITouch.dragBy(dx: Dp = 0.dp, dy: Dp = 0.dp, duration: Duration = 0.5.seconds): UITouch { return dragBy(DpOffset(dx, dy), duration) } + + /** + * Simulates a trackpad continuous pan gesture as a stateful scroll session: + * 1. [UIWindow.scrollEventAt] opens the session (`UIScrollPhaseBegan`). + * 2. [UIEvent.scrollBy] emits [steps] `UIScrollPhaseChanged` events carrying + * the linear per-step delta. + * 3. [UIEvent.endScroll] closes the session (`UIScrollPhaseEnded`). + * + * All scroll synthesis goes through the `UIEvent (CMPScroll)` category in + * `UIEvent+Test.m`, which builds a scroll-rooted HID tree with a pointer + * child (matching the on-device trace) and dispatches through + * `-[UIApplication sendEvent:]` and `-[UIApplication _handleHIDEvent:]`. + * + * Known limitation (iOS 18, April 2026): synthetic `UIScrollEvent` dispatched + * from the same process does NOT trigger + * `UIPanGestureRecognizer.allowedScrollTypesMask`. See [TrackpadPanTest] for + * details. The scaffolding is kept for future experiments. + * + * @return `true` once the sequence has been dispatched. `false` only if the + * runtime lacks the private `UIScrollEvent` class. + */ + fun trackpadPan( + position: DpOffset, + totalDelta: DpOffset, + steps: Int = 10, + stepInterval: Duration = 16.milliseconds, + window: UIWindow? = null, + ): Boolean { + require(steps >= 1) { "steps must be >= 1" } + val targetWindow = window ?: appDelegate.window()!! + + val scrollEvent = targetWindow.scrollEventAt(location = position) ?: return false + + val dxPerStep = totalDelta.x / steps.toFloat() + val dyPerStep = totalDelta.y / steps.toFloat() + val perStepDelta = DpOffset(dxPerStep, dyPerStep) + repeat(steps) { + delay(stepInterval.inWholeMilliseconds) + scrollEvent.scrollBy(perStepDelta, targetWindow) + } + + delay(stepInterval.inWholeMilliseconds) + scrollEvent.endScroll(targetWindow) + return true + } } @OptIn(ExperimentalForeignApi::class) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt new file mode 100644 index 0000000000000..815bc77c0baf9 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.test.utils + +import androidx.compose.test.utils.endInWindow +import androidx.compose.test.utils.scrollByDelta +import androidx.compose.test.utils.scrollEventAtPoint +import androidx.compose.ui.unit.DpOffset +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIEvent +import platform.UIKit.UIWindow + +/** + * Opens a synthetic trackpad scroll session anchored at [location] in this window. The + * returned [UIEvent] is a UIScrollEvent already dispatched with `UIScrollPhaseBegan` and + * the initial [delta]; use [UIEvent.scrollBy] to emit follow-up `Changed` events and + * [UIEvent.endScroll] to close the session. + * + * Returns `null` if the private `UIScrollEvent` class is not available on the current + * runtime (pre-iOS 13.4) — synthesized scroll is then unavailable. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.scrollEventAt( + location: DpOffset, + delta: DpOffset = DpOffset.Zero, +): UIEvent { + return UIEvent.scrollEventAtPoint( + point = location.toCGPoint(), + delta = delta.toCGPoint(), + inWindow = this, + ) ?: error("UIScrollEvent unavailable on this runtime") +} + +/** + * Emits one `UIScrollPhaseChanged` event on this scroll session with the given [delta] + * (in dp, converted to points). Must only be called on a [UIEvent] returned by + * [UIWindow.scrollEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.scrollBy(delta: DpOffset, window: UIWindow) { + scrollByDelta(delta.toCGPoint(), inWindow = window) +} + +/** + * Emits the closing `UIScrollPhaseEnded` event on this scroll session. Must only be + * called on a [UIEvent] returned by [UIWindow.scrollEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.endScroll(window: UIWindow) { + endInWindow(window) +} diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj index 6d5bc3bd04b2a..ba5a274a824c9 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj @@ -3,14 +3,35 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ + 3053733953284D266BAD1BC5 /* UIEvent+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 33E3670D7DA407FD6DDDC97C /* UIEvent+Test.m */; }; + 525401B1518F10E0A5F31F86 /* UIEvent+Test.h in Headers */ = {isa = PBXBuildFile; fileRef = D04C7EAD66F878FEC6CC0F11 /* UIEvent+Test.h */; settings = {ATTRIBUTES = (Public, ); }; }; 999869392D479FAB0096554D /* HIDEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869362D479FAB0096554D /* HIDEvent.m */; }; 9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869382D479FAB0096554D /* UITouch+Test.m */; }; + CC01AB001234567890ABCDEF /* libCMPTestUtils.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */; }; + CC01AB021234567890ABCDEF /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC01AB011234567890ABCDEF /* IOKit.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 99C2D6C52F9B8F2200435A2B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 995A49042D301B510091FB9B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 99C2D6B72F9B8F2100435A2B; + remoteInfo = CMPTestUtilsApp; + }; + 9FFEB09268A87DCD9D76938B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 995A49042D301B510091FB9B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 995A49392D3023CC0091FB9B; + remoteInfo = CMPTestUtils; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 995A49382D3023CC0091FB9B /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; @@ -24,14 +45,24 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 33E3670D7DA407FD6DDDC97C /* UIEvent+Test.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "UIEvent+Test.m"; sourceTree = ""; }; 995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCMPTestUtils.a; sourceTree = BUILT_PRODUCTS_DIR; }; 999342432D96F10E0081AB74 /* CMPTestUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTestUtils.h; sourceTree = ""; }; 999869352D479FAB0096554D /* HIDEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HIDEvent.h; sourceTree = ""; }; 999869362D479FAB0096554D /* HIDEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HIDEvent.m; sourceTree = ""; }; 999869372D479FAB0096554D /* UITouch+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UITouch+Test.h"; sourceTree = ""; }; 999869382D479FAB0096554D /* UITouch+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITouch+Test.m"; sourceTree = ""; }; + 99C2D73E2F9B97D000435A2B /* CMPTestUtilsApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CMPTestUtilsApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 99C2D73F2F9B97D000435A2B /* CMPTestUtilsAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CMPTestUtilsAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CC01AB011234567890ABCDEF /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + D04C7EAD66F878FEC6CC0F11 /* UIEvent+Test.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "UIEvent+Test.h"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 99C2D6B92F9B8F2100435A2B /* CMPTestUtilsApp */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CMPTestUtilsApp; sourceTree = ""; }; + 99C2D6C72F9B8F2200435A2B /* CMPTestUtilsAppTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CMPTestUtilsAppTests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 995A49372D3023CC0091FB9B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -40,6 +71,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 99C2D6B52F9B8F2100435A2B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99C2D6C12F9B8F2200435A2B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CC01AB001234567890ABCDEF /* libCMPTestUtils.a in Frameworks */, + CC01AB021234567890ABCDEF /* IOKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -47,19 +94,15 @@ isa = PBXGroup; children = ( 9998693B2D479FC80096554D /* CMPTestUtils */, - 995A490E2D301B510091FB9B /* Products */, + 99C2D6B92F9B8F2100435A2B /* CMPTestUtilsApp */, + 99C2D6C72F9B8F2200435A2B /* CMPTestUtilsAppTests */, + 99C2D73D2F9B97D000435A2B /* Recovered References */, + 99C2D73E2F9B97D000435A2B /* CMPTestUtilsApp.app */, + 99C2D73F2F9B97D000435A2B /* CMPTestUtilsAppTests.xctest */, ); sourceTree = ""; wrapsLines = 1; }; - 995A490E2D301B510091FB9B /* Products */ = { - isa = PBXGroup; - children = ( - 995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */, - ); - name = Products; - sourceTree = ""; - }; 9998693B2D479FC80096554D /* CMPTestUtils */ = { isa = PBXGroup; children = ( @@ -68,12 +111,34 @@ 999869362D479FAB0096554D /* HIDEvent.m */, 999869372D479FAB0096554D /* UITouch+Test.h */, 999869382D479FAB0096554D /* UITouch+Test.m */, + D04C7EAD66F878FEC6CC0F11 /* UIEvent+Test.h */, + 33E3670D7DA407FD6DDDC97C /* UIEvent+Test.m */, ); path = CMPTestUtils; sourceTree = ""; }; + 99C2D73D2F9B97D000435A2B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */, + CC01AB011234567890ABCDEF /* IOKit.framework */, + ); + name = "Recovered References"; + sourceTree = ""; + }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + 9851EEAAB4C42BBD966FAEF1 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 525401B1518F10E0A5F31F86 /* UIEvent+Test.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ 995A49392D3023CC0091FB9B /* CMPTestUtils */ = { isa = PBXNativeTarget; @@ -82,18 +147,59 @@ 995A49362D3023CC0091FB9B /* Sources */, 995A49372D3023CC0091FB9B /* Frameworks */, 995A49382D3023CC0091FB9B /* CopyFiles */, + 9851EEAAB4C42BBD966FAEF1 /* Headers */, ); buildRules = ( ); dependencies = ( ); name = CMPTestUtils; - packageProductDependencies = ( - ); productName = CMPTestUtils; productReference = 995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */; productType = "com.apple.product-type.library.static"; }; + 99C2D6B72F9B8F2100435A2B /* CMPTestUtilsApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99C2D6D62F9B8F2200435A2B /* Build configuration list for PBXNativeTarget "CMPTestUtilsApp" */; + buildPhases = ( + 99C2D6B42F9B8F2100435A2B /* Sources */, + 99C2D6B52F9B8F2100435A2B /* Frameworks */, + 99C2D6B62F9B8F2100435A2B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 99C2D6B92F9B8F2100435A2B /* CMPTestUtilsApp */, + ); + name = CMPTestUtilsApp; + productName = CMPTestUtilsApp; + productReference = 99C2D73E2F9B97D000435A2B /* CMPTestUtilsApp.app */; + productType = "com.apple.product-type.application"; + }; + 99C2D6C32F9B8F2200435A2B /* CMPTestUtilsAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99C2D6D92F9B8F2200435A2B /* Build configuration list for PBXNativeTarget "CMPTestUtilsAppTests" */; + buildPhases = ( + 99C2D6C02F9B8F2200435A2B /* Sources */, + 99C2D6C12F9B8F2200435A2B /* Frameworks */, + 99C2D6C22F9B8F2200435A2B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 99C2D6C62F9B8F2200435A2B /* PBXTargetDependency */, + 55FFE6AF7F649F70D161824D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 99C2D6C72F9B8F2200435A2B /* CMPTestUtilsAppTests */, + ); + name = CMPTestUtilsAppTests; + productName = CMPTestUtilsAppTests; + productReference = 99C2D73F2F9B97D000435A2B /* CMPTestUtilsAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -101,11 +207,19 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastUpgradeCheck = 1610; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; TargetAttributes = { 995A49392D3023CC0091FB9B = { CreatedOnToolsVersion = 16.1; }; + 99C2D6B72F9B8F2100435A2B = { + CreatedOnToolsVersion = 26.4; + }; + 99C2D6C32F9B8F2200435A2B = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 99C2D6B72F9B8F2100435A2B; + }; }; }; buildConfigurationList = 995A49072D301B510091FB9B /* Build configuration list for PBXProject "CMPTestUtils" */; @@ -118,15 +232,34 @@ ); mainGroup = 995A49032D301B510091FB9B; minimizedProjectReferenceProxies = 1; - productRefGroup = 995A490E2D301B510091FB9B /* Products */; + productRefGroup = 995A49032D301B510091FB9B; projectDirPath = ""; projectRoot = ""; targets = ( 995A49392D3023CC0091FB9B /* CMPTestUtils */, + 99C2D6B72F9B8F2100435A2B /* CMPTestUtilsApp */, + 99C2D6C32F9B8F2200435A2B /* CMPTestUtilsAppTests */, ); }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 99C2D6B62F9B8F2100435A2B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99C2D6C22F9B8F2200435A2B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 995A49362D3023CC0091FB9B /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -134,11 +267,40 @@ files = ( 999869392D479FAB0096554D /* HIDEvent.m in Sources */, 9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */, + 3053733953284D266BAD1BC5 /* UIEvent+Test.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99C2D6B42F9B8F2100435A2B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99C2D6C02F9B8F2200435A2B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 55FFE6AF7F649F70D161824D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = CMPTestUtils; + target = 995A49392D3023CC0091FB9B /* CMPTestUtils */; + targetProxy = 9FFEB09268A87DCD9D76938B /* PBXContainerItemProxy */; + }; + 99C2D6C62F9B8F2200435A2B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 99C2D6B72F9B8F2100435A2B /* CMPTestUtilsApp */; + targetProxy = 99C2D6C52F9B8F2200435A2B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 995A49182D301B510091FB9B /* Debug */ = { isa = XCBuildConfiguration; @@ -199,6 +361,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; @@ -256,6 +419,8 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -266,7 +431,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -278,7 +443,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -286,6 +451,140 @@ }; name = Release; }; + 99C2D6D72F9B8F2200435A2B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8U9FYW5TMF; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = JB.CMPTestUtilsApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 99C2D6D82F9B8F2200435A2B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8U9FYW5TMF; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = JB.CMPTestUtilsApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 99C2D6DA2F9B8F2200435A2B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8U9FYW5TMF; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/CMPTestUtils", + ); + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = JB.CMPTestUtilsAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OBJC_BRIDGING_HEADER = "CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CMPTestUtilsApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CMPTestUtilsApp"; + }; + name = Debug; + }; + 99C2D6DB2F9B8F2200435A2B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8U9FYW5TMF; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/CMPTestUtils", + ); + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = JB.CMPTestUtilsAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OBJC_BRIDGING_HEADER = "CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CMPTestUtilsApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CMPTestUtilsApp"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -307,6 +606,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 99C2D6D62F9B8F2200435A2B /* Build configuration list for PBXNativeTarget "CMPTestUtilsApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99C2D6D72F9B8F2200435A2B /* Debug */, + 99C2D6D82F9B8F2200435A2B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 99C2D6D92F9B8F2200435A2B /* Build configuration list for PBXNativeTarget "CMPTestUtilsAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99C2D6DA2F9B8F2200435A2B /* Debug */, + 99C2D6DB2F9B8F2200435A2B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 995A49042D301B510091FB9B /* Project object */; diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtils.xcscheme b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtils.xcscheme index 635accdd8aa07..5402281208ec2 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtils.xcscheme +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtils.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsAppTests.xcscheme b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsAppTests.xcscheme new file mode 100644 index 0000000000000..5d4879d9683c6 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsAppTests.xcscheme @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h index 38df9dfd1fb9c..32cf6bd126753 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h @@ -23,3 +23,4 @@ FOUNDATION_EXPORT double CMPTestUtilsVersionNumber; FOUNDATION_EXPORT const unsigned char CMPTestUtilsVersionString[]; #import "UITouch+Test.h" +#import "UIEvent+Test.h" diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h new file mode 100644 index 0000000000000..8fd8e8285cd08 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIEvent (CMPScroll) + +/** + * Opens a scroll session at [point] with initial [delta] (phase Began). + * Returns `nil` if `UIScrollEvent` is unavailable (pre-iOS 13.4). + */ ++ (nullable instancetype)scrollEventAtPoint:(CGPoint)point + delta:(CGPoint)delta + inWindow:(UIWindow *)window; + +/** Emits a phase-Changed scroll event with the given [delta]. */ +- (void)scrollByDelta:(CGPoint)delta + inWindow:(UIWindow *)window; + +/** Emits a phase-Ended scroll event and closes the session. */ +- (void)endInWindow:(UIWindow *)window; + +@end + + +@interface UIEvent (CMPPinch) + +/** + * Opens a pinch session at [point] with initial absolute [scale] + * (typically `1.0`), phase Began. Returns `nil` if `UITransformEvent` + * is unavailable. + */ ++ (nullable instancetype)pinchEventAtPoint:(CGPoint)point + scale:(CGFloat)scale + inWindow:(UIWindow *)window; + +/** Emits a phase-Changed pinch event with the new absolute [scale]. */ +- (void)pinchByScale:(CGFloat)scale + inWindow:(UIWindow *)window; + +/** Emits a phase-Ended pinch event and closes the session. */ +- (void)endPinchInWindow:(UIWindow *)window; + +@end + + +@interface UIEvent (CMPHover) + +/** + * Opens a hover session at [point]. Returns `nil` if `UIHoverEvent` + * is unavailable. + */ ++ (nullable instancetype)hoverEventAtPoint:(CGPoint)point + inWindow:(UIWindow *)window; + +/** Emits a hover-moved event with the cursor now at [point]. */ +- (void)hoverMoveToPoint:(CGPoint)point + inWindow:(UIWindow *)window; + +/** Closes the hover session. */ +- (void)endHoverInWindow:(UIWindow *)window; + +@end + + +/** Read the anchor point from a synthetic scroll/pinch/hover event. */ +@interface UIEvent (CMPSyntheticLocation) + +/** + * Returns the synthetic event's anchor point converted to [view]'s coordinate + * space. If [view] is `nil`, returns the raw window-space point. + */ +- (CGPoint)cmp_locationInView:(nullable UIView *)view; + +@end + +NS_ASSUME_NONNULL_END diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m new file mode 100644 index 0000000000000..27c3efc09250a --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -0,0 +1,505 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "UIEvent+Test.h" +#import +#import +#import + +// Private enums; values confirmed via swizzle traces on iPadOS 17+. +// Scroll and transform use different numbering — mixing them silently drops +// synthetic events at the gesture environment. +typedef NS_ENUM(NSInteger, CMPScrollPhase) { + CMPScrollPhaseBegan = 2, + CMPScrollPhaseChanged = 3, + CMPScrollPhaseEnded = 4, +}; + +typedef NS_ENUM(NSInteger, CMPTransformPhase) { + CMPTransformPhaseNone = 0, + CMPTransformPhaseBegan = 1, + CMPTransformPhaseChanged = 2, + CMPTransformPhaseEnded = 3, + CMPTransformPhaseCancelled = 4, +}; + +#pragma mark - Synthetic event state (associated objects) + +static const void *kCMPSynPhaseKey = &kCMPSynPhaseKey; +static const void *kCMPSynLocationKey = &kCMPSynLocationKey; +static const void *kCMPSynDeltaKey = &kCMPSynDeltaKey; +static const void *kCMPSynWindowKey = &kCMPSynWindowKey; +static const void *kCMPSynScaleKey = &kCMPSynScaleKey; + +static void CMPSynSetPhase(id evt, NSInteger phase) { + objc_setAssociatedObject(evt, kCMPSynPhaseKey, @(phase), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static CMPScrollPhase CMPSynGetPhase(id evt) { + NSNumber *n = objc_getAssociatedObject(evt, kCMPSynPhaseKey); + return n ? (CMPScrollPhase)[n integerValue] : 0; +} + +static void CMPSynSetLocation(id evt, CGPoint location) { + objc_setAssociatedObject(evt, kCMPSynLocationKey, + [NSValue valueWithBytes:&location objCType:@encode(CGPoint)], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static CGPoint CMPSynGetLocation(id evt) { + NSValue *v = objc_getAssociatedObject(evt, kCMPSynLocationKey); + CGPoint p = CGPointZero; + if (v) { [v getValue:&p size:sizeof(CGPoint)]; } + return p; +} + +static void CMPSynSetDelta(id evt, CGVector delta) { + objc_setAssociatedObject(evt, kCMPSynDeltaKey, + [NSValue valueWithBytes:&delta objCType:@encode(CGVector)], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static CGVector CMPSynGetDelta(id evt) { + NSValue *v = objc_getAssociatedObject(evt, kCMPSynDeltaKey); + CGVector d = {0, 0}; + if (v) { [v getValue:&d size:sizeof(CGVector)]; } + return d; +} + +static void CMPSynSetWindow(id evt, UIWindow *window) { + objc_setAssociatedObject(evt, kCMPSynWindowKey, window, OBJC_ASSOCIATION_ASSIGN); +} + +static UIWindow *CMPSynGetWindow(id evt) { + return objc_getAssociatedObject(evt, kCMPSynWindowKey); +} + +static void CMPSynSetScale(id evt, CGFloat scale) { + objc_setAssociatedObject(evt, kCMPSynScaleKey, @(scale), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static CGFloat CMPSynGetScale(id evt) { + NSNumber *n = objc_getAssociatedObject(evt, kCMPSynScaleKey); + return n ? (CGFloat)[n doubleValue] : 1.0; +} + + +@interface UIEvent (CMPSyntheticOverrides) + +- (NSInteger)cmp_syntheticScrollType; +- (CMPScrollPhase)cmp_syntheticPhase; +- (CGPoint)cmp_syntheticLocationInView:(UIView *)view; +- (CGVector)cmp_syntheticDelta; +- (NSSet *)cmp_syntheticAllWindows; +- (NSSet *)cmp_syntheticGestureRecognizersForWindow:(UIWindow *)window; + +@end + +static void CMPCollectRecognizers(UIView *view, NSMutableSet *sink) { + for (UIGestureRecognizer *g in view.gestureRecognizers) { + [sink addObject:g]; + } + for (UIView *sub in view.subviews) { + CMPCollectRecognizers(sub, sink); + } +} + +@implementation UIEvent (CMPSyntheticOverrides) + +- (NSInteger)cmp_syntheticScrollType { + return UIEventTypeScroll; +} + +- (CMPScrollPhase)cmp_syntheticPhase { + return CMPSynGetPhase(self); +} + +- (CGPoint)cmp_syntheticLocationInView:(UIView *)view { + // Location is stored in window coordinates; convert to the target view's + // coordinate space on request, matching UIKit's own contract. + CGPoint location = CMPSynGetLocation(self); + if (view == nil) { return location; } + UIWindow *window = CMPSynGetWindow(self) ?: view.window; + if (window == nil || window == view) { return location; } + return [view convertPoint:location fromView:window]; +} + +- (CGVector)cmp_syntheticDelta { + return CMPSynGetDelta(self); +} + +- (NSSet *)cmp_syntheticAllWindows { + UIWindow *w = CMPSynGetWindow(self); + return w ? [NSSet setWithObject:w] : [NSSet set]; +} + +- (NSSet *)cmp_syntheticGestureRecognizersForWindow:(UIWindow *)window { + NSMutableSet *set = [NSMutableSet set]; + UIWindow *w = window ?: CMPSynGetWindow(self); + if (w != nil) { CMPCollectRecognizers(w, set); } + return set; +} + +@end + +#pragma mark - UIEvent (CMPTransformSyntheticOverrides) + +@interface UIEvent (CMPTransformSyntheticOverrides) + +- (NSInteger)cmp_syntheticTransformType; +- (CMPTransformPhase)cmp_syntheticTransformPhase; +- (CGFloat)cmp_syntheticScale; + +@end + +@implementation UIEvent (CMPTransformSyntheticOverrides) + +- (NSInteger)cmp_syntheticTransformType { + return UIEventTypeTransform; +} + +- (CMPTransformPhase)cmp_syntheticTransformPhase { + return (CMPTransformPhase)CMPSynGetPhase(self); +} + +- (CGFloat)cmp_syntheticScale { + return CMPSynGetScale(self); +} + +@end + +#pragma mark - Runtime subclass registration + +static void CMPInstallOverride(Class synCls, Class parent, SEL uikitSel, SEL srcSel) { + Method parentMethod = class_getInstanceMethod(parent, uikitSel); + if (parentMethod == NULL) { return; } + Method srcMethod = class_getInstanceMethod([UIEvent class], srcSel); + if (srcMethod == NULL) { return; } + class_addMethod(synCls, uikitSel, + method_getImplementation(srcMethod), + method_getTypeEncoding(parentMethod)); +} + +static Class CMPSyntheticScrollEventClass(void) { + static Class cls; + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class parent = NSClassFromString(@"UIScrollEvent"); + if (parent == Nil) { return; } + cls = objc_allocateClassPair(parent, "CMPSyntheticScrollEvent", 0); + if (cls == Nil) { return; } + + CMPInstallOverride(cls, parent, @selector(type), @selector(cmp_syntheticScrollType)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"phase"), @selector(cmp_syntheticPhase)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"locationInView:"), @selector(cmp_syntheticLocationInView:)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"acceleratedDelta"), @selector(cmp_syntheticDelta)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"_allWindows"), @selector(cmp_syntheticAllWindows)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"_gestureRecognizersForWindow:"), @selector(cmp_syntheticGestureRecognizersForWindow:)); + + objc_registerClassPair(cls); + }); + return cls; +} + +static Class CMPSyntheticTransformEventClass(void) { + static Class cls; + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class parent = NSClassFromString(@"UITransformEvent"); + if (parent == Nil) { return; } + cls = objc_allocateClassPair(parent, "CMPSyntheticTransformEvent", 0); + if (cls == Nil) { return; } + + CMPInstallOverride(cls, parent, @selector(type), @selector(cmp_syntheticTransformType)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"phase"), @selector(cmp_syntheticTransformPhase)); + CMPInstallOverride(cls, parent, NSSelectorFromString(@"scale"), @selector(cmp_syntheticScale)); + + objc_registerClassPair(cls); + }); + return cls; +} + +// Hover dispatch drives the recognizer via `shouldReceiveEvent:` directly and +// skips `-[UIApplication sendEvent:]`, so the synthetic subclass needs no +// overrides — it exists only to carry associated-object state on an instance +// that passes `isKindOfClass:[UIHoverEvent class]` checks. +static Class CMPSyntheticHoverEventClass(void) { + static Class cls; + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class parent = NSClassFromString(@"UIHoverEvent"); + if (parent == Nil) { return; } + cls = objc_allocateClassPair(parent, "CMPSyntheticHoverEvent", 0); + if (cls == Nil) { return; } + + objc_registerClassPair(cls); + }); + return cls; +} + +#pragma mark - Target-action forcing + +// UIKit's deferred action cycle only runs for events delivered through the +// hardware HID pipeline. `sendEvent:` on a synthetic event drives the +// recognizer's state machine (state transitions to Began/Changed/Ended) but +// skips the subsequent "invoke target-action" pass — so we reach into the +// recognizer's private `_targets` array and fire each `(target, action)` pair +// ourselves, with the same shape UIKit's own dispatcher would produce. +static void CMPForceRecognizerActions(NSSet *recognizers, Class cls) { + Ivar targetsIvar = class_getInstanceVariable([UIGestureRecognizer class], "_targets"); + if (targetsIvar == NULL) { return; } + for (UIGestureRecognizer *recognizer in recognizers) { + if (![recognizer isKindOfClass:cls]) { continue; } + UIGestureRecognizerState state = recognizer.state; + if (state != UIGestureRecognizerStateBegan && + state != UIGestureRecognizerStateChanged && + state != UIGestureRecognizerStateEnded && + state != UIGestureRecognizerStateCancelled) { + continue; + } + id targets = object_getIvar(recognizer, targetsIvar); + if (![targets isKindOfClass:[NSArray class]]) { continue; } + for (id pair in (NSArray *)targets) { + Ivar targetIvar = class_getInstanceVariable([pair class], "_target"); + Ivar actionIvar = class_getInstanceVariable([pair class], "_action"); + if (targetIvar == NULL || actionIvar == NULL) { continue; } + id tgt = object_getIvar(pair, targetIvar); + // `_action` holds a SEL, which `object_getIvar` can't return; read raw. + SEL act = *(SEL *)((uint8_t *)(__bridge void *)pair + ivar_getOffset(actionIvar)); + if (tgt != nil && act != NULL) { + ((void(*)(id, SEL, id))objc_msgSend)(tgt, act, recognizer); + } + } + } +} + +#pragma mark - Direct pinch recognizer driver + +// UIKit's gesture environment needs a real HID event to transition pinch +// recognizers into Began/Changed — synthetic transform events reach the +// environment but state never moves. Instead of trying to fake deeper into +// the pipeline, we drive the recognizer directly: force its state via +// private `-setState:`, write `scale` (public API), and let +// `CMPForceRecognizerActions` fire the bound target-actions. +// `UIScrollViewPinchGestureRecognizer`'s action reads `scale` and mutates +// `zoomScale`, so this path delivers a real zoom. +static void CMPDrivePinchRecognizers(NSSet *recognizers, + CGFloat absoluteScale, + CMPTransformPhase phase) { + UIGestureRecognizerState targetState; + switch (phase) { + case CMPTransformPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; + case CMPTransformPhaseChanged: targetState = UIGestureRecognizerStateChanged; break; + case CMPTransformPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; + case CMPTransformPhaseCancelled: targetState = UIGestureRecognizerStateCancelled; break; + default: return; + } + SEL setStateSel = NSSelectorFromString(@"setState:"); + for (UIGestureRecognizer *recognizer in recognizers) { + if (![recognizer isKindOfClass:[UIPinchGestureRecognizer class]]) { continue; } + UIPinchGestureRecognizer *pinch = (UIPinchGestureRecognizer *)recognizer; + + // `-setState:Began` resets internal `_scale` to 1.0, so write `scale` + // after the transition to avoid having the reset clobber our value. + ((void(*)(id, SEL, NSInteger))objc_msgSend)(pinch, setStateSel, targetState); + pinch.scale = absoluteScale; + } + CMPForceRecognizerActions(recognizers, [UIPinchGestureRecognizer class]); +} + +#pragma mark - UIEvent + +@implementation UIEvent (CMPScrollDispatch) + ++ (void)dispatchScrollOnEvent:(UIEvent *)event + atAnchor:(CGPoint)anchor + delta:(CGVector)delta + phase:(CMPScrollPhase)phase + inWindow:(UIWindow *)window { + CMPSynSetLocation(event, anchor); + CMPSynSetDelta(event, delta); + CMPSynSetPhase(event, phase); + if (window != nil) { CMPSynSetWindow(event, window); } + + NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + + [[UIApplication sharedApplication] sendEvent:event]; + + CMPForceRecognizerActions(recognizers, [UIPanGestureRecognizer class]); +} + ++ (void)dispatchTransformOnEvent:(UIEvent *)event + atAnchor:(CGPoint)anchor + scale:(CGFloat)scale + phase:(CMPTransformPhase)phase + inWindow:(UIWindow *)window { + CMPSynSetLocation(event, anchor); + CMPSynSetScale(event, scale); + CMPSynSetPhase(event, phase); + if (window != nil) { CMPSynSetWindow(event, window); } + + NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + + // DO NOT route through `sendEvent:`. UIKit's transform dispatch reads + // zeroed private ivars (`_dispatchWindows`, `_deliveryTableByTouch`, …) + // as object pointers and retains them unconditionally, crashing on iOS 26. + // We drive the pinch recognizer directly below, so UIKit's routing is + // unnecessary anyway. + CMPDrivePinchRecognizers(recognizers, scale, phase); +} + ++ (void)dispatchHoverOnEvent:(UIEvent *)event + atAnchor:(CGPoint)anchor + inWindow:(UIWindow *)window { + CMPSynSetLocation(event, anchor); + if (window != nil) { CMPSynSetWindow(event, window); } + + NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + + // Skip `-[UIApplication sendEvent:]` entirely — UIKit's hover path + // dereferences private touch-bookkeeping ivars we can't safely populate. + // `shouldReceiveEvent:` only needs the event, so invoke it directly as + // the delivery entry point. + SEL shouldReceiveEventSel = @selector(shouldReceiveEvent:); + for (UIGestureRecognizer *recognizer in recognizers) { + if (![recognizer isKindOfClass:[UIHoverGestureRecognizer class]]) { continue; } + ((BOOL(*)(id, SEL, id))objc_msgSend)(recognizer, shouldReceiveEventSel, event); + } + + CMPForceRecognizerActions(recognizers, [UIHoverGestureRecognizer class]); +} + ++ (void)forcePanRecognizerActionsIn:(NSSet *)recognizers { + CMPForceRecognizerActions(recognizers, [UIPanGestureRecognizer class]); +} + +@end + +@implementation UIEvent (CMPScroll) + ++ (nullable instancetype)scrollEventAtPoint:(CGPoint)point + delta:(CGPoint)delta + inWindow:(UIWindow *)window { + Class cls = CMPSyntheticScrollEventClass(); + if (cls == Nil) { return nil; } + id scrollEvent = [[cls alloc] init]; + if (scrollEvent == nil) { return nil; } + + CMPSynSetWindow(scrollEvent, window); + [UIEvent dispatchScrollOnEvent:scrollEvent + atAnchor:point + delta:CGVectorMake(delta.x, delta.y) + phase:CMPScrollPhaseBegan + inWindow:window]; + return (UIEvent *)scrollEvent; +} + +- (void)scrollByDelta:(CGPoint)delta inWindow:(UIWindow *)window { + [UIEvent dispatchScrollOnEvent:self + atAnchor:CMPSynGetLocation(self) + delta:CGVectorMake(delta.x, delta.y) + phase:CMPScrollPhaseChanged + inWindow:window]; +} + +- (void)endInWindow:(UIWindow *)window { + [UIEvent dispatchScrollOnEvent:self + atAnchor:CMPSynGetLocation(self) + delta:CGVectorMake(0, 0) + phase:CMPScrollPhaseEnded + inWindow:window]; +} + +@end + +@implementation UIEvent (CMPPinch) + ++ (nullable instancetype)pinchEventAtPoint:(CGPoint)point + scale:(CGFloat)scale + inWindow:(UIWindow *)window { + Class cls = CMPSyntheticTransformEventClass(); + if (cls == Nil) { return nil; } + id transformEvent = [[cls alloc] init]; + if (transformEvent == nil) { return nil; } + + CMPSynSetWindow(transformEvent, window); + [UIEvent dispatchTransformOnEvent:transformEvent + atAnchor:point + scale:scale + phase:CMPTransformPhaseBegan + inWindow:window]; + return (UIEvent *)transformEvent; +} + +- (void)pinchByScale:(CGFloat)scale inWindow:(UIWindow *)window { + [UIEvent dispatchTransformOnEvent:self + atAnchor:CMPSynGetLocation(self) + scale:scale + phase:CMPTransformPhaseChanged + inWindow:window]; +} + +- (void)endPinchInWindow:(UIWindow *)window { + [UIEvent dispatchTransformOnEvent:self + atAnchor:CMPSynGetLocation(self) + scale:CMPSynGetScale(self) + phase:CMPTransformPhaseEnded + inWindow:window]; +} + +@end + +@implementation UIEvent (CMPHover) + ++ (nullable instancetype)hoverEventAtPoint:(CGPoint)point + inWindow:(UIWindow *)window { + Class cls = CMPSyntheticHoverEventClass(); + if (cls == Nil) { return nil; } + id hoverEvent = [[cls alloc] init]; + if (hoverEvent == nil) { return nil; } + + CMPSynSetWindow(hoverEvent, window); + [UIEvent dispatchHoverOnEvent:hoverEvent + atAnchor:point + inWindow:window]; + return (UIEvent *)hoverEvent; +} + +- (void)hoverMoveToPoint:(CGPoint)point inWindow:(UIWindow *)window { + [UIEvent dispatchHoverOnEvent:self + atAnchor:point + inWindow:window]; +} + +- (void)endHoverInWindow:(UIWindow *)window { + [UIEvent dispatchHoverOnEvent:self + atAnchor:CMPSynGetLocation(self) + inWindow:window]; +} + +@end + +#pragma mark - UIEvent (CMPSyntheticLocation) — Public API + +@implementation UIEvent (CMPSyntheticLocation) + +- (CGPoint)cmp_locationInView:(UIView *)view { + return [self cmp_syntheticLocationInView:view]; +} + +@end diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsApp/CMPTestUtilsAppApp.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsApp/CMPTestUtilsAppApp.swift new file mode 100644 index 0000000000000..d66339e3010c0 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsApp/CMPTestUtilsAppApp.swift @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +@main +struct CMPTestUtilsAppApp: App { + var body: some Scene { + WindowGroup { + Color.blue + } + } +} diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h new file mode 100644 index 0000000000000..7d413834142b1 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/CMPTestUtilsAppTests-Bridging-Header.h @@ -0,0 +1,17 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPTestUtils.h" diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift new file mode 100644 index 0000000000000..a7640c923b578 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift @@ -0,0 +1,114 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import UIKit +import XCTest + +final class CMPHoverTest: XCTestCase { + private var appDelegate: MockAppDelegate! + + override func setUpWithError() throws { + super.setUp() + appDelegate = MockAppDelegate() + UIApplication.shared.delegate = appDelegate + appDelegate.setUpClearWindow() + } + + override func tearDownWithError() throws { + super.tearDown() + appDelegate?.cleanUp() + appDelegate = nil + } + + private func pumpRunLoop(_ seconds: TimeInterval) { + let end = Date().addingTimeInterval(seconds) + while Date() < end { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) + } + } + + @MainActor + func testSimulatedHoverReachesHoverRecognizer() { + let window = appDelegate.window! + + let viewController = HoverRecordingViewController() + window.rootViewController = viewController + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + let hover = UIEvent.hover(at: start, in: window) + XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + var expectedPoints: [CGPoint] = [start] + + // Walk the cursor across the view to generate multiple hover-moved dispatches. + let stepCount = 5 + let step = CGPoint(x: 12, y: 8) + for i in 1...stepCount { + let target = CGPoint(x: start.x + CGFloat(i) * step.x, + y: start.y + CGFloat(i) * step.y) + hover?.hoverMove(to: target, in: window) + expectedPoints.append(target) + pumpRunLoop(0.1) + } + hover?.endHover(in: window) + // endHover replays the last anchor, so the recognizer observes it twice. + expectedPoints.append(expectedPoints.last!) + + let deadline = Date().addingTimeInterval(2.0) + while viewController.recognizer.receivedLocations.isEmpty && Date() < deadline { + pumpRunLoop(0.05) + } + + // The view fills the window, so window-space == view-space here. + XCTAssertEqual( + viewController.recognizer.receivedLocations, expectedPoints, + "UIHoverGestureRecognizer received hover events at unexpected points." + ) + } +} + +/// Captures the anchor of every hover event delivered to `shouldReceive(_:)`. +/// Reading the location eagerly (not after the fact) matters because the +/// synthetic dispatch reuses a single `UIEvent` and mutates its anchor +/// between calls. +private final class RecordingHoverRecognizer: UIHoverGestureRecognizer { + private(set) var receivedLocations: [CGPoint] = [] + + override func shouldReceive(_ event: UIEvent) -> Bool { + if let view = self.view { + receivedLocations.append(event.cmp_location(in: view)) + } + return super.shouldReceive(event) + } +} + +private final class HoverRecordingViewController: UIViewController { + let recognizer = RecordingHoverRecognizer() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + recognizer.addTarget(self, action: #selector(handleHover(_:))) + view.addGestureRecognizer(recognizer) + } + + @objc + func handleHover(_ recognizer: UIHoverGestureRecognizer) { + } +} diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/MockAppDelegate.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/MockAppDelegate.swift new file mode 100644 index 0000000000000..3d4397a7cb355 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/MockAppDelegate.swift @@ -0,0 +1,36 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import UIKit + +class MockAppDelegate: NSObject, UIApplicationDelegate { + var window: UIWindow? + + func setUpClearWindow() { + window = UIWindow(frame: UIScreen.main.bounds) + window?.backgroundColor = .systemBackground + + UIView.setAnimationsEnabled(false) + + window?.rootViewController = UIViewController() + window?.makeKeyAndVisible() + } + + func cleanUp() { + window = nil + UIWindow(frame: UIScreen.main.bounds).makeKeyAndVisible() + } +} diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift new file mode 100644 index 0000000000000..3178a3cb349ec --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift @@ -0,0 +1,169 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import UIKit +import XCTest + +final class CMPPanTest: XCTestCase { + private var appDelegate: MockAppDelegate! + + override func setUpWithError() throws { + super.setUp() + appDelegate = MockAppDelegate() + UIApplication.shared.delegate = appDelegate + appDelegate.setUpClearWindow() + } + + override func tearDownWithError() throws { + super.tearDown() + appDelegate?.cleanUp() + appDelegate = nil + } + + private func pumpRunLoop(_ seconds: TimeInterval) { + // Run both default and tracking modes — UIKit gesture-action dispatch is + // observed to fire in one or the other depending on how the event entered + // the system (touch vs. synthetic UIScrollEvent). + let end = Date().addingTimeInterval(seconds) + while Date() < end { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) + } + } + + @MainActor + func testSimulatedDragReachesPanRecognizer() { + let window = appDelegate.window! + + let viewController = PanRecordingViewController() + window.rootViewController = viewController + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + let perStepDelta = CGPoint(x: 20, y: 30) + let stepCount = 5 + + let scroll = UIEvent.scroll(at: start, delta: perStepDelta, in: window) + pumpRunLoop(0.1) + + for _ in 0.. UIView? { + return contentView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + zoomEventCount += 1 + } +} + +private final class PinchRecordingViewController: UIViewController { + let recognizer = UIPinchGestureRecognizer() + private(set) var events: [(state: UIGestureRecognizer.State, scale: CGFloat)] = [] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + recognizer.addTarget(self, action: #selector(handlePinch(_:))) + view.addGestureRecognizer(recognizer) + } + + @objc + func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + events.append((state: recognizer.state, scale: recognizer.scale)) + } +} From 99d630c3c8dd6088c30cdeaf09f84f7d91ffe682 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 24 Apr 2026 15:37:04 +0200 Subject: [PATCH 2/8] Fix pan tests --- .../androidx/compose/ui/Configuration.kt | 2 - .../compose/ui/interaction/TrackpadPanTest.kt | 4 +- .../iosMain/objc/CMPTestUtils/UIEvent+Test.m | 22 +++++++++ .../objc/CMPTestUtilsAppTests/PanTest.swift | 48 +++++++++++++++---- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt index b02196ecf292b..961cff994452e 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt @@ -16,7 +16,6 @@ package androidx.compose.ui -import androidx.compose.ui.interaction.TrackpadPanTest import androidx.compose.xctest.setupXCTestSuite import kotlinx.cinterop.ExperimentalForeignApi import platform.XCTest.XCTestSuite @@ -24,7 +23,6 @@ import platform.XCTest.XCTestSuite @Suppress("unused") @OptIn(ExperimentalForeignApi::class) fun testSuite(): XCTestSuite = setupXCTestSuite( - TrackpadPanTest::class // Run all test cases from the tests // BasicInteractionTest::class, // LayersAccessibilityTest::class, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt index b0006a6fe224a..486c05e667027 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt @@ -114,8 +114,8 @@ internal class TrackpadPanTest { val expectedTotalPxAbs = panDx.value * density.density assertTrue( - totalPan.value.getDistance() > expectedTotalPxAbs / 2, - "Accumulated pan offset (${totalPan.value}) should be in the ballpark of " + + totalPan.getDistance() > expectedTotalPxAbs / 2, + "Accumulated pan offset ($totalPan) should be in the ballpark of " + "the simulated delta (~${expectedTotalPxAbs}px along X)." ) } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m index 27c3efc09250a..d51c205ed75cc 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -340,6 +340,28 @@ + (void)dispatchScrollOnEvent:(UIEvent *)event [[UIApplication sharedApplication] sendEvent:event]; + // UIKit's in-process scroll dispatch keeps every UIPanGestureRecognizer stuck + // at .began after each sendEvent — the translation accumulates correctly but + // the recognizer's `state` never advances past .began until the session ends. + // Force the state to match the scroll phase so target-actions observe the + // proper Began → Changed …→ Ended lifecycle. + UIGestureRecognizerState targetState; + switch (phase) { + case CMPScrollPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; + case CMPScrollPhaseChanged: targetState = UIGestureRecognizerStateChanged; break; + case CMPScrollPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; + default: targetState = UIGestureRecognizerStatePossible; break; + } + if (targetState != UIGestureRecognizerStatePossible) { + SEL setStateSel = NSSelectorFromString(@"setState:"); + for (UIGestureRecognizer *r in recognizers) { + if (![r isKindOfClass:[UIPanGestureRecognizer class]]) { continue; } + if (r.state != targetState) { + ((void(*)(id, SEL, NSInteger))objc_msgSend)(r, setStateSel, targetState); + } + } + } + CMPForceRecognizerActions(recognizers, [UIPanGestureRecognizer class]); } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift index 3178a3cb349ec..d00b01cee1f33 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift @@ -72,22 +72,37 @@ final class CMPPanTest: XCTestCase { XCTFail("UIPanGestureRecognizer received no state transitions from the simulated scroll stream.") } - // Expected per-fire accumulation: the recognizer only transitions to Began - // on the second dispatch (the first `scrollByDelta`), and UIKit applies a - // small hysteresis (~10pt on each axis) to the step that triggers the - // Began state. After that the translation grows by exactly perStepDelta - // on each subsequent dispatch, so the total is - // stepCount × perStepDelta − hysteresis - // e.g. 5 × (20, 30) − (10, 10) = (90, 140). ±15 accommodates minor UIKit - // differences without masking a real regression. + // The recognizer must observe the full scroll lifecycle — we want to see a + // Began state at least once, multiple Changed states (one per scroll step + // past the one that triggers Began), and a terminal Ended state. + XCTAssertEqual(viewController.states.first, .began, + "Expected first recorded state to be .began, got \(describe(viewController.states.first))") + XCTAssertEqual(viewController.states.last, .ended, + "Expected last recorded state to be .ended, got \(describe(viewController.states.last))") + + let changedCount = viewController.states.filter { $0 == .changed }.count + XCTAssertEqual(changedCount, stepCount, + "Expected exactly \(stepCount) .changed transitions, got \(changedCount); states=\(viewController.states.map(describe))") + + // The healthy sequence is Began → Changed* → Ended with no interstitial + // failed/cancelled/possible transitions. + let allowed: Set = [.began, .changed, .ended] + for state in viewController.states { + XCTAssertTrue(allowed.contains(state), + "Unexpected state \(describe(state)) in sequence \(viewController.states.map(describe))") + } + + // Translation accumulates by perStepDelta on each `scrollByDelta` + // dispatch — the initial `scrollEventAt` delta opens the session but is + // not applied, so total = stepCount × perStepDelta. let last = viewController.translations.last ?? .zero let expected = CGPoint( x: CGFloat(stepCount) * perStepDelta.x, y: CGFloat(stepCount) * perStepDelta.y ) - XCTAssertEqual(last.x, expected.x, accuracy: 15, + XCTAssertEqual(last.x, expected.x, accuracy: 0.5, "Unexpected accumulated x translation; got \(last.x), translations=\(viewController.translations)") - XCTAssertEqual(last.y, expected.y, accuracy: 15, + XCTAssertEqual(last.y, expected.y, accuracy: 0.5, "Unexpected accumulated y translation; got \(last.y), translations=\(viewController.translations)") } @@ -127,6 +142,19 @@ final class CMPPanTest: XCTestCase { } } +private func describe(_ state: UIGestureRecognizer.State?) -> String { + guard let state = state else { return "nil" } + switch state { + case .possible: return "possible" + case .began: return "began" + case .changed: return "changed" + case .ended: return "ended" + case .cancelled: return "cancelled" + case .failed: return "failed" + @unknown default: return "unknown(\(state.rawValue))" + } +} + private final class VerticalScrollHostViewController: UIViewController { let scrollView = UIScrollView() From ce843d71c6ccb8e904daba5c54256a75d1465a57 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 24 Apr 2026 17:11:05 +0200 Subject: [PATCH 3/8] Update pan tests --- .../compose/ui/window/InputViews.ios.kt | 29 +-- .../compose/ui/interaction/TrackpadPanTest.kt | 76 +++---- .../compose/ui/test/UIKitInstrumentedTest.kt | 44 ++-- .../xcschemes/CMPTestUtilsApp.xcscheme | 16 -- .../iosMain/objc/CMPTestUtils/UIEvent+Test.h | 12 -- .../iosMain/objc/CMPTestUtils/UIEvent+Test.m | 10 - .../objc/CMPTestUtilsAppTests/HoverTest.swift | 195 ++++++++---------- 7 files changed, 145 insertions(+), 237 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index d7bdce999f670..75e0856581a63 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -57,7 +57,6 @@ import platform.UIKit.UIGestureRecognizerStateChanged import platform.UIKit.UIGestureRecognizerStateEnded import platform.UIKit.UIGestureRecognizerStateFailed import platform.UIKit.UIGestureRecognizerStatePossible -import platform.UIKit.UIHoverGestureRecognizer import platform.UIKit.UIPanGestureRecognizer import platform.UIKit.UIPinchGestureRecognizer import platform.UIKit.UIPressesEvent @@ -247,7 +246,9 @@ private class TouchesGestureRecognizer( private fun cancelAllTrackedTouches() { setState(UIGestureRecognizerStateCancelled) - onCancelAllTouches(trackedTouches.keys) + if (trackedTouches.isNotEmpty()) { + onCancelAllTouches(trackedTouches.keys) + } trackedTouches.clear() cancelTouchesFailure() } @@ -431,39 +432,39 @@ private class ScrollGestureRecognizer( } private var cursorPosition: DpOffset? = null - private var previousPosition: DpOffset? = null + private var previousTransition: DpOffset? = null private var event: UIEvent? = null @OptIn(BetaInteropApi::class) @ObjCAction fun onPan(gestureRecognizer: UIPanGestureRecognizer) { - val position = gestureRecognizer.locationInView(view).asDpOffset() + val transition = gestureRecognizer.translationInView(view).asDpOffset() when (gestureRecognizer.state) { UIGestureRecognizerStateBegan -> { - onScrollEvent(position, DpOffset.Zero, event, TouchesEventKind.BEGAN) - cursorPosition = position - previousPosition = position + onScrollEvent(transition, DpOffset.Zero, event, TouchesEventKind.BEGAN) + cursorPosition = transition + previousTransition = transition } UIGestureRecognizerStateChanged -> { - val delta = (previousPosition ?: position) - position - onScrollEvent(cursorPosition ?: position, delta, event, TouchesEventKind.MOVED) - previousPosition = position + val delta = (previousTransition ?: transition) - transition + onScrollEvent(cursorPosition ?: transition, delta, event, TouchesEventKind.MOVED) + previousTransition = transition } UIGestureRecognizerStateEnded -> { - val delta = (previousPosition ?: position) - position - onScrollEvent(cursorPosition ?: position, delta, event, TouchesEventKind.ENDED) + val delta = (previousTransition ?: transition) - transition + onScrollEvent(cursorPosition ?: transition, delta, event, TouchesEventKind.ENDED) cursorPosition = null - previousPosition = null + previousTransition = null event = null } UIGestureRecognizerStateCancelled, UIGestureRecognizerStateFailed -> { onCancelScroll() cursorPosition = null - previousPosition = null + previousTransition = null event = null } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt index 486c05e667027..43cb7c4b2e067 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt @@ -23,16 +23,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.utils.endScroll import androidx.compose.ui.test.utils.scrollBy import androidx.compose.ui.test.utils.scrollEventAt +import androidx.compose.ui.touch import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.center import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toDpOffset import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds @@ -41,9 +47,9 @@ internal class TrackpadPanTest { @Test fun testPointerInputReceivesTrackpadPan() = runUIKitInstrumentedTest { - val panStartCount = mutableStateOf(0) - val panMoveCount = mutableStateOf(0) - val panEndCount = mutableStateOf(0) + var panStartCount = 0 + var panMoveCount = 0 + var panEndCount = 0 var totalPan = Offset.Zero setContent { @@ -54,21 +60,18 @@ internal class TrackpadPanTest { .pointerInput(Unit) { awaitPointerEventScope { while (true) { - val event = awaitPointerEvent() - println( - ">>> pointerInput got ${event.type}" + - " changes=${event.changes.size}" + - " | ${event.changes.map { it.panOffset }}" - ) + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + when (event.type) { - PointerEventType.PanStart -> panStartCount.value++ + PointerEventType.PanStart -> panStartCount++ PointerEventType.PanMove -> { - panMoveCount.value++ + panMoveCount++ totalPan = event.changes.fold(totalPan) { acc, c -> acc + c.panOffset } } - PointerEventType.PanEnd -> panEndCount.value++ + + PointerEventType.PanEnd -> panEndCount++ } } } @@ -76,47 +79,18 @@ internal class TrackpadPanTest { ) } - val panDx = 120.dp - val panDy = 0.dp - val steps = 8 - val stepInterval = 16.milliseconds - val perStepDelta = DpOffset(panDx / steps.toFloat(), panDy / steps.toFloat()) - val center = DpOffset(screenSize.width / 2, screenSize.height / 2) - val window = appDelegate.window() - assertNotNull(window, "Host window must exist") - - val scrollEvent = window.scrollEventAt(location = center, delta = perStepDelta) - - // 2. Emit UIScrollPhaseChanged events for each subsequent step. - repeat(steps - 1) { - UIKitInstrumentedTest.delay(stepInterval.inWholeMilliseconds) - scrollEvent.scrollBy(delta = perStepDelta, window = window) - } - - // 3. Close the session — UIScrollPhaseEnded. - UIKitInstrumentedTest.delay(stepInterval.inWholeMilliseconds) - scrollEvent.endScroll(window = window) - - waitForIdle() + trackpadPan(screenSize.center, 120.dp, dy = 75.dp) + assertEquals(1, panStartCount) assertTrue( - panStartCount.value >= 1, - "Expected at least one PanStart, received ${panStartCount.value}" - ) - assertTrue( - panMoveCount.value >= 1, - "Expected at least one PanMove, received ${panMoveCount.value}" - ) - assertTrue( - panEndCount.value >= 1, - "Expected at least one PanEnd, received ${panEndCount.value}" + panMoveCount >= 1, + "Expected at least one PanMove, received ${panMoveCount}" ) + assertEquals(1, panEndCount) - val expectedTotalPxAbs = panDx.value * density.density - assertTrue( - totalPan.getDistance() > expectedTotalPxAbs / 2, - "Accumulated pan offset ($totalPan) should be in the ballpark of " + - "the simulated delta (~${expectedTotalPxAbs}px along X)." - ) + val totalPanDp = totalPan.toDpOffset(density) + println(">>> totalPan=$totalPanDp") + assertEquals(120.dp.value, totalPanDp.x.value) + assertEquals(75.dp.value, totalPanDp.y.value) } -} +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 158e37276f281..149ace0ec9895 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -482,40 +482,25 @@ internal class UIKitInstrumentedTest( } /** - * Simulates a trackpad continuous pan gesture as a stateful scroll session: - * 1. [UIWindow.scrollEventAt] opens the session (`UIScrollPhaseBegan`). - * 2. [UIEvent.scrollBy] emits [steps] `UIScrollPhaseChanged` events carrying - * the linear per-step delta. - * 3. [UIEvent.endScroll] closes the session (`UIScrollPhaseEnded`). - * + * Simulates a trackpad continuous pan gesture as a stateful scroll session anchored + * at [position]. * All scroll synthesis goes through the `UIEvent (CMPScroll)` category in - * `UIEvent+Test.m`, which builds a scroll-rooted HID tree with a pointer - * child (matching the on-device trace) and dispatches through - * `-[UIApplication sendEvent:]` and `-[UIApplication _handleHIDEvent:]`. - * - * Known limitation (iOS 18, April 2026): synthetic `UIScrollEvent` dispatched - * from the same process does NOT trigger - * `UIPanGestureRecognizer.allowedScrollTypesMask`. See [TrackpadPanTest] for - * details. The scaffolding is kept for future experiments. - * - * @return `true` once the sequence has been dispatched. `false` only if the - * runtime lacks the private `UIScrollEvent` class. + * `UIEvent+Test.m`, which dispatches via `-[UIApplication sendEvent:]` and + * then forces each [UIPanGestureRecognizer] into the matching state so + * target-actions observe the proper `Began → Changed …→ Ended` lifecycle. */ fun trackpadPan( position: DpOffset, - totalDelta: DpOffset, - steps: Int = 10, - stepInterval: Duration = 16.milliseconds, - window: UIWindow? = null, - ): Boolean { - require(steps >= 1) { "steps must be >= 1" } - val targetWindow = window ?: appDelegate.window()!! - - val scrollEvent = targetWindow.scrollEventAt(location = position) ?: return false + dx: Dp = 0.dp, + dy: Dp = 0.dp, + duration: Duration = 0.5.seconds, + ) { + val stepInterval = 16.milliseconds + val steps = maxOf(1, (duration / stepInterval).toInt()) + val targetWindow = appDelegate.window()!! + val scrollEvent = targetWindow.scrollEventAt(location = position) - val dxPerStep = totalDelta.x / steps.toFloat() - val dyPerStep = totalDelta.y / steps.toFloat() - val perStepDelta = DpOffset(dxPerStep, dyPerStep) + val perStepDelta = DpOffset(dx / steps.toFloat(), dy / steps.toFloat()) repeat(steps) { delay(stepInterval.inWholeMilliseconds) scrollEvent.scrollBy(perStepDelta, targetWindow) @@ -523,7 +508,6 @@ internal class UIKitInstrumentedTest( delay(stepInterval.inWholeMilliseconds) scrollEvent.endScroll(targetWindow) - return true } } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsApp.xcscheme b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsApp.xcscheme index 5409c494bf9c3..0adddacebadff 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsApp.xcscheme +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/xcshareddata/xcschemes/CMPTestUtilsApp.xcscheme @@ -1,20 +1,4 @@ - - diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h index 8fd8e8285cd08..b52ff12df8c93 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h @@ -77,16 +77,4 @@ NS_ASSUME_NONNULL_BEGIN @end - -/** Read the anchor point from a synthetic scroll/pinch/hover event. */ -@interface UIEvent (CMPSyntheticLocation) - -/** - * Returns the synthetic event's anchor point converted to [view]'s coordinate - * space. If [view] is `nil`, returns the raw window-space point. - */ -- (CGPoint)cmp_locationInView:(nullable UIView *)view; - -@end - NS_ASSUME_NONNULL_END diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m index d51c205ed75cc..340767201669f 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -515,13 +515,3 @@ - (void)endHoverInWindow:(UIWindow *)window { } @end - -#pragma mark - UIEvent (CMPSyntheticLocation) — Public API - -@implementation UIEvent (CMPSyntheticLocation) - -- (CGPoint)cmp_locationInView:(UIView *)view { - return [self cmp_syntheticLocationInView:view]; -} - -@end diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift index a7640c923b578..85abb61c201af 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift @@ -1,114 +1,101 @@ /* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +* Copyright 2026 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ import UIKit import XCTest final class CMPHoverTest: XCTestCase { - private var appDelegate: MockAppDelegate! - - override func setUpWithError() throws { - super.setUp() - appDelegate = MockAppDelegate() - UIApplication.shared.delegate = appDelegate - appDelegate.setUpClearWindow() - } - - override func tearDownWithError() throws { - super.tearDown() - appDelegate?.cleanUp() - appDelegate = nil - } - - private func pumpRunLoop(_ seconds: TimeInterval) { - let end = Date().addingTimeInterval(seconds) - while Date() < end { - RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) - RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) - } - } - - @MainActor - func testSimulatedHoverReachesHoverRecognizer() { - let window = appDelegate.window! - - let viewController = HoverRecordingViewController() - window.rootViewController = viewController - - let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) - - let hover = UIEvent.hover(at: start, in: window) - XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") - pumpRunLoop(0.1) - - var expectedPoints: [CGPoint] = [start] - - // Walk the cursor across the view to generate multiple hover-moved dispatches. - let stepCount = 5 - let step = CGPoint(x: 12, y: 8) - for i in 1...stepCount { - let target = CGPoint(x: start.x + CGFloat(i) * step.x, - y: start.y + CGFloat(i) * step.y) - hover?.hoverMove(to: target, in: window) - expectedPoints.append(target) - pumpRunLoop(0.1) - } - hover?.endHover(in: window) - // endHover replays the last anchor, so the recognizer observes it twice. - expectedPoints.append(expectedPoints.last!) - - let deadline = Date().addingTimeInterval(2.0) - while viewController.recognizer.receivedLocations.isEmpty && Date() < deadline { - pumpRunLoop(0.05) - } - - // The view fills the window, so window-space == view-space here. - XCTAssertEqual( - viewController.recognizer.receivedLocations, expectedPoints, - "UIHoverGestureRecognizer received hover events at unexpected points." - ) - } -} - -/// Captures the anchor of every hover event delivered to `shouldReceive(_:)`. -/// Reading the location eagerly (not after the fact) matters because the -/// synthetic dispatch reuses a single `UIEvent` and mutates its anchor -/// between calls. -private final class RecordingHoverRecognizer: UIHoverGestureRecognizer { - private(set) var receivedLocations: [CGPoint] = [] - - override func shouldReceive(_ event: UIEvent) -> Bool { - if let view = self.view { - receivedLocations.append(event.cmp_location(in: view)) - } - return super.shouldReceive(event) - } + private var appDelegate: MockAppDelegate! + + override func setUpWithError() throws { + super.setUp() + appDelegate = MockAppDelegate() + UIApplication.shared.delegate = appDelegate + appDelegate.setUpClearWindow() + } + + override func tearDownWithError() throws { + super.tearDown() + appDelegate?.cleanUp() + appDelegate = nil + } + + private func pumpRunLoop(_ seconds: TimeInterval) { + let end = Date().addingTimeInterval(seconds) + while Date() < end { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) + } + } + + @MainActor + func testSimulatedHoverReachesHoverRecognizer() { + let window = appDelegate.window! + + let viewController = HoverRecordingViewController() + window.rootViewController = viewController + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + let hover = UIEvent.hover(at: start, in: window) + XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + var expectedPoints: [CGPoint] = [start] + + // Walk the cursor across the view to generate multiple hover-moved dispatches. + let stepCount = 5 + let step = CGPoint(x: 12, y: 8) + for i in 1...stepCount { + let target = CGPoint(x: start.x + CGFloat(i) * step.x, + y: start.y + CGFloat(i) * step.y) + hover?.hoverMove(to: target, in: window) + expectedPoints.append(target) + pumpRunLoop(0.1) + } + hover?.endHover(in: window) + // endHover replays the last anchor, so the recognizer observes it twice. + expectedPoints.append(expectedPoints.last!) + + let deadline = Date().addingTimeInterval(2.0) + while viewController.receivedLocations.isEmpty && Date() < deadline { + pumpRunLoop(0.05) + } + + // The view fills the window, so window-space == view-space here. + XCTAssertEqual( + viewController.receivedLocations, expectedPoints, + "UIHoverGestureRecognizer received hover events at unexpected points." + ) + } } private final class HoverRecordingViewController: UIViewController { - let recognizer = RecordingHoverRecognizer() - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - recognizer.addTarget(self, action: #selector(handleHover(_:))) - view.addGestureRecognizer(recognizer) - } - - @objc - func handleHover(_ recognizer: UIHoverGestureRecognizer) { - } + private let recognizer = UIHoverGestureRecognizer() + private(set) var receivedLocations: [CGPoint] = [] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + recognizer.addTarget(self, action: #selector(handleHover(_:))) + view.addGestureRecognizer(recognizer) + } + + @objc + func handleHover(_ recognizer: UIHoverGestureRecognizer) { + receivedLocations.append(recognizer.location(in: view)) + } } From 91989f7e29a60fd53dec19bcfee3db0f20a7943e Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 24 Apr 2026 17:55:26 +0200 Subject: [PATCH 4/8] Revert input views --- .../compose/ui/window/InputViews.ios.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 75e0856581a63..d2ad952268970 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -432,39 +432,39 @@ private class ScrollGestureRecognizer( } private var cursorPosition: DpOffset? = null - private var previousTransition: DpOffset? = null + private var previousPosition: DpOffset? = null private var event: UIEvent? = null @OptIn(BetaInteropApi::class) @ObjCAction fun onPan(gestureRecognizer: UIPanGestureRecognizer) { - val transition = gestureRecognizer.translationInView(view).asDpOffset() + val position = gestureRecognizer.locationInView(view).asDpOffset() when (gestureRecognizer.state) { UIGestureRecognizerStateBegan -> { - onScrollEvent(transition, DpOffset.Zero, event, TouchesEventKind.BEGAN) - cursorPosition = transition - previousTransition = transition + onScrollEvent(position, DpOffset.Zero, event, TouchesEventKind.BEGAN) + cursorPosition = position + previousPosition = position } UIGestureRecognizerStateChanged -> { - val delta = (previousTransition ?: transition) - transition - onScrollEvent(cursorPosition ?: transition, delta, event, TouchesEventKind.MOVED) - previousTransition = transition + val delta = (previousPosition ?: position) - position + onScrollEvent(cursorPosition ?: position, delta, event, TouchesEventKind.MOVED) + previousPosition = position } UIGestureRecognizerStateEnded -> { - val delta = (previousTransition ?: transition) - transition - onScrollEvent(cursorPosition ?: transition, delta, event, TouchesEventKind.ENDED) + val delta = (previousPosition ?: position) - position + onScrollEvent(cursorPosition ?: position, delta, event, TouchesEventKind.ENDED) cursorPosition = null - previousTransition = null + previousPosition = null event = null } UIGestureRecognizerStateCancelled, UIGestureRecognizerStateFailed -> { onCancelScroll() cursorPosition = null - previousTransition = null + previousPosition = null event = null } From ab9b20fae04c53c43a53345acfc49ee125af2ff3 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 27 Apr 2026 11:00:55 +0200 Subject: [PATCH 5/8] Fix pinch tests --- .../androidx/compose/ui/Configuration.kt | 2 + .../compose/ui/interaction/TrackpadPanTest.kt | 142 ++++++++++++-- .../CMPTestUtils.xcodeproj/project.pbxproj | 4 +- .../iosMain/objc/CMPTestUtils/UIEvent+Test.m | 82 +++++--- .../objc/CMPTestUtilsAppTests/HoverTest.swift | 182 +++++++++--------- .../objc/CMPTestUtilsAppTests/PanTest.swift | 82 +++++--- .../objc/CMPTestUtilsAppTests/PinchTest.swift | 97 +++++++++- 7 files changed, 425 insertions(+), 166 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt index 961cff994452e..b02196ecf292b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt @@ -16,6 +16,7 @@ package androidx.compose.ui +import androidx.compose.ui.interaction.TrackpadPanTest import androidx.compose.xctest.setupXCTestSuite import kotlinx.cinterop.ExperimentalForeignApi import platform.XCTest.XCTestSuite @@ -23,6 +24,7 @@ import platform.XCTest.XCTestSuite @Suppress("unused") @OptIn(ExperimentalForeignApi::class) fun testSuite(): XCTestSuite = setupXCTestSuite( + TrackpadPanTest::class // Run all test cases from the tests // BasicInteractionTest::class, // LayersAccessibilityTest::class, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt index 43cb7c4b2e067..228fb760f5cc4 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt @@ -16,32 +16,31 @@ package androidx.compose.ui.interaction +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.runUIKitInstrumentedTest -import androidx.compose.ui.test.utils.endScroll -import androidx.compose.ui.test.utils.scrollBy -import androidx.compose.ui.test.utils.scrollEventAt -import androidx.compose.ui.touch -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.center import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toDpOffset import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds internal class TrackpadPanTest { @@ -89,8 +88,125 @@ internal class TrackpadPanTest { assertEquals(1, panEndCount) val totalPanDp = totalPan.toDpOffset(density) - println(">>> totalPan=$totalPanDp") - assertEquals(120.dp.value, totalPanDp.x.value) - assertEquals(75.dp.value, totalPanDp.y.value) + assertEquals(120.dp.value, totalPanDp.x.value, absoluteTolerance = 0.5f) + assertEquals(75.dp.value, totalPanDp.y.value, absoluteTolerance = 0.5f) + } + + @Test + fun testVerticalScroll() = runUIKitInstrumentedTest { + val state = ScrollState(0) + + setContent { + Column(modifier = Modifier.fillMaxSize().verticalScroll(state)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .background(Color.Red) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenSize.height) + .background(Color.Blue) + ) + } + } + + trackpadPan(screenSize.center, 0.dp, dy = 75.dp) + waitForIdle() + assertEquals(with(density) { 75.dp.roundToPx() }, state.value) + + trackpadPan(screenSize.center, 0.dp, dy = (-75).dp) + waitForIdle() + assertEquals(0, state.value) + } + + @Test + fun testHorizontalScroll() = runUIKitInstrumentedTest { + val state = ScrollState(0) + + setContent { + Row(modifier = Modifier.fillMaxSize().horizontalScroll(state)) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(100.dp) + .background(Color.Red) + ) + Box( + modifier = Modifier + .fillMaxHeight() + .width(screenSize.width) + .background(Color.Blue) + ) + } + } + + trackpadPan(screenSize.center, dx = 75.dp) + waitForIdle() + assertEquals(with(density) { 75.dp.roundToPx() }, state.value) + + trackpadPan(screenSize.center, dx = (-75).dp) + waitForIdle() + assertEquals(0, state.value) + } + + @Test + fun testPointerInputReceivesMultipleTrackpadPans() = runUIKitInstrumentedTest { + var panStartCount = 0 + var panMoveCount = 0 + var panEndCount = 0 + var totalPan = Offset.Zero + + setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + when (event.type) { + PointerEventType.PanStart -> panStartCount++ + PointerEventType.PanMove -> { + panMoveCount++ + totalPan = event.changes.fold(totalPan) { acc, c -> + acc + c.panOffset + } + } + + PointerEventType.PanEnd -> panEndCount++ + } + } + } + } + ) + } + + val deltas = listOf( + 120.dp to 75.dp, + (-50).dp to 30.dp, + 0.dp to (-90).dp, + ) + for ((dx, dy) in deltas) { + trackpadPan(screenSize.center, dx = dx, dy = dy) + } + waitForIdle() + + assertEquals(deltas.size, panStartCount) + assertEquals(deltas.size, panEndCount) + assertTrue( + panMoveCount >= deltas.size, + "Expected at least ${deltas.size} PanMove events, received $panMoveCount" + ) + + val expectedDx = deltas.sumOf { it.first.value.toDouble() }.toFloat() + val expectedDy = deltas.sumOf { it.second.value.toDouble() }.toFloat() + val totalPanDp = totalPan.toDpOffset(density) + assertEquals(expectedDx, totalPanDp.x.value, absoluteTolerance = 0.5f) + assertEquals(expectedDy, totalPanDp.y.value, absoluteTolerance = 0.5f) } } \ No newline at end of file diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj index ba5a274a824c9..7041f5b32db05 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -501,7 +501,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m index 340767201669f..eb43cca5205c5 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -288,16 +288,46 @@ static void CMPForceRecognizerActions(NSSet *recognizers, } } +#pragma mark - State override (UIGestureRecognizer) + +// `UIGestureRecognizer.state` is cached outside the recognizer (in +// UIGestureEnvironment / gesture-graph nodes) and rejects backwards +// transitions via `setState:`, so a synthetic gesture session that ends in +// .ended cannot be reopened: the next .began dispatch leaves state stuck at +// .ended and target-actions observe the wrong phase. Swizzle the getter so we +// can pin the value the action handler reads via an associated object. + +static const void *kCMPGRStateOverrideKey = &kCMPGRStateOverrideKey; +static IMP gOriginalGRStateImp = NULL; + +static UIGestureRecognizerState CMPSwizzledState(id self, SEL _cmd) { + NSNumber *override = objc_getAssociatedObject(self, kCMPGRStateOverrideKey); + if (override != nil) { + return (UIGestureRecognizerState)[override integerValue]; + } + return ((UIGestureRecognizerState(*)(id, SEL))gOriginalGRStateImp)(self, _cmd); +} + +static void CMPInstallStateOverrideOnce(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + Method m = class_getInstanceMethod([UIGestureRecognizer class], @selector(state)); + if (m == NULL) { return; } + gOriginalGRStateImp = method_getImplementation(m); + method_setImplementation(m, (IMP)CMPSwizzledState); + }); +} + #pragma mark - Direct pinch recognizer driver // UIKit's gesture environment needs a real HID event to transition pinch // recognizers into Began/Changed — synthetic transform events reach the -// environment but state never moves. Instead of trying to fake deeper into -// the pipeline, we drive the recognizer directly: force its state via -// private `-setState:`, write `scale` (public API), and let -// `CMPForceRecognizerActions` fire the bound target-actions. -// `UIScrollViewPinchGestureRecognizer`'s action reads `scale` and mutates -// `zoomScale`, so this path delivers a real zoom. +// environment but state never moves. Pin the value the target-action handler +// reads via the state-override swizzle, write `scale` (public API), and let +// `CMPForceRecognizerActions` fire the bound target-actions. `setState:` would +// double-fire (UIKit fires the action on the transition AND the force-fire +// fires it again) and would reject backwards transitions, blocking a second +// pinch session after the first ends. static void CMPDrivePinchRecognizers(NSSet *recognizers, CGFloat absoluteScale, CMPTransformPhase phase) { @@ -309,14 +339,11 @@ static void CMPDrivePinchRecognizers(NSSet *recognizers, case CMPTransformPhaseCancelled: targetState = UIGestureRecognizerStateCancelled; break; default: return; } - SEL setStateSel = NSSelectorFromString(@"setState:"); for (UIGestureRecognizer *recognizer in recognizers) { if (![recognizer isKindOfClass:[UIPinchGestureRecognizer class]]) { continue; } UIPinchGestureRecognizer *pinch = (UIPinchGestureRecognizer *)recognizer; - - // `-setState:Began` resets internal `_scale` to 1.0, so write `scale` - // after the transition to avoid having the reset clobber our value. - ((void(*)(id, SEL, NSInteger))objc_msgSend)(pinch, setStateSel, targetState); + objc_setAssociatedObject(pinch, kCMPGRStateOverrideKey, @(targetState), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); pinch.scale = absoluteScale; } CMPForceRecognizerActions(recognizers, [UIPinchGestureRecognizer class]); @@ -331,6 +358,8 @@ + (void)dispatchScrollOnEvent:(UIEvent *)event delta:(CGVector)delta phase:(CMPScrollPhase)phase inWindow:(UIWindow *)window { + CMPInstallStateOverrideOnce(); + CMPSynSetLocation(event, anchor); CMPSynSetDelta(event, delta); CMPSynSetPhase(event, phase); @@ -338,13 +367,6 @@ + (void)dispatchScrollOnEvent:(UIEvent *)event NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; - [[UIApplication sharedApplication] sendEvent:event]; - - // UIKit's in-process scroll dispatch keeps every UIPanGestureRecognizer stuck - // at .began after each sendEvent — the translation accumulates correctly but - // the recognizer's `state` never advances past .began until the session ends. - // Force the state to match the scroll phase so target-actions observe the - // proper Began → Changed …→ Ended lifecycle. UIGestureRecognizerState targetState; switch (phase) { case CMPScrollPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; @@ -352,16 +374,18 @@ + (void)dispatchScrollOnEvent:(UIEvent *)event case CMPScrollPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; default: targetState = UIGestureRecognizerStatePossible; break; } - if (targetState != UIGestureRecognizerStatePossible) { - SEL setStateSel = NSSelectorFromString(@"setState:"); - for (UIGestureRecognizer *r in recognizers) { - if (![r isKindOfClass:[UIPanGestureRecognizer class]]) { continue; } - if (r.state != targetState) { - ((void(*)(id, SEL, NSInteger))objc_msgSend)(r, setStateSel, targetState); - } - } + + // Pin the synthetic state so target-actions observe the right phase even + // when UIKit's state cache disagrees (e.g. the first dispatch of a second + // session — the recognizer is still .ended from the previous one). + for (UIGestureRecognizer *r in recognizers) { + if (![r isKindOfClass:[UIPanGestureRecognizer class]]) { continue; } + objc_setAssociatedObject(r, kCMPGRStateOverrideKey, @(targetState), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); } + [[UIApplication sharedApplication] sendEvent:event]; + CMPForceRecognizerActions(recognizers, [UIPanGestureRecognizer class]); } @@ -370,6 +394,8 @@ + (void)dispatchTransformOnEvent:(UIEvent *)event scale:(CGFloat)scale phase:(CMPTransformPhase)phase inWindow:(UIWindow *)window { + CMPInstallStateOverrideOnce(); + CMPSynSetLocation(event, anchor); CMPSynSetScale(event, scale); CMPSynSetPhase(event, phase); @@ -425,7 +451,7 @@ + (nullable instancetype)scrollEventAtPoint:(CGPoint)point CMPSynSetWindow(scrollEvent, window); [UIEvent dispatchScrollOnEvent:scrollEvent atAnchor:point - delta:CGVectorMake(delta.x, delta.y) + delta:CGVectorMake(-delta.x, -delta.y) phase:CMPScrollPhaseBegan inWindow:window]; return (UIEvent *)scrollEvent; @@ -434,7 +460,7 @@ + (nullable instancetype)scrollEventAtPoint:(CGPoint)point - (void)scrollByDelta:(CGPoint)delta inWindow:(UIWindow *)window { [UIEvent dispatchScrollOnEvent:self atAnchor:CMPSynGetLocation(self) - delta:CGVectorMake(delta.x, delta.y) + delta:CGVectorMake(-delta.x, -delta.y) phase:CMPScrollPhaseChanged inWindow:window]; } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift index 85abb61c201af..c90e71b410ccf 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift @@ -1,101 +1,101 @@ /* -* Copyright 2026 The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import UIKit import XCTest final class CMPHoverTest: XCTestCase { - private var appDelegate: MockAppDelegate! - - override func setUpWithError() throws { - super.setUp() - appDelegate = MockAppDelegate() - UIApplication.shared.delegate = appDelegate - appDelegate.setUpClearWindow() - } - - override func tearDownWithError() throws { - super.tearDown() - appDelegate?.cleanUp() - appDelegate = nil - } - - private func pumpRunLoop(_ seconds: TimeInterval) { - let end = Date().addingTimeInterval(seconds) - while Date() < end { - RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) - RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) - } - } - - @MainActor - func testSimulatedHoverReachesHoverRecognizer() { - let window = appDelegate.window! - - let viewController = HoverRecordingViewController() - window.rootViewController = viewController - - let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) - - let hover = UIEvent.hover(at: start, in: window) - XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") - pumpRunLoop(0.1) - - var expectedPoints: [CGPoint] = [start] - - // Walk the cursor across the view to generate multiple hover-moved dispatches. - let stepCount = 5 - let step = CGPoint(x: 12, y: 8) - for i in 1...stepCount { - let target = CGPoint(x: start.x + CGFloat(i) * step.x, - y: start.y + CGFloat(i) * step.y) - hover?.hoverMove(to: target, in: window) - expectedPoints.append(target) - pumpRunLoop(0.1) - } - hover?.endHover(in: window) - // endHover replays the last anchor, so the recognizer observes it twice. - expectedPoints.append(expectedPoints.last!) - - let deadline = Date().addingTimeInterval(2.0) - while viewController.receivedLocations.isEmpty && Date() < deadline { - pumpRunLoop(0.05) - } - - // The view fills the window, so window-space == view-space here. - XCTAssertEqual( - viewController.receivedLocations, expectedPoints, - "UIHoverGestureRecognizer received hover events at unexpected points." - ) - } + private var appDelegate: MockAppDelegate! + + override func setUpWithError() throws { + super.setUp() + appDelegate = MockAppDelegate() + UIApplication.shared.delegate = appDelegate + appDelegate.setUpClearWindow() + } + + override func tearDownWithError() throws { + super.tearDown() + appDelegate?.cleanUp() + appDelegate = nil + } + + private func pumpRunLoop(_ seconds: TimeInterval) { + let end = Date().addingTimeInterval(seconds) + while Date() < end { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + RunLoop.main.run(mode: .tracking, before: Date().addingTimeInterval(0.01)) + } + } + + @MainActor + func testSimulatedHoverReachesHoverRecognizer() { + let window = appDelegate.window! + + let viewController = HoverRecordingViewController() + window.rootViewController = viewController + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + let hover = UIEvent.hover(at: start, in: window) + XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + var expectedPoints: [CGPoint] = [start] + + // Walk the cursor across the view to generate multiple hover-moved dispatches. + let stepCount = 5 + let step = CGPoint(x: 12, y: 8) + for i in 1...stepCount { + let target = CGPoint(x: start.x + CGFloat(i) * step.x, + y: start.y + CGFloat(i) * step.y) + hover?.hoverMove(to: target, in: window) + expectedPoints.append(target) + pumpRunLoop(0.1) + } + hover?.endHover(in: window) + // endHover replays the last anchor, so the recognizer observes it twice. + expectedPoints.append(expectedPoints.last!) + + let deadline = Date().addingTimeInterval(2.0) + while viewController.receivedLocations.isEmpty && Date() < deadline { + pumpRunLoop(0.05) + } + + // The view fills the window, so window-space == view-space here. + XCTAssertEqual( + viewController.receivedLocations, expectedPoints, + "UIHoverGestureRecognizer received hover events at unexpected points." + ) + } } private final class HoverRecordingViewController: UIViewController { - private let recognizer = UIHoverGestureRecognizer() - private(set) var receivedLocations: [CGPoint] = [] - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - recognizer.addTarget(self, action: #selector(handleHover(_:))) - view.addGestureRecognizer(recognizer) - } - - @objc - func handleHover(_ recognizer: UIHoverGestureRecognizer) { - receivedLocations.append(recognizer.location(in: view)) - } + private let recognizer = UIHoverGestureRecognizer() + private(set) var receivedLocations: [CGPoint] = [] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + recognizer.addTarget(self, action: #selector(handleHover(_:))) + view.addGestureRecognizer(recognizer) + } + + @objc + func handleHover(_ recognizer: UIHoverGestureRecognizer) { + receivedLocations.append(recognizer.location(in: view)) + } } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift index d00b01cee1f33..9302f4c7c89eb 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift @@ -17,7 +17,7 @@ import UIKit import XCTest -final class CMPPanTest: XCTestCase { +final class PanTest: XCTestCase { private var appDelegate: MockAppDelegate! override func setUpWithError() throws { @@ -34,9 +34,6 @@ final class CMPPanTest: XCTestCase { } private func pumpRunLoop(_ seconds: TimeInterval) { - // Run both default and tracking modes — UIKit gesture-action dispatch is - // observed to fire in one or the other depending on how the event entered - // the system (touch vs. synthetic UIScrollEvent). let end = Date().addingTimeInterval(seconds) while Date() < end { RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) @@ -52,9 +49,31 @@ final class CMPPanTest: XCTestCase { window.rootViewController = viewController let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) - let perStepDelta = CGPoint(x: 20, y: 30) - let stepCount = 5 + runScrollSession(label: "forward", + from: start, + perStepDelta: CGPoint(x: 20, y: 30), + stepCount: 5, + viewController: viewController, + window: window) + + viewController.reset() + + runScrollSession(label: "reverse", + from: start, + perStepDelta: CGPoint(x: -25, y: -15), + stepCount: 5, + viewController: viewController, + window: window) + } + + @MainActor + private func runScrollSession(label: String, + from start: CGPoint, + perStepDelta: CGPoint, + stepCount: Int, + viewController: PanRecordingViewController, + window: UIWindow) { let scroll = UIEvent.scroll(at: start, delta: perStepDelta, in: window) pumpRunLoop(0.1) @@ -69,41 +88,33 @@ final class CMPPanTest: XCTestCase { } if viewController.states.isEmpty { - XCTFail("UIPanGestureRecognizer received no state transitions from the simulated scroll stream.") + XCTFail("[\(label)] UIPanGestureRecognizer received no state transitions from the simulated scroll stream.") + return } - // The recognizer must observe the full scroll lifecycle — we want to see a - // Began state at least once, multiple Changed states (one per scroll step - // past the one that triggers Began), and a terminal Ended state. XCTAssertEqual(viewController.states.first, .began, - "Expected first recorded state to be .began, got \(describe(viewController.states.first))") + "[\(label)] Expected first recorded state to be .began, got \(describe(viewController.states.first))") XCTAssertEqual(viewController.states.last, .ended, - "Expected last recorded state to be .ended, got \(describe(viewController.states.last))") + "[\(label)] Expected last recorded state to be .ended, got \(describe(viewController.states.last))") let changedCount = viewController.states.filter { $0 == .changed }.count XCTAssertEqual(changedCount, stepCount, - "Expected exactly \(stepCount) .changed transitions, got \(changedCount); states=\(viewController.states.map(describe))") + "[\(label)] Expected exactly \(stepCount) .changed transitions, got \(changedCount); states=\(viewController.states.map(describe))") - // The healthy sequence is Began → Changed* → Ended with no interstitial - // failed/cancelled/possible transitions. let allowed: Set = [.began, .changed, .ended] for state in viewController.states { XCTAssertTrue(allowed.contains(state), - "Unexpected state \(describe(state)) in sequence \(viewController.states.map(describe))") + "[\(label)] Unexpected state \(describe(state)) in sequence \(viewController.states.map(describe))") } - // Translation accumulates by perStepDelta on each `scrollByDelta` - // dispatch — the initial `scrollEventAt` delta opens the session but is - // not applied, so total = stepCount × perStepDelta. let last = viewController.translations.last ?? .zero - let expected = CGPoint( - x: CGFloat(stepCount) * perStepDelta.x, - y: CGFloat(stepCount) * perStepDelta.y - ) + let expected = CGPoint(x: CGFloat(stepCount) * perStepDelta.x, + y: CGFloat(stepCount) * perStepDelta.y) + XCTAssertEqual(last.x, expected.x, accuracy: 0.5, - "Unexpected accumulated x translation; got \(last.x), translations=\(viewController.translations)") + "[\(label)] Unexpected accumulated x translation; got \(last.x), translations=\(viewController.translations)") XCTAssertEqual(last.y, expected.y, accuracy: 0.5, - "Unexpected accumulated y translation; got \(last.y), translations=\(viewController.translations)") + "[\(label)] Unexpected accumulated y translation; got \(last.y), translations=\(viewController.translations)") } @MainActor @@ -118,11 +129,7 @@ final class CMPPanTest: XCTestCase { "Scroll view should start at top before the simulated gesture.") let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) - // UIScrollView treats scroll-delta the same way it treats pan translation: - // a positive Y delta drags content down (overscroll past the top, yielding - // a negative content offset). To scroll DOWN through content — advancing - // `contentOffset.y` — we feed a negative Y delta. - let perStepDelta = CGPoint(x: 0, y: -40) + let perStepDelta = CGPoint(x: 0, y: 40) let stepCount = 6 let scroll = UIEvent.scroll(at: start, delta: perStepDelta, in: window) @@ -179,6 +186,7 @@ private final class VerticalScrollHostViewController: UIViewController { private final class PanRecordingViewController: UIViewController { private(set) var states: [UIGestureRecognizer.State] = [] private(set) var translations: [CGPoint] = [] + private var initialLocation = CGPoint.zero override func viewDidLoad() { super.viewDidLoad() @@ -189,9 +197,21 @@ private final class PanRecordingViewController: UIViewController { view.addGestureRecognizer(pan) } + func reset() { + states.removeAll() + translations.removeAll() + initialLocation = .zero + } + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { + if recognizer.state == .began { + initialLocation = recognizer.location(in: view) + } states.append(recognizer.state) - translations.append(recognizer.translation(in: view)) + let currentLocation = recognizer.location(in: view) + let delta = CGPoint(x: initialLocation.x - currentLocation.x, + y: initialLocation.y - currentLocation.y) + translations.append(delta) } } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift index fecf1db54c6e9..b7d09db7a587e 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift @@ -17,7 +17,7 @@ import UIKit import XCTest -final class CMPPinchTest: XCTestCase { +final class PinchTest: XCTestCase { private var appDelegate: MockAppDelegate! override func setUpWithError() throws { @@ -112,6 +112,97 @@ final class CMPPinchTest: XCTestCase { "UIPinchGestureRecognizer received no events from the simulated pinch stream." ) } + + @MainActor + func testSimulatedPinchInThenPinchOut() { + let window = appDelegate.window! + + let viewController = PinchRecordingViewController() + window.rootViewController = viewController + + let anchor = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + runPinchSession(label: "pinch-in", + anchor: anchor, + finalScale: 0.5, + stepCount: 4, + viewController: viewController, + window: window) + + // After the first gesture ends, the recognizer is in .ended. The + // dispatch layer must reset it to .possible so a second session can + // run as a fresh Began → Changed* → Ended cycle. + viewController.reset() + + runPinchSession(label: "pinch-out", + anchor: anchor, + finalScale: 2.0, + stepCount: 4, + viewController: viewController, + window: window) + } + + @MainActor + private func runPinchSession(label: String, + anchor: CGPoint, + finalScale: CGFloat, + stepCount: Int, + viewController: PinchRecordingViewController, + window: UIWindow) { + let pinch = UIEvent.pinch(at: anchor, scale: 1.0, in: window) + XCTAssertNotNil(pinch, "[\(label)] UITransformEvent is unavailable.") + pumpRunLoop(0.1) + + for i in 1...stepCount { + let scale = 1.0 + (finalScale - 1.0) * CGFloat(i) / CGFloat(stepCount) + pinch?.pinch(byScale: scale, in: window) + pumpRunLoop(0.1) + } + pinch?.endPinch(in: window) + + let deadline = Date().addingTimeInterval(2.0) + while viewController.events.isEmpty && Date() < deadline { + pumpRunLoop(0.05) + } + + let states = viewController.events.map { $0.state } + if states.isEmpty { + XCTFail("[\(label)] UIPinchGestureRecognizer received no events.") + return + } + + XCTAssertEqual(states.first, .began, + "[\(label)] Expected first state .began, got \(describe(states.first))") + XCTAssertEqual(states.last, .ended, + "[\(label)] Expected last state .ended, got \(describe(states.last))") + + let changedCount = states.filter { $0 == .changed }.count + XCTAssertEqual(changedCount, stepCount, + "[\(label)] Expected exactly \(stepCount) .changed transitions, got \(changedCount); states=\(states.map(describe))") + + let allowed: Set = [.began, .changed, .ended] + for state in states { + XCTAssertTrue(allowed.contains(state), + "[\(label)] Unexpected state \(describe(state)) in sequence \(states.map(describe))") + } + + let lastScale = viewController.events.last?.scale ?? 0 + XCTAssertEqual(lastScale, finalScale, accuracy: 0.01, + "[\(label)] Expected final scale ≈ \(finalScale), got \(lastScale)") + } +} + +private func describe(_ state: UIGestureRecognizer.State?) -> String { + guard let state = state else { return "nil" } + switch state { + case .possible: return "possible" + case .began: return "began" + case .changed: return "changed" + case .ended: return "ended" + case .cancelled: return "cancelled" + case .failed: return "failed" + @unknown default: return "unknown(\(state.rawValue))" + } } private final class ZoomableScrollHostViewController: UIViewController, UIScrollViewDelegate { @@ -160,6 +251,10 @@ private final class PinchRecordingViewController: UIViewController { view.addGestureRecognizer(recognizer) } + func reset() { + events.removeAll() + } + @objc func handlePinch(_ recognizer: UIPinchGestureRecognizer) { events.append((state: recognizer.state, scale: recognizer.scale)) From 30d9808ddf5a59acda48dc57b6c0b80e45f506d0 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 27 Apr 2026 12:06:53 +0200 Subject: [PATCH 6/8] Fix hover tests --- .../androidx/compose/ui/Configuration.kt | 2 - .../ui/interaction/TrackpadHoverTest.kt | 199 ++++++++++++++++++ .../ui/interaction/TrackpadPinchTest.kt | 152 +++++++++++++ .../compose/ui/test/UIKitInstrumentedTest.kt | 32 +++ .../compose/ui/test/utils/UIEvent+Utils.kt | 72 +++++++ .../iosMain/objc/CMPTestUtils/UIEvent+Test.m | 144 ++++++++++++- .../objc/CMPTestUtilsAppTests/HoverTest.swift | 107 ++++++++-- 7 files changed, 679 insertions(+), 29 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadHoverTest.kt create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPinchTest.kt diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt index b02196ecf292b..961cff994452e 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/Configuration.kt @@ -16,7 +16,6 @@ package androidx.compose.ui -import androidx.compose.ui.interaction.TrackpadPanTest import androidx.compose.xctest.setupXCTestSuite import kotlinx.cinterop.ExperimentalForeignApi import platform.XCTest.XCTestSuite @@ -24,7 +23,6 @@ import platform.XCTest.XCTestSuite @Suppress("unused") @OptIn(ExperimentalForeignApi::class) fun testSuite(): XCTestSuite = setupXCTestSuite( - TrackpadPanTest::class // Run all test cases from the tests // BasicInteractionTest::class, // LayersAccessibilityTest::class, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadHoverTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadHoverTest.kt new file mode 100644 index 0000000000000..ace0477a0bf44 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadHoverTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction + +import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.endHover +import androidx.compose.ui.test.utils.hoverEventAt +import androidx.compose.ui.test.utils.hoverTo +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class TrackpadHoverTest { + + @Test + fun testBoxReceivesHoverEnterMoveExitForMultipleGestures() = runUIKitInstrumentedTest { + var enterCount = 0 + var moveCount = 0 + var exitCount = 0 + + setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + when (event.type) { + PointerEventType.Enter -> enterCount++ + PointerEventType.Move -> moveCount++ + PointerEventType.Exit -> exitCount++ + } + } + } + } + ) + } + + val window = appDelegate.window()!! + val center = screenSize.center + + run { + val hover = window.hoverEventAt(center) + hover.hoverTo(DpOffset(center.x + 30.dp, center.y), window) + hover.hoverTo(DpOffset(center.x + 60.dp, center.y + 20.dp), window) + hover.endHover(window) + } + waitForIdle() + + run { + val hover = window.hoverEventAt(DpOffset(center.x - 50.dp, center.y - 50.dp)) + hover.hoverTo(DpOffset(center.x - 20.dp, center.y - 20.dp), window) + hover.endHover(window) + } + waitForIdle() + + assertEquals(2, enterCount, "Expected 2 Enter events for 2 gestures, got $enterCount") + assertTrue(moveCount >= 3, "Expected at least 3 Move events, got $moveCount") + assertEquals(2, exitCount, "Expected 2 Exit events for 2 gestures, got $exitCount") + } + + @Test + fun testButtonHover_outside_in_out() = runUIKitInstrumentedTest { + val (buttonCenter, isHovered) = setUpHoverableBoxAtScreenCenter() + val window = appDelegate.window()!! + val outside = outsideButton(buttonCenter) + + val hover = window.hoverEventAt(outside) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after begin outside") + + hover.hoverTo(buttonCenter, window) + waitForIdle() + assertTrue(isHovered(), "Button should be hovered after move over it") + + hover.hoverTo(outside, window) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after move out") + + hover.endHover(window) + } + + @Test + fun testButtonHover_outside_in_end() = runUIKitInstrumentedTest { + val (buttonCenter, isHovered) = setUpHoverableBoxAtScreenCenter() + val window = appDelegate.window()!! + val outside = outsideButton(buttonCenter) + + val hover = window.hoverEventAt(outside) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after begin outside") + + hover.hoverTo(buttonCenter, window) + waitForIdle() + assertTrue(isHovered(), "Button should be hovered after move over it") + + hover.endHover(window) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after end hover") + } + + @Test + fun testButtonHover_overButton_movesOut() = runUIKitInstrumentedTest { + val (buttonCenter, isHovered) = setUpHoverableBoxAtScreenCenter() + val window = appDelegate.window()!! + val outside = outsideButton(buttonCenter) + + val hover = window.hoverEventAt(buttonCenter) + waitForIdle() + assertTrue(isHovered(), "Button should be hovered after begin over it") + + hover.hoverTo(outside, window) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after move out") + + hover.endHover(window) + } + + @Test + fun testButtonHover_overButton_endHover() = runUIKitInstrumentedTest { + val (buttonCenter, isHovered) = setUpHoverableBoxAtScreenCenter() + val window = appDelegate.window()!! + + val hover = window.hoverEventAt(buttonCenter) + waitForIdle() + assertTrue(isHovered(), "Button should be hovered after begin over it") + + hover.endHover(window) + waitForIdle() + assertFalse(isHovered(), "Button should not be hovered after end hover") + } +} + +private val ButtonSize = DpSize(120.dp, 60.dp) + +private fun outsideButton(buttonCenter: DpOffset): DpOffset = + DpOffset(buttonCenter.x + ButtonSize.width, buttonCenter.y) + +private fun UIKitInstrumentedTest.setUpHoverableBoxAtScreenCenter(): Pair Boolean> { + val boxCenter = screenSize.center + val topLeft = DpOffset( + boxCenter.x - ButtonSize.width / 2, + boxCenter.y - ButtonSize.height / 2, + ) + var hovered = false + setContent { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + LaunchedEffect(isHovered) { hovered = isHovered } + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .offset(topLeft.x, topLeft.y) + .size(ButtonSize) + .background(Color.Cyan) + .hoverable(interactionSource) + ) + } + } + return boxCenter to { hovered } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPinchTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPinchTest.kt new file mode 100644 index 0000000000000..7d46f4c887899 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPinchTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toDpOffset +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class TrackpadPinchTest { + + @Test + fun testTransformableReceivesTrackpadPinchIn() = runUIKitInstrumentedTest { + var totalZoom = 1f + var transformEventCount = 0 + var lastCentroid: Offset = Offset.Unspecified + + setContent { + val state = rememberTransformableState { centroid, zoomChange, _, _ -> + totalZoom *= zoomChange + transformEventCount++ + lastCentroid = centroid + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .transformable(state) + ) + } + + val anchor = screenSize.center + trackpadPinch(anchor, finalScale = 2f) + waitForIdle() + + assertTrue( + transformEventCount >= 1, + "Expected at least one transformation event, received $transformEventCount" + ) + assertEquals(2f, totalZoom, absoluteTolerance = 0.05f) + assertCentroidMatchesAnchor(lastCentroid, anchor) + } + + @Test + fun testTransformableReceivesTrackpadPinchOut() = runUIKitInstrumentedTest { + var totalZoom = 1f + var transformEventCount = 0 + var lastCentroid: Offset = Offset.Unspecified + + setContent { + val state = rememberTransformableState { centroid, zoomChange, _, _ -> + totalZoom *= zoomChange + transformEventCount++ + lastCentroid = centroid + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .transformable(state) + ) + } + + val anchor = screenSize.center + trackpadPinch(anchor, finalScale = 0.5f) + waitForIdle() + + assertTrue( + transformEventCount >= 1, + "Expected at least one transformation event, received $transformEventCount" + ) + assertEquals(0.5f, totalZoom, absoluteTolerance = 0.05f) + assertCentroidMatchesAnchor(lastCentroid, anchor) + } + + @Test + fun testTransformableReceivesMultipleTrackpadPinches() = runUIKitInstrumentedTest { + var totalZoom = 1f + var transformEventCount = 0 + var lastCentroid: Offset = Offset.Unspecified + + setContent { + val state = rememberTransformableState { centroid, zoomChange, _, _ -> + totalZoom *= zoomChange + transformEventCount++ + lastCentroid = centroid + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.LightGray) + .transformable(state) + ) + } + + val anchor = screenSize.center + val finalScales = listOf(2f, 0.5f, 1.5f) + for (finalScale in finalScales) { + trackpadPinch(anchor, finalScale) + } + waitForIdle() + + val expectedTotal = finalScales.fold(1f) { acc, s -> acc * s } + assertTrue( + transformEventCount >= finalScales.size, + "Expected at least ${finalScales.size} transformation events, received $transformEventCount" + ) + assertTrue( + abs(totalZoom - expectedTotal) < 0.05f, + "Expected accumulated zoom ≈ $expectedTotal, got $totalZoom" + ) + assertCentroidMatchesAnchor(lastCentroid, anchor) + } +} + +private fun UIKitInstrumentedTest.assertCentroidMatchesAnchor( + centroid: Offset, + anchor: DpOffset, +) { + assertTrue(centroid.isSpecified, "Transform centroid was never reported") + val centroidDp = centroid.toDpOffset(density) + assertEquals(anchor.x.value, centroidDp.x.value, absoluteTolerance = 1f) + assertEquals(anchor.y.value, centroidDp.y.value, absoluteTolerance = 1f) +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 149ace0ec9895..29401565f0422 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -23,10 +23,13 @@ import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.scene.ComposeLayersViewController import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.endPinch import androidx.compose.ui.test.utils.endScroll import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow +import androidx.compose.ui.test.utils.pinchBy +import androidx.compose.ui.test.utils.pinchEventAt import androidx.compose.ui.test.utils.resetTouches import androidx.compose.ui.test.utils.scrollBy import androidx.compose.ui.test.utils.scrollEventAt @@ -509,6 +512,35 @@ internal class UIKitInstrumentedTest( delay(stepInterval.inWholeMilliseconds) scrollEvent.endScroll(targetWindow) } + + /** + * Simulates a trackpad continuous pinch gesture as a stateful transform session anchored + * at [position]. The gesture ramps the absolute scale linearly from `1.0` to [finalScale] + * over [duration], emitting one `phase-Began`, multiple `phase-Changed`, and a final + * `phase-Ended` event. All transform synthesis goes through the `UIEvent (CMPPinch)` + * category in `UIEvent+Test.m`, which drives each [platform.UIKit.UIPinchGestureRecognizer] + * directly so target-actions observe the proper `Began → Changed …→ Ended` lifecycle. + */ + fun trackpadPinch( + position: DpOffset, + finalScale: Float, + duration: Duration = 0.5.seconds, + ) { + val stepInterval = 16.milliseconds + val steps = maxOf(1, (duration / stepInterval).toInt()) + val targetWindow = appDelegate.window()!! + val pinchEvent = targetWindow.pinchEventAt(location = position, scale = 1.0) + + repeat(steps) { i -> + delay(stepInterval.inWholeMilliseconds) + val progress = (i + 1).toFloat() / steps.toFloat() + val scale = 1.0 + (finalScale - 1.0) * progress + pinchEvent.pinchBy(scale.toDouble(), targetWindow) + } + + delay(stepInterval.inWholeMilliseconds) + pinchEvent.endPinch(targetWindow) + } } @OptIn(ExperimentalForeignApi::class) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt index 815bc77c0baf9..85e6a33aae74c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt @@ -16,7 +16,13 @@ package androidx.compose.ui.test.utils +import androidx.compose.test.utils.endHoverInWindow import androidx.compose.test.utils.endInWindow +import androidx.compose.test.utils.endPinchInWindow +import androidx.compose.test.utils.hoverEventAtPoint +import androidx.compose.test.utils.hoverMoveToPoint +import androidx.compose.test.utils.pinchByScale +import androidx.compose.test.utils.pinchEventAtPoint import androidx.compose.test.utils.scrollByDelta import androidx.compose.test.utils.scrollEventAtPoint import androidx.compose.ui.unit.DpOffset @@ -63,3 +69,69 @@ internal fun UIEvent.scrollBy(delta: DpOffset, window: UIWindow) { internal fun UIEvent.endScroll(window: UIWindow) { endInWindow(window) } + +/** + * Begins a synthetic trackpad pinch session anchored at [location] in this window with the + * initial absolute [scale] (typically `1.0`, phase Began). Use [UIEvent.pinchBy] to emit + * follow-up `Changed` events with new absolute scales and [UIEvent.endPinch] to close the + * session. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.pinchEventAt(location: DpOffset, scale: Double = 1.0): UIEvent { + return UIEvent.pinchEventAtPoint( + point = location.toCGPoint(), + scale = scale, + inWindow = this, + ) ?: error("UITransformEvent unavailable on this runtime") +} + +/** + * Emits one `phase-Changed` pinch event on this pinch session with the new absolute + * [scale]. Must only be called on a [UIEvent] returned by [UIWindow.pinchEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.pinchBy(scale: Double, window: UIWindow) { + pinchByScale(scale, inWindow = window) +} + +/** + * Emits the closing `phase-Ended` pinch event on this pinch session. Must only be called + * on a [UIEvent] returned by [UIWindow.pinchEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.endPinch(window: UIWindow) { + endPinchInWindow(window) +} + +/** + * Opens a synthetic hover session anchored at [location] in this window (phase Began). + * Use [UIEvent.hoverTo] to emit follow-up `Changed` events at new locations and + * [UIEvent.endHover] to close the session. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.hoverEventAt( + location: DpOffset, +): UIEvent { + return UIEvent.hoverEventAtPoint( + point = location.toCGPoint(), + inWindow = this, + ) ?: error("UIHoverEvent unavailable on this runtime") +} + +/** + * Emits one `Changed` hover event at [location] on this hover session. Must only be + * called on a [UIEvent] returned by [UIWindow.hoverEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.hoverTo(location: DpOffset, window: UIWindow) { + hoverMoveToPoint(location.toCGPoint(), inWindow = window) +} + +/** + * Emits the closing `Ended` hover event on this hover session. Must only be called on + * a [UIEvent] returned by [UIWindow.hoverEventAt]. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIEvent.endHover(window: UIWindow) { + endHoverInWindow(window) +} diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m index eb43cca5205c5..9edcf54543b2a 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -36,6 +36,12 @@ typedef NS_ENUM(NSInteger, CMPTransformPhase) { CMPTransformPhaseCancelled = 4, }; +typedef NS_ENUM(NSInteger, CMPHoverPhase) { + CMPHoverPhaseBegan = 1, + CMPHoverPhaseChanged = 2, + CMPHoverPhaseEnded = 3, +}; + #pragma mark - Synthetic event state (associated objects) static const void *kCMPSynPhaseKey = &kCMPSynPhaseKey; @@ -234,10 +240,10 @@ static Class CMPSyntheticTransformEventClass(void) { return cls; } -// Hover dispatch drives the recognizer via `shouldReceiveEvent:` directly and -// skips `-[UIApplication sendEvent:]`, so the synthetic subclass needs no -// overrides — it exists only to carry associated-object state on an instance -// that passes `isKindOfClass:[UIHoverEvent class]` checks. +// Hover dispatch drives the recognizer directly (state + location pins, then +// force-fired target-actions) and never routes through `-[UIApplication +// sendEvent:]`, so the synthetic subclass needs no overrides — it just +// provides a UIEvent-typed instance that can hold associated-object state. static Class CMPSyntheticHoverEventClass(void) { static Class cls; static dispatch_once_t once; @@ -315,6 +321,95 @@ static void CMPInstallStateOverrideOnce(void) { if (m == NULL) { return; } gOriginalGRStateImp = method_getImplementation(m); method_setImplementation(m, (IMP)CMPSwizzledState); + // UIHoverGestureRecognizer (and possibly other subclasses) have their + // own `-state` IMP that bypasses the base class swizzle, so install the + // override directly on the subclass too. We use the same swizzle + // function — when its associated-object check finds nothing it falls + // back to `gOriginalGRStateImp`, which is the base-class IMP. That's a + // fine approximation: if a subclass overrides `state`, its override + // typically just reads the same underlying storage. + Class hoverCls = NSClassFromString(@"UIHoverGestureRecognizer"); + if (hoverCls != Nil) { + Method hm = class_getInstanceMethod(hoverCls, @selector(state)); + const char *typeEncoding = hm ? method_getTypeEncoding(hm) : + method_getTypeEncoding(m); + BOOL added = class_addMethod(hoverCls, @selector(state), + (IMP)CMPSwizzledState, typeEncoding); + if (!added && hm != NULL) { + method_setImplementation(hm, (IMP)CMPSwizzledState); + } + } + }); +} + +#pragma mark - Location override (UIGestureRecognizer) + +// Synthetic hover/pinch dispatch skips `-[UIApplication sendEvent:]`, so UIKit +// never populates the recognizer's internal `_locationInWindow`. Without this, +// `recognizer.location(in: view)` from the target-action handler reports stale +// or zero data. Pin a per-recognizer window-coordinate point via an associated +// object and swizzle the recognizer class's `locationInView:` to convert that +// point into the requested view's coordinate space. + +static const void *kCMPGRLocationOverrideKey = &kCMPGRLocationOverrideKey; +static IMP gOriginalHoverLocationInViewImp = NULL; +static IMP gOriginalPinchLocationInViewImp = NULL; + +static CGPoint CMPSwizzledLocationInViewImpl(id self, SEL _cmd, UIView *view, IMP original) { + NSValue *override = objc_getAssociatedObject(self, kCMPGRLocationOverrideKey); + if (override != nil) { + CGPoint windowPoint = CGPointZero; + [override getValue:&windowPoint size:sizeof(CGPoint)]; + UIView *recognizerView = [(UIGestureRecognizer *)self view]; + UIWindow *window = recognizerView.window; + if (view == nil || window == nil || view == window) { return windowPoint; } + return [view convertPoint:windowPoint fromView:window]; + } + if (original != NULL) { + return ((CGPoint(*)(id, SEL, UIView *))original)(self, _cmd, view); + } + return CGPointZero; +} + +static CGPoint CMPSwizzledHoverLocationInView(id self, SEL _cmd, UIView *view) { + return CMPSwizzledLocationInViewImpl(self, _cmd, view, gOriginalHoverLocationInViewImp); +} + +static CGPoint CMPSwizzledPinchLocationInView(id self, SEL _cmd, UIView *view) { + return CMPSwizzledLocationInViewImpl(self, _cmd, view, gOriginalPinchLocationInViewImp); +} + +static void CMPInstallLocationOverrideOnClass(Class cls, IMP swizzled, IMP *outOriginal) { + if (cls == Nil) { return; } + SEL sel = @selector(locationInView:); + Method m = class_getInstanceMethod(cls, sel); + if (m == NULL) { return; } + *outOriginal = method_getImplementation(m); + const char *typeEncoding = method_getTypeEncoding(m); + // If `locationInView:` is inherited from UIGestureRecognizer, add a direct + // override on the subclass instead of swizzling the base class — keeps + // blast radius limited to recognizers we synthesize. + BOOL added = class_addMethod(cls, sel, swizzled, typeEncoding); + if (!added) { + method_setImplementation(m, swizzled); + } +} + +static void CMPInstallHoverLocationOverrideOnce(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + CMPInstallLocationOverrideOnClass(NSClassFromString(@"UIHoverGestureRecognizer"), + (IMP)CMPSwizzledHoverLocationInView, + &gOriginalHoverLocationInViewImp); + }); +} + +static void CMPInstallPinchLocationOverrideOnce(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + CMPInstallLocationOverrideOnClass([UIPinchGestureRecognizer class], + (IMP)CMPSwizzledPinchLocationInView, + &gOriginalPinchLocationInViewImp); }); } @@ -330,6 +425,7 @@ static void CMPInstallStateOverrideOnce(void) { // pinch session after the first ends. static void CMPDrivePinchRecognizers(NSSet *recognizers, CGFloat absoluteScale, + CGPoint anchorInWindow, CMPTransformPhase phase) { UIGestureRecognizerState targetState; switch (phase) { @@ -339,11 +435,17 @@ static void CMPDrivePinchRecognizers(NSSet *recognizers, case CMPTransformPhaseCancelled: targetState = UIGestureRecognizerStateCancelled; break; default: return; } + NSValue *anchorBox = [NSValue valueWithBytes:&anchorInWindow objCType:@encode(CGPoint)]; for (UIGestureRecognizer *recognizer in recognizers) { if (![recognizer isKindOfClass:[UIPinchGestureRecognizer class]]) { continue; } UIPinchGestureRecognizer *pinch = (UIPinchGestureRecognizer *)recognizer; objc_setAssociatedObject(pinch, kCMPGRStateOverrideKey, @(targetState), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + // Pin the centroid (in window coords) so `recognizer.location(in: view)` + // reports the synthetic anchor — UIKit doesn't populate it for us when + // we skip `sendEvent:`. + objc_setAssociatedObject(pinch, kCMPGRLocationOverrideKey, anchorBox, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); pinch.scale = absoluteScale; } CMPForceRecognizerActions(recognizers, [UIPinchGestureRecognizer class]); @@ -395,6 +497,7 @@ + (void)dispatchTransformOnEvent:(UIEvent *)event phase:(CMPTransformPhase)phase inWindow:(UIWindow *)window { CMPInstallStateOverrideOnce(); + CMPInstallPinchLocationOverrideOnce(); CMPSynSetLocation(event, anchor); CMPSynSetScale(event, scale); @@ -408,25 +511,43 @@ + (void)dispatchTransformOnEvent:(UIEvent *)event // as object pointers and retains them unconditionally, crashing on iOS 26. // We drive the pinch recognizer directly below, so UIKit's routing is // unnecessary anyway. - CMPDrivePinchRecognizers(recognizers, scale, phase); + CMPDrivePinchRecognizers(recognizers, scale, anchor, phase); } + (void)dispatchHoverOnEvent:(UIEvent *)event atAnchor:(CGPoint)anchor + phase:(CMPHoverPhase)phase inWindow:(UIWindow *)window { + CMPInstallStateOverrideOnce(); + CMPInstallHoverLocationOverrideOnce(); + CMPSynSetLocation(event, anchor); if (window != nil) { CMPSynSetWindow(event, window); } - NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + UIGestureRecognizerState targetState; + switch (phase) { + case CMPHoverPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; + case CMPHoverPhaseChanged: targetState = UIGestureRecognizerStateChanged; break; + case CMPHoverPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; + } - // Skip `-[UIApplication sendEvent:]` entirely — UIKit's hover path - // dereferences private touch-bookkeeping ivars we can't safely populate. - // `shouldReceiveEvent:` only needs the event, so invoke it directly as - // the delivery entry point. + // Skip `-[UIApplication sendEvent:]` — UIKit's hover path dereferences + // private touch-bookkeeping ivars we can't safely populate. Pin synthetic + // state + location on each candidate recognizer, then force-fire its + // target-actions. We also forward the event through `shouldReceiveEvent:` + // so subclasses that override it (e.g. CMPHoverGestureRecognizer caching + // `lastReceivedEvent`) observe the synthetic event. + NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + NSNumber *boxedState = @(targetState); + NSValue *boxedAnchor = [NSValue valueWithBytes:&anchor objCType:@encode(CGPoint)]; SEL shouldReceiveEventSel = @selector(shouldReceiveEvent:); for (UIGestureRecognizer *recognizer in recognizers) { if (![recognizer isKindOfClass:[UIHoverGestureRecognizer class]]) { continue; } ((BOOL(*)(id, SEL, id))objc_msgSend)(recognizer, shouldReceiveEventSel, event); + objc_setAssociatedObject(recognizer, kCMPGRStateOverrideKey, boxedState, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(recognizer, kCMPGRLocationOverrideKey, boxedAnchor, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); } CMPForceRecognizerActions(recognizers, [UIHoverGestureRecognizer class]); @@ -524,6 +645,7 @@ + (nullable instancetype)hoverEventAtPoint:(CGPoint)point CMPSynSetWindow(hoverEvent, window); [UIEvent dispatchHoverOnEvent:hoverEvent atAnchor:point + phase:CMPHoverPhaseBegan inWindow:window]; return (UIEvent *)hoverEvent; } @@ -531,12 +653,14 @@ + (nullable instancetype)hoverEventAtPoint:(CGPoint)point - (void)hoverMoveToPoint:(CGPoint)point inWindow:(UIWindow *)window { [UIEvent dispatchHoverOnEvent:self atAnchor:point + phase:CMPHoverPhaseChanged inWindow:window]; } - (void)endHoverInWindow:(UIWindow *)window { [UIEvent dispatchHoverOnEvent:self atAnchor:CMPSynGetLocation(self) + phase:CMPHoverPhaseEnded inWindow:window]; } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift index c90e71b410ccf..d6712e1bcb81a 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift @@ -50,42 +50,111 @@ final class CMPHoverTest: XCTestCase { let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + runHoverSession(label: "single", + from: start, + step: CGPoint(x: 12, y: 8), + stepCount: 5, + viewController: viewController, + window: window) + } + + @MainActor + func testSimulatedSeveralHoverSessions() { + let window = appDelegate.window! + + let viewController = HoverRecordingViewController() + window.rootViewController = viewController + + let center = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + runHoverSession(label: "first", + from: center, + step: CGPoint(x: 12, y: 8), + stepCount: 4, + viewController: viewController, + window: window) + + viewController.reset() + + runHoverSession(label: "second", + from: CGPoint(x: center.x - 30, y: center.y + 20), + step: CGPoint(x: -10, y: 15), + stepCount: 3, + viewController: viewController, + window: window) + } + + @MainActor + private func runHoverSession(label: String, + from start: CGPoint, + step: CGPoint, + stepCount: Int, + viewController: HoverRecordingViewController, + window: UIWindow) { let hover = UIEvent.hover(at: start, in: window) - XCTAssertNotNil(hover, "UIHoverEvent is unavailable; synthetic class allocation failed.") + XCTAssertNotNil(hover, "[\(label)] UIHoverEvent is unavailable; synthetic class allocation failed.") pumpRunLoop(0.1) - var expectedPoints: [CGPoint] = [start] - - // Walk the cursor across the view to generate multiple hover-moved dispatches. - let stepCount = 5 - let step = CGPoint(x: 12, y: 8) + var walked: [CGPoint] = [] for i in 1...stepCount { let target = CGPoint(x: start.x + CGFloat(i) * step.x, y: start.y + CGFloat(i) * step.y) hover?.hoverMove(to: target, in: window) - expectedPoints.append(target) + walked.append(target) pumpRunLoop(0.1) } hover?.endHover(in: window) - // endHover replays the last anchor, so the recognizer observes it twice. - expectedPoints.append(expectedPoints.last!) let deadline = Date().addingTimeInterval(2.0) - while viewController.receivedLocations.isEmpty && Date() < deadline { + while viewController.events.isEmpty && Date() < deadline { pumpRunLoop(0.05) } - // The view fills the window, so window-space == view-space here. - XCTAssertEqual( - viewController.receivedLocations, expectedPoints, - "UIHoverGestureRecognizer received hover events at unexpected points." - ) + let events = viewController.events + if events.isEmpty { + XCTFail("[\(label)] UIHoverGestureRecognizer received no events.") + return + } + + let states = events.map { $0.state } + XCTAssertEqual(states.first, .began, + "[\(label)] Expected first state .began, got \(describe(states.first))") + XCTAssertEqual(states.last, .ended, + "[\(label)] Expected last state .ended, got \(describe(states.last))") + + let changedCount = states.filter { $0 == .changed }.count + XCTAssertEqual(changedCount, stepCount, + "[\(label)] Expected exactly \(stepCount) .changed transitions, got \(changedCount); states=\(states.map(describe))") + + let allowed: Set = [.began, .changed, .ended] + for state in states { + XCTAssertTrue(allowed.contains(state), + "[\(label)] Unexpected state \(describe(state)) in sequence \(states.map(describe))") + } + + let expectedLocations: [CGPoint] = [start] + walked + [walked.last ?? start] + let recordedLocations = events.map { $0.location } + XCTAssertEqual(recordedLocations, expectedLocations, + "[\(label)] UIHoverGestureRecognizer received hover events at unexpected points.") + } +} + +private func describe(_ state: UIGestureRecognizer.State?) -> String { + guard let state = state else { return "nil" } + switch state { + case .possible: return "possible" + case .began: return "began" + case .changed: return "changed" + case .ended: return "ended" + case .cancelled: return "cancelled" + case .failed: return "failed" + @unknown default: return "unknown(\(state.rawValue))" } } private final class HoverRecordingViewController: UIViewController { private let recognizer = UIHoverGestureRecognizer() - private(set) var receivedLocations: [CGPoint] = [] + private(set) var events: [(state: UIGestureRecognizer.State, location: CGPoint)] = [] override func viewDidLoad() { super.viewDidLoad() @@ -94,8 +163,12 @@ private final class HoverRecordingViewController: UIViewController { view.addGestureRecognizer(recognizer) } + func reset() { + events.removeAll() + } + @objc func handleHover(_ recognizer: UIHoverGestureRecognizer) { - receivedLocations.append(recognizer.location(in: view)) + events.append((state: recognizer.state, location: recognizer.location(in: view))) } } From 68600222c194a02b40f91ece4c34ccb1c08dddfc Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 4 May 2026 10:09:05 +0200 Subject: [PATCH 7/8] Fix tests --- .../androidx/compose/foundation/gestures/UikitScrollable.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt index 2da2650a0522e..398e1ab08dcd7 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.ios.kt @@ -36,7 +36,7 @@ internal object UiKitScrollConfig : ScrollConfig { ) { acc + c.panOffset } else { - acc + c.scrollDelta + acc + c.scrollDelta * -64.dp.toPx() } } } From 2bcc02018c68f1d00a6788fd9c0b4f3f72a76626 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 12 May 2026 11:51:19 +0200 Subject: [PATCH 8/8] Fix input view --- .../iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index e596e1f3030f3..d1277990c7e71 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -520,7 +520,7 @@ private class PinchGestureRecognizer( @OptIn(BetaInteropApi::class) @ObjCAction fun onPinch(gestureRecognizer: UIPinchGestureRecognizer) { - val position = gestureRecognizer.locationInView(view).asDpOffset() + val position = gestureRecognizer.locationInView(view).toDpOffset() when (gestureRecognizer.state) { UIGestureRecognizerStateBegan -> {