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..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 @@ -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 + acc + c.scrollDelta * -64.dp.toPx() } - } * -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 a956c6fe3dfa8..d47bc858a14ad 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() @@ -820,7 +872,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 fd681691a8057..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 @@ -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.toDpOffset @@ -57,6 +58,7 @@ import platform.UIKit.UIGestureRecognizerStateEnded import platform.UIKit.UIGestureRecognizerStateFailed import platform.UIKit.UIGestureRecognizerStatePossible import platform.UIKit.UIPanGestureRecognizer +import platform.UIKit.UIPinchGestureRecognizer import platform.UIKit.UIPressesEvent import platform.UIKit.UIScreenEdgePanGestureRecognizer import platform.UIKit.UIScrollTypeMaskAll @@ -244,7 +246,9 @@ private class TouchesGestureRecognizer( private fun cancelAllTrackedTouches() { setState(UIGestureRecognizerStateCancelled) - onCancelAllTouches(trackedTouches.keys) + if (trackedTouches.isNotEmpty()) { + onCancelAllTouches(trackedTouches.keys) + } trackedTouches.clear() cancelTouchesFailure() } @@ -497,6 +501,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).toDpOffset() + + 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]. @@ -511,6 +599,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, @@ -540,6 +630,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 @@ -563,6 +664,9 @@ internal class OverlayInputView( scrollGestureRecognizer?.let { addGestureRecognizer(it) } + customPinchGestureRecognizer?.let { + addGestureRecognizer(it) + } addGestureRecognizer(hoverGestureRecognizer) @@ -706,6 +810,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/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/TrackpadPanTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt new file mode 100644 index 0000000000000..228fb760f5cc4 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TrackpadPanTest.kt @@ -0,0 +1,212 @@ +/* + * 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.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.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.pointerInput +import androidx.compose.ui.test.runUIKitInstrumentedTest +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.assertTrue + +internal class TrackpadPanTest { + + @Test + fun testPointerInputReceivesTrackpadPan() = 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++ + } + } + } + } + ) + } + + trackpadPan(screenSize.center, 120.dp, dy = 75.dp) + + assertEquals(1, panStartCount) + assertTrue( + panMoveCount >= 1, + "Expected at least one PanMove, received ${panMoveCount}" + ) + assertEquals(1, panEndCount) + + val totalPanDp = totalPan.toDpOffset(density) + 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/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 5bbd551a4bc06..8d14b096795fa 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,16 @@ 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 import androidx.compose.ui.test.utils.toCGPoint import androidx.compose.ui.test.utils.touchDown import androidx.compose.ui.test.utils.up @@ -471,6 +477,64 @@ 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 anchored + * at [position]. + * All scroll synthesis goes through the `UIEvent (CMPScroll)` category in + * `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, + 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 perStepDelta = DpOffset(dx / steps.toFloat(), dy / steps.toFloat()) + repeat(steps) { + delay(stepInterval.inWholeMilliseconds) + scrollEvent.scrollBy(perStepDelta, targetWindow) + } + + 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 new file mode 100644 index 0000000000000..85e6a33aae74c --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIEvent+Utils.kt @@ -0,0 +1,137 @@ +/* + * 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.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 +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) +} + +/** + * 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.xcodeproj/project.pbxproj b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj index 6d5bc3bd04b2a..7041f5b32db05 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 = 15.6; + 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 = 15.6; + 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..b52ff12df8c93 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.h @@ -0,0 +1,80 @@ +/* + * 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 + +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..9edcf54543b2a --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIEvent+Test.m @@ -0,0 +1,667 @@ +/* + * 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, +}; + +typedef NS_ENUM(NSInteger, CMPHoverPhase) { + CMPHoverPhaseBegan = 1, + CMPHoverPhaseChanged = 2, + CMPHoverPhaseEnded = 3, +}; + +#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 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; + 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 - 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); + // 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); + }); +} + +#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. 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, + CGPoint anchorInWindow, + 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; + } + 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]); +} + +#pragma mark - UIEvent + +@implementation UIEvent (CMPScrollDispatch) + ++ (void)dispatchScrollOnEvent:(UIEvent *)event + atAnchor:(CGPoint)anchor + delta:(CGVector)delta + phase:(CMPScrollPhase)phase + inWindow:(UIWindow *)window { + CMPInstallStateOverrideOnce(); + + CMPSynSetLocation(event, anchor); + CMPSynSetDelta(event, delta); + CMPSynSetPhase(event, phase); + if (window != nil) { CMPSynSetWindow(event, window); } + + NSSet *recognizers = [event cmp_syntheticGestureRecognizersForWindow:window]; + + UIGestureRecognizerState targetState; + switch (phase) { + case CMPScrollPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; + case CMPScrollPhaseChanged: targetState = UIGestureRecognizerStateChanged; break; + case CMPScrollPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; + default: targetState = UIGestureRecognizerStatePossible; break; + } + + // 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]); +} + ++ (void)dispatchTransformOnEvent:(UIEvent *)event + atAnchor:(CGPoint)anchor + scale:(CGFloat)scale + phase:(CMPTransformPhase)phase + inWindow:(UIWindow *)window { + CMPInstallStateOverrideOnce(); + CMPInstallPinchLocationOverrideOnce(); + + 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, 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); } + + UIGestureRecognizerState targetState; + switch (phase) { + case CMPHoverPhaseBegan: targetState = UIGestureRecognizerStateBegan; break; + case CMPHoverPhaseChanged: targetState = UIGestureRecognizerStateChanged; break; + case CMPHoverPhaseEnded: targetState = UIGestureRecognizerStateEnded; break; + } + + // 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]); +} + ++ (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 + phase:CMPHoverPhaseBegan + inWindow:window]; + return (UIEvent *)hoverEvent; +} + +- (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]; +} + +@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..d6712e1bcb81a --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/HoverTest.swift @@ -0,0 +1,174 @@ +/* + * 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) + + 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, "[\(label)] UIHoverEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + 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) + walked.append(target) + pumpRunLoop(0.1) + } + hover?.endHover(in: window) + + let deadline = Date().addingTimeInterval(2.0) + while viewController.events.isEmpty && Date() < deadline { + pumpRunLoop(0.05) + } + + 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 events: [(state: UIGestureRecognizer.State, location: CGPoint)] = [] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + recognizer.addTarget(self, action: #selector(handleHover(_:))) + view.addGestureRecognizer(recognizer) + } + + func reset() { + events.removeAll() + } + + @objc + func handleHover(_ recognizer: UIHoverGestureRecognizer) { + events.append((state: recognizer.state, location: recognizer.location(in: view))) + } +} 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..9302f4c7c89eb --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PanTest.swift @@ -0,0 +1,217 @@ +/* + * 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 PanTest: 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 testSimulatedDragReachesPanRecognizer() { + let window = appDelegate.window! + + let viewController = PanRecordingViewController() + window.rootViewController = viewController + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + 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) + + for _ in 0.. = [.began, .changed, .ended] + for state in viewController.states { + XCTAssertTrue(allowed.contains(state), + "[\(label)] Unexpected state \(describe(state)) in sequence \(viewController.states.map(describe))") + } + + 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: 0.5, + "[\(label)] Unexpected accumulated x translation; got \(last.x), translations=\(viewController.translations)") + XCTAssertEqual(last.y, expected.y, accuracy: 0.5, + "[\(label)] Unexpected accumulated y translation; got \(last.y), translations=\(viewController.translations)") + } + + @MainActor + func testSimulatedDragScrollsVerticalScrollView() { + let window = appDelegate.window! + + let viewController = VerticalScrollHostViewController() + window.rootViewController = viewController + let scrollView = viewController.scrollView + + XCTAssertEqual(scrollView.contentOffset.y, 0, + "Scroll view should start at top before the simulated gesture.") + + let start = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + let perStepDelta = CGPoint(x: 0, y: 40) + let stepCount = 6 + + let scroll = UIEvent.scroll(at: start, delta: perStepDelta, in: window) + pumpRunLoop(0.1) + + for _ in 0.. 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() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + scrollView.frame = view.bounds + scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + scrollView.contentSize = CGSize(width: view.bounds.width, height: 3000) + scrollView.backgroundColor = .systemGray5 + scrollView.alwaysBounceVertical = true + + let content = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 3000)) + content.backgroundColor = .systemBlue + scrollView.addSubview(content) + + view.addSubview(scrollView) + } +} + +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() + view.backgroundColor = .systemBackground + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.allowedScrollTypesMask = .all + 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) + 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 new file mode 100644 index 0000000000000..b7d09db7a587e --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtilsAppTests/PinchTest.swift @@ -0,0 +1,262 @@ +/* + * 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 PinchTest: 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 testSimulatedPinchZoomsScrollView() { + let window = appDelegate.window! + + let viewController = ZoomableScrollHostViewController() + window.rootViewController = viewController + let scrollView = viewController.scrollView + + XCTAssertEqual(scrollView.zoomScale, 1.0, + "Scroll view should start at zoomScale 1.0 before the simulated gesture.") + + let anchor = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + let pinch = UIEvent.pinch(at: anchor, scale: 1.0, in: window) + XCTAssertNotNil(pinch, "UITransformEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + let stepCount = 5 + let finalScale: CGFloat = 2.0 + 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) + pumpRunLoop(0.5) + + XCTAssertGreaterThan( + scrollView.zoomScale, 1.0, + "UIScrollView did not zoom in response to the synthetic pinch gesture; zoomScale=\(scrollView.zoomScale)." + ) + XCTAssertGreaterThan( + viewController.zoomEventCount, 0, + "UIScrollViewDelegate.scrollViewDidZoom was never called during the simulated pinch." + ) + } + + @MainActor + func testSimulatedPinchReachesPinchRecognizer() { + let window = appDelegate.window! + + let viewController = PinchRecordingViewController() + window.rootViewController = viewController + + let anchor = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + + let pinch = UIEvent.pinch(at: anchor, scale: 1.0, in: window) + XCTAssertNotNil(pinch, "UITransformEvent is unavailable; synthetic class allocation failed.") + pumpRunLoop(0.1) + + // Ramp absolute scale from 1.0 toward 2.0 over N steps — matches what + // UIKit would emit during a two-finger trackpad zoom-in. + let stepCount = 5 + let finalScale: CGFloat = 2.0 + 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) + } + + XCTAssertFalse( + viewController.events.isEmpty, + "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 { + let scrollView = UIScrollView() + let contentView = UIView() + private(set) var zoomEventCount = 0 + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + scrollView.frame = view.bounds + scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + scrollView.delegate = self + scrollView.minimumZoomScale = 0.5 + scrollView.maximumZoomScale = 4.0 + scrollView.bouncesZoom = true + + contentView.frame = CGRect(x: 0, y: 0, + width: view.bounds.width, + height: view.bounds.height) + contentView.backgroundColor = .systemBlue + scrollView.contentSize = CGSizeMake(5000, 5000) + scrollView.addSubview(contentView) + + view.addSubview(scrollView) + } + + func viewForZooming(in scrollView: UIScrollView) -> 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) + } + + func reset() { + events.removeAll() + } + + @objc + func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + events.append((state: recognizer.state, scale: recognizer.scale)) + } +}