Rework button/buttons handling & add a new test checking for move events on chorded mouse button presses

Summary:
Changelog: [iOS][Internal] - Rework button/buttons handling on pointer events & add a new test checking for move events on chorded mouse button presses

This is a larger diff that largely stems from implementing a new pointer event platform test about chorded mouse button presses (adapted from https://github.com/web-platform-tests/wpt/blob/master/pointerevents/pointerevent_pointermove_on_chorded_mouse_button.html). In order to implement this test I added a new method, `step`, to an async test instance to allow you to run ad-hoc assertions labeled by the async test (like it's done in the original web platform test).

Once implementing the test I also discovered my current logic for handling the `button` and `buttons` properties was insufficient so I reworked it to handle more/most cases by making the `button` property instead determined by the change in the `buttons` property. This largely works but unfortunately due to what seems to be a bug in UIKit, chorded pointer "up" events receive the wrong button mask values so it's currently impossible to get this test fully passing on iOS. I've since submitted a bug report to Apple but my hopes aren't high that it will be focused on since it's such a niche issue.

Reviewed By: lunaleaps

Differential Revision: D38951159

fbshipit-source-id: 426b7cf7930ed8329151382fafee487493e568de
This commit is contained in:
Vincent Riemer
2022-09-01 15:01:33 -07:00
committed by Facebook GitHub Bot
parent b09821591c
commit c89f5fdfd6
5 changed files with 274 additions and 93 deletions
+30 -31
View File
@@ -174,21 +174,36 @@ static CGFloat RadsToDegrees(CGFloat rads)
static int ButtonMaskToButtons(UIEventButtonMask buttonMask)
{
int buttonsMaskResult = 0;
if (@available(iOS 13.4, *)) {
return (((buttonMask & UIEventButtonMaskPrimary) > 0) ? 1 : 0) |
(((buttonMask & UIEventButtonMaskSecondary) > 0) ? 2 : 0);
if ((buttonMask & UIEventButtonMaskPrimary) != 0) {
buttonsMaskResult |= 1;
}
if ((buttonMask & UIEventButtonMaskSecondary) != 0) {
buttonsMaskResult |= 2;
}
// undocumented mask value which represents the "auxiliary button" (i.e. middle mouse button)
if ((buttonMask & 0x4) != 0) {
buttonsMaskResult |= 4;
}
}
return 0;
return buttonsMaskResult;
}
static int ButtonMaskToButton(UIEventButtonMask buttonMask)
static int ButtonMaskDiffToButton(UIEventButtonMask prevButtonMask, UIEventButtonMask curButtonMask)
{
if (@available(iOS 13.4, *)) {
if ((buttonMask & UIEventButtonMaskSecondary) > 0) {
if ((prevButtonMask & UIEventButtonMaskPrimary) != (curButtonMask & UIEventButtonMaskPrimary)) {
return 0;
}
if ((prevButtonMask & 0x4) != (curButtonMask & 0x4)) {
return 1;
}
if ((prevButtonMask & UIEventButtonMaskSecondary) != (curButtonMask & UIEventButtonMaskSecondary)) {
return 2;
}
}
return 0;
return -1;
}
static void UpdateActiveTouchWithUITouch(
@@ -220,9 +235,15 @@ static void UpdateActiveTouchWithUITouch(
activeTouch.altitudeAngle = uiTouch.altitudeAngle;
activeTouch.azimuthAngle = [uiTouch azimuthAngleInView:nil];
if (@available(iOS 13.4, *)) {
activeTouch.buttonMask = uiEvent.buttonMask;
UIEventButtonMask nextButtonMask = 0;
if (uiTouch.phase != UITouchPhaseEnded) {
nextButtonMask = uiTouch.type == UITouchTypeIndirectPointer ? uiEvent.buttonMask : 1;
}
activeTouch.button = ButtonMaskDiffToButton(activeTouch.buttonMask, nextButtonMask);
activeTouch.buttonMask = nextButtonMask;
activeTouch.modifierFlags = uiEvent.modifierFlags;
} else {
activeTouch.button = 0;
activeTouch.buttonMask = 0;
activeTouch.modifierFlags = 0;
}
@@ -245,7 +266,6 @@ CreateTouchWithUITouch(UITouch *uiTouch, UIEvent *uiEvent, UIView *rootComponent
}
componentView = componentView.superview;
}
UpdateActiveTouchWithUITouch(activeTouch, uiTouch, uiEvent, rootComponentView, rootViewOriginOffset);
return activeTouch;
}
@@ -344,29 +364,11 @@ static PointerEvent CreatePointerEventFromActiveTouch(ActiveTouch activeTouch, R
event.detail = 0;
event.button = -1;
if (eventType == RCTTouchEventTypeTouchStart || eventType == RCTTouchEventTypeTouchEnd) {
event.button = activeTouch.button;
}
event.buttons = 1;
if (@available(iOS 13.4, *)) {
if (activeTouch.touchType == UITouchTypeIndirectPointer) {
// Indirect pointers are the only situations where buttonMask is "accurate"
// so we override the assumed "left click" button value when those type of
// events are recieved
event.buttons = ButtonMaskToButtons(activeTouch.buttonMask);
}
}
event.button = activeTouch.button;
event.buttons = ButtonMaskToButtons(activeTouch.buttonMask);
UpdatePointerEventModifierFlags(event, activeTouch.modifierFlags);
// UIEvent's button mask for touch end events still marks the button as down
// so this ensures it's set to 0 as per the pointer event spec
if (eventType == RCTTouchEventTypeTouchEnd) {
event.buttons = 0;
}
event.tangentialPressure = 0.0;
event.twist = 0;
event.isPrimary = activeTouch.isPrimary;
@@ -557,15 +559,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithTarget : (id)target action : (SEL)act
}
break;
}
activeTouch.button = ButtonMaskToButton(event.buttonMask);
} else {
activeTouch.touch.identifier = _identifierPool.dequeue();
if (_primaryTouchPointerId == -1) {
_primaryTouchPointerId = activeTouch.touch.identifier;
activeTouch.isPrimary = true;
}
activeTouch.button = 0;
}
// If the pointer has not been marked as hovering over views before the touch started, we register
@@ -40,6 +40,7 @@ export type PlatformTestResult = $ReadOnly<{|
export type PlatformTestContext = $ReadOnly<{
assert_true(a: boolean, description: string): void,
assert_equals(a: any, b: any, description: string): void,
assert_not_equals(a: any, b: any, description: string): void,
assert_greater_than_equal(a: number, b: number, description: string): void,
assert_less_than_equal(a: number, b: number, description: string): void,
}>;
@@ -48,6 +49,7 @@ export type PlatformTestCase = (context: PlatformTestContext) => void;
export type AsyncPlatformTest = $ReadOnly<{|
done(): void,
step(testcase: PlatformTestCase): void,
|}>;
export type SyncTestOptions = $ReadOnly<{|
@@ -35,8 +35,13 @@ function constructAsyncTestHook(
$ReadOnly<{[string]: AsyncTestStatus}>,
) => $ReadOnly<{[string]: AsyncTestStatus}>,
) => void,
runTestCase: (
testCase: PlatformTestCase,
) => Array<PlatformTestAssertionResult>,
) {
return (description: string, timeoutMs?: number = 10000) => {
const assertionsRef = useRef([]);
const timeoutIDRef = useRef<TimeoutID | null>(null);
const timeoutHandler = useCallback(() => {
@@ -44,6 +49,7 @@ function constructAsyncTestHook(
addTestResult({
name: description,
assertions: [
...assertionsRef.current,
{
passing: false,
name: 'async_timeout',
@@ -82,13 +88,16 @@ function constructAsyncTestHook(
addTestResult({
name: description,
assertions: [
...assertionsRef.current,
{
passing: true,
name: 'async_test',
description: 'async test should be completed',
},
],
status: 'PASS',
status: didAllAssertionsPass(assertionsRef.current)
? 'PASS'
: 'FAIL',
error: null,
});
return {...prev, [description]: 'COMPLETED'};
@@ -97,6 +106,11 @@ function constructAsyncTestHook(
});
}, [description]);
const stepHandler = useCallback(testCase => {
const stepAssertions = runTestCase(testCase);
assertionsRef.current.push(...stepAssertions);
}, []);
// test registration
useEffect(() => {
updateAsyncTestStatuses(prev => {
@@ -110,8 +124,9 @@ function constructAsyncTestHook(
return useMemo(
() => ({
done: completionHandler,
step: stepHandler,
}),
[completionHandler],
[completionHandler, stepHandler],
);
};
}
@@ -174,6 +189,74 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult
setTestElementKey(k => k + 1);
}, []);
const runTestCase = useCallback((testCase: PlatformTestCase) => {
const assertionResults: Array<PlatformTestAssertionResult> = [];
const baseAssert = (
assertionName: string,
testConditionResult: boolean,
description: string,
failureMessage: string,
) => {
if (testConditionResult) {
assertionResults.push({
passing: true,
name: assertionName,
description,
});
} else {
assertionResults.push({
passing: false,
name: assertionName,
description,
failureMessage,
});
}
};
const context: PlatformTestContext = {
assert_true: (cond: boolean, desc: string) =>
baseAssert(
'assert_true',
cond,
desc,
"expected 'true' but recieved 'false'",
),
assert_equals: (a: any, b: any, desc: string) =>
baseAssert(
'assert_equal',
a === b,
desc,
`expected ${a} to equal ${b}`,
),
assert_not_equals: (a: any, b: any, desc: string) =>
baseAssert(
'assert_not_equals',
a !== b,
desc,
`expected ${a} not to equal ${b}`,
),
assert_greater_than_equal: (a: number, b: number, desc: string) =>
baseAssert(
'assert_greater_than_equal',
a >= b,
desc,
`expected ${a} to be greater than or equal to ${b}`,
),
assert_less_than_equal: (a: number, b: number, desc: string) =>
baseAssert(
'assert_less_than_equal',
a <= b,
desc,
`expected ${a} to be less than or equal to ${b}`,
),
};
testCase(context);
return assertionResults;
}, []);
const testFunction: PlatformTestHarness['test'] = useCallback(
(
testCase: PlatformTestCase,
@@ -192,63 +275,8 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult
return;
}
const assertionResults: Array<PlatformTestAssertionResult> = [];
const baseAssert = (
assertionName: string,
testConditionResult: boolean,
description: string,
failureMessage: string,
) => {
if (testConditionResult) {
assertionResults.push({
passing: true,
name: assertionName,
description,
});
} else {
assertionResults.push({
passing: false,
name: assertionName,
description,
failureMessage,
});
}
};
const context: PlatformTestContext = {
assert_true: (cond: boolean, desc: string) =>
baseAssert(
'assert_true',
cond,
desc,
"expected 'true' but recieved 'false'",
),
assert_equals: (a: any, b: any, desc: string) =>
baseAssert(
'assert_equal',
a === b,
desc,
`expected ${a} to equal ${b}`,
),
assert_greater_than_equal: (a: number, b: number, desc: string) =>
baseAssert(
'assert_greater_than_equal',
a >= b,
desc,
`expected ${a} to be greater than or equal to ${b}`,
),
assert_less_than_equal: (a: number, b: number, desc: string) =>
baseAssert(
'assert_less_than_equal',
a <= b,
desc,
`expected ${a} to be less than or equal to ${b}`,
),
};
try {
testCase(context);
const assertionResults = runTestCase(testCase);
addTestResult({
name,
status: didAllAssertionsPass(assertionResults) ? 'PASS' : 'FAIL',
@@ -259,17 +287,22 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult
addTestResult({
name,
status: 'ERROR',
assertions: assertionResults,
assertions: [],
error,
});
}
},
[addTestResult],
[addTestResult, runTestCase],
);
const asyncTestHook: PlatformTestHarness['useAsyncTest'] = useMemo(
() => constructAsyncTestHook(addTestResult, updateAsyncTestStatuses),
[addTestResult],
() =>
constructAsyncTestHook(
addTestResult,
updateAsyncTestStatuses,
runTestCase,
),
[addTestResult, runTestCase],
);
const numPendingAsyncTests = useMemo(() => {
@@ -0,0 +1,138 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
import type {PlatformTestComponentBaseProps} from '../PlatformTest/RNTesterPlatformTestTypes';
import type {PointerEvent} from 'react-native/Libraries/Types/CoreEventTypes';
import RNTesterPlatformTest from '../PlatformTest/RNTesterPlatformTest';
import * as React from 'react';
import {useCallback, useRef} from 'react';
import {View, StyleSheet} from 'react-native';
const styles = StyleSheet.create({
target: {
backgroundColor: 'black',
padding: 32,
},
});
// adapted from https://github.com/web-platform-tests/wpt/blob/master/pointerevents/pointerevent_pointermove_on_chorded_mouse_button.html
function PointerEventPointerMoveOnChordedMouseButtonTestCase(
props: PlatformTestComponentBaseProps,
) {
const {harness} = props;
const test_pointermove = harness.useAsyncTest(
'pointermove events received for button state changes',
);
const stepRef = useRef(0);
const firstButtonRef = useRef(0);
// When a pointer changes button state and the circumstances produce no other pointer event, the pointermove event must be dispatched.
// 5.2.6
const handlePointerDown = useCallback(
(event: PointerEvent) => {
test_pointermove.step(({assert_equals}) => {
assert_equals(
stepRef.current,
0,
'There must not be more than one pointer down event.',
);
});
if (stepRef.current === 0) {
stepRef.current = 1;
firstButtonRef.current = event.nativeEvent.buttons;
}
},
[test_pointermove],
);
const handlePointerMove = useCallback(
(event: PointerEvent) => {
if (stepRef.current === 1 && event.nativeEvent.button !== -1) {
// second button pressed
test_pointermove.step(({assert_not_equals, assert_true}) => {
assert_not_equals(
event.nativeEvent.buttons,
firstButtonRef.current,
'The pointermove event must be triggered by pressing a second button.',
);
assert_true(
// eslint-disable-next-line no-bitwise
(event.nativeEvent.buttons & firstButtonRef.current) !== 0,
'The first button must still be reported pressed.',
);
});
stepRef.current = 2;
} else if (stepRef.current === 2 && event.nativeEvent.button !== -1) {
// second button released
test_pointermove.step(({assert_equals}) => {
assert_equals(
event.nativeEvent.buttons,
firstButtonRef.current,
'The pointermove event must be triggered by releasing the second button.',
);
});
stepRef.current = 3;
}
},
[test_pointermove],
);
const handlePointerUp = useCallback(
(event: PointerEvent) => {
test_pointermove.step(({assert_equals}) => {
assert_equals(
stepRef.current,
3,
'The pointerup event must be triggered after pressing and releasing the second button.',
);
assert_equals(
event.nativeEvent.buttons,
0,
'The pointerup event must be triggered by releasing the last pressed button.',
);
});
test_pointermove.done();
},
[test_pointermove],
);
return (
<View
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={styles.target}
/>
);
}
type Props = $ReadOnly<{}>;
export default function PointerEventPointerMoveOnChordedMouseButton(
props: Props,
): React.MixedElement {
return (
<RNTesterPlatformTest
component={PointerEventPointerMoveOnChordedMouseButtonTestCase}
description="This test checks if pointermove event are triggered by button state changes"
instructions={[
'Put your mouse over the black rectangle',
'Press the left mouse button and hold it',
'Press the middle mouse button',
'Release the middle mouse button',
'Release the left mouse button to complete the test',
]}
title="PointerEvents pointermove on button state changes"
/>
);
}
@@ -18,6 +18,7 @@ import PointerEventPointerMove from './W3CPointerEventPlatformTests/PointerEvent
import CompatibilityAnimatedPointerMove from './Compatibility/CompatibilityAnimatedPointerMove';
import PointerEventPrimaryTouchPointer from './W3CPointerEventPlatformTests/PointerEventPrimaryTouchPointer';
import PointerEventAttributesNoHoverPointers from './W3CPointerEventPlatformTests/PointerEventAttributesNoHoverPointers';
import PointerEventPointerMoveOnChordedMouseButton from './W3CPointerEventPlatformTests/PointerEventPointerMoveOnChordedMouseButton';
function EventfulView(props: {|
name: string,
@@ -283,6 +284,14 @@ export default {
return <PointerEventPrimaryTouchPointer />;
},
},
{
name: 'pointerevent_pointermove_on_chorded_mouse_button',
description: '',
title: 'PointerEvents pointermove on button state changes',
render(): React.Node {
return <PointerEventPointerMoveOnChordedMouseButton />;
},
},
CompatibilityAnimatedPointerMove,
],
};