Skip to content

Commit

Permalink
Improve interop touches by using UIScrollView-like strategy (#1440)
Browse files Browse the repository at this point in the history
## Description
In this approach `CMPGestureRecognizer` delays `touchesBegan` until
explicitly failed, and is required to fail by `UIGestureRecognizer` of
children views (aka interop views). Failure happens after the first
touch started and no motion above scroll slop happens within 150ms. If
this happens, intercepted touches are delivered to children views (and
their gesture recognisers), Compose itself gets all tracked touches as
cancelled and ignores them until the touch sequence ends (imposed by
UIKit).
This behavior is inspired by `UIScrollView` implementation.


https://github.com/JetBrains/compose-multiplatform-core/assets/4167681/1245b986-1f11-4f02-a939-c069a7797e3a

### Fixes
Improves behavior of touches in [certain
scenarios](JetBrains/compose-multiplatform#4818)

## Testing
This should be tested by QA

## Release Notes

### iOS - Features
- Improvements in touches processing to detect if touches were meant to
be delivered to interop views, or should be processed by Compose.

---------

Co-authored-by: Ivan Matkov <ivan.matkov@jetbrains.com>
  • Loading branch information
elijah-semyonov and MatkovIvan committed Jul 12, 2024
1 parent 97f7fbd commit 174766b
Show file tree
Hide file tree
Showing 5 changed files with 495 additions and 120 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ NS_ASSUME_NONNULL_BEGIN
- (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;
- (void)onFailure;

@end

@interface CMPGestureRecognizer : UIGestureRecognizer <UIGestureRecognizerDelegate>

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

- (void)cancelFailure;
- (void)scheduleFailure;

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -7,82 +7,100 @@

#import "CMPGestureRecognizer.h"

@implementation CMPGestureRecognizer
@implementation CMPGestureRecognizer {
dispatch_block_t _scheduledFailureBlock;
}

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

if (self) {
self.cancelsTouchesInView = false;
if (self) {
self.delegate = self;
[self addTarget:self action:@selector(handleStateChange)];
}

return self;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
id <CMPGestureRecognizerHandler> handler = self.handler;
- (void)handleStateChange {
switch (self.state) {
case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateCancelled:
[self cancelFailure];
break;

default:
break;
}
}

- (BOOL)shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
UIView *view = self.view;
UIView *otherView = otherGestureRecognizer.view;

if (handler) {
return [handler shouldRecognizeSimultaneously:gestureRecognizer withOther:otherGestureRecognizer];
} else {
if (view == nil || otherView == nil) {
return NO;
}

// Allow simultaneous recognition only if otherGestureRecognizer is attached to the view up in the hierarchy
return ![otherView isDescendantOfView:view];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.handler touchesBegan:touches withEvent:event];
- (BOOL)shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return NO;
}

- (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}

- (void)cancelFailure {
if (_scheduledFailureBlock) {
dispatch_block_cancel(_scheduledFailureBlock);
_scheduledFailureBlock = NULL;
}
}

- (void)fail {
[self.handler onFailure];
}

- (void)scheduleFailure {
__weak typeof(self) weakSelf = self;
dispatch_block_t dispatchBlock = dispatch_block_create(0, ^{
[weakSelf fail];
});

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

// 150ms is a timer delay for notifying a handler that the gesture was failed to recognize.
// `handler` implementtion is responsible for cancelling this via calling `cancelFailure` and transitioning
// this gesture recognizer to a proper state.
double failureDelay = 0.15;

dispatch_time_t dispatchTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(failureDelay * NSEC_PER_SEC));

// Schedule the block to be executed at `dispatchTime`
dispatch_after(dispatchTime, dispatch_get_main_queue(), dispatchBlock);
}

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

- (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
@@ -0,0 +1,22 @@
/*
* 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.platform

/**
* iOS default value in scale-independent points for touch slop that recognizes as scroll/pan gesture.
*/
internal const val CUPERTINO_TOUCH_SLOP = 10
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import androidx.compose.ui.interop.UIKitInteropContainer
import androidx.compose.ui.node.TrackInteropContainer
import androidx.compose.ui.platform.AccessibilityMediator
import androidx.compose.ui.platform.AccessibilitySyncOptions
import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP
import androidx.compose.ui.platform.DefaultInputModeManager
import androidx.compose.ui.platform.EmptyViewConfiguration
import androidx.compose.ui.platform.LocalLayoutMargins
Expand Down Expand Up @@ -87,6 +88,7 @@ import platform.CoreGraphics.CGRect
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGRectZero
import platform.CoreGraphics.CGSize
import platform.QuartzCore.CACurrentMediaTime
import platform.QuartzCore.CATransaction
import platform.UIKit.NSLayoutConstraint
import platform.UIKit.UIEvent
Expand Down Expand Up @@ -229,7 +231,7 @@ internal class ComposeSceneMediator(
override val touchSlop: Float
get() = with(density) {
// this value is originating from iOS 16 drag behavior reverse engineering
10.dp.toPx()
CUPERTINO_TOUCH_SLOP.dp.toPx()
}
}

Expand Down Expand Up @@ -367,29 +369,41 @@ internal class ComposeSceneMediator(
* @param event the [UIEvent] associated with the touches
* @param phase the [CupertinoTouchesPhase] of the touches
*/
private fun onTouchesEvent(view: UIView, touches: Set<*>, event: UIEvent, phase: CupertinoTouchesPhase) {
private fun onTouchesEvent(view: UIView, touches: Set<*>, event: UIEvent?, phase: CupertinoTouchesPhase) {
val pointers = touches.map {
val touch = it as UITouch
val id = touch.hashCode().toLong()
val position = touch.offsetInView(view, density.density)
ComposeScenePointer(
id = PointerId(id),
position = position,
pressed = touch.isPressed,
pressed = when (phase) {
// When CMPGestureRecognizer is failed, all tracked touches are sent immediately
// as CANCELLED. In this case, we should not consider the touch as pressed
// despite them being on the screen. This is the last event for Compose in a
// given gesture sequence and should be treated as such.
CupertinoTouchesPhase.CANCELLED -> false
else -> touch.isPressed
},
type = PointerType.Touch,
pressure = touch.force.toFloat(),
historical = event.historicalChangesForTouch(
historical = event?.historicalChangesForTouch(
touch,
view,
density.density
)
) ?: emptyList()
)
} ?: emptyList()
}

// If the touches were cancelled due to gesture failure, the timestamp is not available,
// because no actual event with touch updates happened. We just use the current time in
// this case.
val timestamp = event?.timestamp ?: CACurrentMediaTime()

scene.sendPointerEvent(
eventType = phase.toPointerEventType(),
pointers = pointers,
timeMillis = (event.timestamp * 1e3).toLong(),
timeMillis = (timestamp * 1e3).toLong(),
nativeEvent = event
)
}
Expand Down
Loading

0 comments on commit 174766b

Please sign in to comment.