Skip to content

Commit

Permalink
Touches forwarding to underlying interop views on iOS (#1426)
Browse files Browse the repository at this point in the history
First approach to allow touches to be processed by both Compose and
nested interop views.
Other UX improvements will follow.

1. `actual typealias InteropView = UIView`
2. Change `ComposeScene` API to allow hit testing specific
`InteropView`.
3. Make `InteractionUIView` a `container` view of `InteropContainer` to
enforce it being in the same responder chain as interop views.
4. Remove `InteractionUIView.Delegate` and replace it with callbacks to
avoid `by lazy` reentry in `ComposeSceneMediator`, make
`InteractionUIView` construction eager.
5. Use `event.allTouches` instead of `event.touchesForView` to avoid
receiving an empty list in case `InteractionUIView` is part of responder
chain but not the hit test result itself.
6. Remove `KeyboardEventHandler.uikit.kt` to avoid `by lazy` reentry,
stick everything in a single lambda.

Fixes some of the cases from the domain of
JetBrains/compose-multiplatform#4818

## Testing
`Demo/IosBugs/UIKitRenderSync` now properly registers touches to allow
LazyColumn scrolling and native view reaction.

## Release Notes

### iOS - Fixes
- Touches inside interop views are not exclusive to them and are
processed on Compose side as well.

### Multiple Platforms - Breaking changes
- ComposeScene `fun hitTestInteropView(position: Offset): Boolean`
changed to `fun hitTestInteropView(position: Offset): InteropView?`

---------

Co-authored-by: Ivan Matkov <ivan.matkov@jetbrains.com>
  • Loading branch information
elijah-semyonov and MatkovIvan committed Jul 9, 2024
1 parent 26b4dee commit a78dd41
Show file tree
Hide file tree
Showing 25 changed files with 606 additions and 275 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,71 @@ import kotlinx.cinterop.readValue
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGRectZero
import platform.Foundation.NSSelectorFromString
import platform.MapKit.MKMapView
import platform.UIKit.*

private class TouchReactingView: UIView(frame = CGRectZero.readValue()) {
init {
setUserInteractionEnabled(true)

setDefaultColor()
}

private fun setDefaultColor() {
backgroundColor = UIColor.greenColor
}

override fun touchesBegan(touches: Set<*>, withEvent: UIEvent?) {
super.touchesBegan(touches, withEvent)
backgroundColor = UIColor.redColor
}

override fun touchesMoved(touches: Set<*>, withEvent: UIEvent?) {
super.touchesMoved(touches, withEvent)
}

override fun touchesEnded(touches: Set<*>, withEvent: UIEvent?) {
super.touchesEnded(touches, withEvent)
setDefaultColor()
}

override fun touchesCancelled(touches: Set<*>, withEvent: UIEvent?) {
super.touchesCancelled(touches, withEvent)
setDefaultColor()
}
}

val UIKitRenderSync = Screen.Example("UIKitRenderSync") {
var text by remember { mutableStateOf("Type something") }
LazyColumn(Modifier.fillMaxSize()) {
items(100) { index ->
when (index % 4) {
0 -> Text("material.Text $index", Modifier.fillMaxSize().height(40.dp))
1 -> UIKitView(
if (index == 0) {
UIKitView(
factory = {
val label = UILabel(frame = CGRectZero.readValue())
label.text = "UILabel $index"
label.textColor = UIColor.blackColor
label
MKMapView()
},
modifier = Modifier.fillMaxWidth().height(40.dp)
modifier = Modifier.fillMaxWidth().height(200.dp)
)
2 -> TextField(text, onValueChange = { text = it }, Modifier.fillMaxWidth())
else -> ComposeUITextField(text, onValueChange = { text = it }, Modifier.fillMaxWidth().height(40.dp))
} else {
when (index % 5) {
0 -> Text("material.Text $index", Modifier.fillMaxSize().height(40.dp))
1 -> UIKitView(
factory = {
val label = UILabel(frame = CGRectZero.readValue())
label.text = "UILabel $index"
label.textColor = UIColor.blackColor
label
},
modifier = Modifier.fillMaxWidth().height(40.dp),
interactive = false
)
2 -> TextField(text, onValueChange = { text = it }, Modifier.fillMaxWidth())
3 -> ComposeUITextField(text, onValueChange = { text = it }, Modifier.fillMaxWidth().height(40.dp))
4 -> UIKitView(
factory = { TouchReactingView() },
modifier = Modifier.fillMaxWidth().height(40.dp)
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
997DFCFD2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCFC2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift */; };
99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; };
99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; };
EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */; };
EA70A7EB2B27106100300068 /* CMPAccessibilityElement.m in Sources */ = {isa = PBXBuildFile; fileRef = EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */; };
EA70A7EC2B27106100300068 /* CMPAccessibilityContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA70A7E92B27106100300068 /* CMPAccessibilityContainer.m */; };
EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = EA82F4F82B86144E00465418 /* CMPOSLogger.m */; };
Expand Down Expand Up @@ -75,6 +76,8 @@
99D97A872BF73A9B0035552B /* CMPEditMenuView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuView.m; sourceTree = "<group>"; };
99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextLoupeSession.h; sourceTree = "<group>"; };
99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = "<group>"; };
EA4B52942C2EDEF200FBB55C /* CMPGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPGestureRecognizer.h; sourceTree = "<group>"; };
EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPGestureRecognizer.m; sourceTree = "<group>"; };
EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CMPAccessibilityElement.h; sourceTree = "<group>"; };
EA70A7E72B27106100300068 /* CMPMacros.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CMPMacros.h; sourceTree = "<group>"; };
EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CMPAccessibilityElement.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -140,6 +143,8 @@
99D97A872BF73A9B0035552B /* CMPEditMenuView.m */,
EAB33E162C12E746002CFF44 /* CMPMetalDrawablesHandler.h */,
EAB33E172C12E746002CFF44 /* CMPMetalDrawablesHandler.m */,
EA4B52942C2EDEF200FBB55C /* CMPGestureRecognizer.h */,
EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */,
);
path = CMPUIKitUtils;
sourceTree = "<group>";
Expand Down Expand Up @@ -320,6 +325,7 @@
EABD912B2BC02B5F00455279 /* CMPInteropWrappingView.m in Sources */,
EA70A7EC2B27106100300068 /* CMPAccessibilityContainer.m in Sources */,
EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */,
EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */,
EA70A7EB2B27106100300068 /* CMPAccessibilityElement.m in Sources */,
99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */,
EA82F4FC2B86184F00465418 /* CMPOSLoggerInterval.m in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// CMPGestureRecognizer.h
// CMPUIKitUtils
//
// Created by Ilia.Semenov on 28/06/2024.
//

#import <UIKit/UIKit.h>
#import <UIKit/UIGestureRecognizerSubclass.h>

NS_ASSUME_NONNULL_BEGIN

@protocol CMPGestureRecognizerHandler <NSObject>

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent * _Nullable)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent * _Nullable)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent * _Nullable)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent * _Nullable)event;
- (BOOL)shouldRecognizeSimultaneously:(UIGestureRecognizer *)first withOther:(UIGestureRecognizer *)second;

@end

@interface CMPGestureRecognizer : UIGestureRecognizer <UIGestureRecognizerDelegate>

@property (weak, nonatomic) id <CMPGestureRecognizerHandler> handler;

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// CMPGestureRecognizer.m
// CMPUIKitUtils
//
// Created by Ilia.Semenov on 28/06/2024.
//

#import "CMPGestureRecognizer.h"

@implementation CMPGestureRecognizer

- (instancetype)init {
self = [super init];

if (self) {
self.cancelsTouchesInView = false;
self.delegate = self;
}

return self;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
id <CMPGestureRecognizerHandler> handler = self.handler;

if (handler) {
return [handler shouldRecognizeSimultaneously:gestureRecognizer withOther:otherGestureRecognizer];
} else {
return NO;
}
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.handler touchesBegan:touches withEvent:event];

if (self.state == UIGestureRecognizerStatePossible) {
self.state = UIGestureRecognizerStateBegan;
}
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.handler touchesMoved:touches withEvent:event];

switch (self.state) {
case UIGestureRecognizerStateBegan:
case UIGestureRecognizerStateChanged:
self.state = UIGestureRecognizerStateChanged;
break;
default:
break;
}
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.handler touchesEnded:touches withEvent:event];

switch (self.state) {
case UIGestureRecognizerStateBegan:
case UIGestureRecognizerStateChanged:
if (self.numberOfTouches == 0) {
self.state = UIGestureRecognizerStateEnded;
} else {
self.state = UIGestureRecognizerStateChanged;
}
break;
default:
break;
}
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.handler touchesCancelled:touches withEvent:event];

switch (self.state) {
case UIGestureRecognizerStateBegan:
case UIGestureRecognizerStateChanged:
if (self.numberOfTouches == 0) {
self.state = UIGestureRecognizerStateCancelled;
} else {
self.state = UIGestureRecognizerStateChanged;
}
break;
default:
break;
}
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[];
#import "CMPOSLogger.h"
#import "CMPTextLoupeSession.h"
#import "CMPMetalDrawablesHandler.h"
#import "CMPGestureRecognizer.h"
11 changes: 10 additions & 1 deletion compose/ui/ui/api/desktop/ui.api
Original file line number Diff line number Diff line change
Expand Up @@ -3485,7 +3485,7 @@ public abstract interface class androidx/compose/ui/scene/ComposeScene {
public abstract fun getLayoutDirection ()Landroidx/compose/ui/unit/LayoutDirection;
public abstract fun getSize-bOM6tXw ()Landroidx/compose/ui/unit/IntSize;
public abstract fun hasInvalidations ()Z
public abstract fun hitTestInteropView-k-4lQ0M (J)Z
public abstract fun hitTestInteropView-k-4lQ0M (J)Ljava/lang/Object;
public abstract fun invalidatePositionInWindow ()V
public abstract fun render (Landroidx/compose/ui/graphics/Canvas;J)V
public abstract fun sendKeyEvent-ZmokQxo (Ljava/lang/Object;)Z
Expand Down Expand Up @@ -3965,6 +3965,15 @@ public final class androidx/compose/ui/text/TextMeasurerHelperKt {
public static final fun rememberTextMeasurer (ILandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/text/TextMeasurer;
}

public class androidx/compose/ui/viewinterop/InteropViewAnchorModifierNode : androidx/compose/ui/Modifier$Node, androidx/compose/ui/node/PointerInputModifierNode {
public static final field $stable I
public fun <init> (Ljava/lang/Object;)V
public final fun getInteropView ()Ljava/lang/Object;
public fun onCancelPointerInput ()V
public fun onPointerEvent-H0pRuoY (Landroidx/compose/ui/input/pointer/PointerEvent;Landroidx/compose/ui/input/pointer/PointerEventPass;J)V
public final fun setInteropView (Ljava/lang/Object;)V
}

public abstract interface class androidx/compose/ui/window/ApplicationScope {
public abstract fun exitApplication ()V
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2024 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.viewinterop

/**
* A typealias for the platform's built-in View type, which may be hosted inside of a Compose UI
* hierarchy to allow for interoperability. Not all platforms support interoperability in this way,
* in which case the typealias will resolve to [Any].
*/
actual typealias InteropView = Any
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2024 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.viewinterop

/**
* A typealias for the platform's built-in View type, which may be hosted inside of a Compose UI
* hierarchy to allow for interoperability. Not all platforms support interoperability in this way,
* in which case the typealias will resolve to [Any].
*/
actual typealias InteropView = Any
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2024 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.viewinterop

/**
* A typealias for the platform's built-in View type, which may be hosted inside of a Compose UI
* hierarchy to allow for interoperability. Not all platforms support interoperability in this way,
* in which case the typealias will resolve to [Any].
*/
actual typealias InteropView = Any

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ private data class RootTrackInteropModifierElement<T>(
* @see ModifierNodeElement
*/
internal data class TrackInteropModifierElement<T>(
var container: InteropContainer<T>,
var nativeView: T,
val container: InteropContainer<T>,
val nativeView: T,
) : ModifierNodeElement<TrackInteropModifierNode<T>>() {
override fun create() = TrackInteropModifierNode(
container = container,
Expand Down
Loading

0 comments on commit a78dd41

Please sign in to comment.