create-expo-cljs-app
Version:
Create a react native application with Expo and Shadow-CLJS!
666 lines (612 loc) • 24.7 kB
JavaScript
/**
* Copyright (c) Nicolas Gallagher
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
/**
* RESPONDER EVENT SYSTEM
*
* A single, global "interaction lock" on views. For a view to be the "responder" means
* that pointer interactions are exclusive to that view and none other. The "interaction
* lock" can be transferred (only) to ancestors of the current "responder" as long as
* pointers continue to be active.
*
* Responder being granted:
*
* A view can become the "responder" after the following events:
* * "pointerdown" (implemented using "touchstart", "mousedown")
* * "pointermove" (implemented using "touchmove", "mousemove")
* * "scroll" (while a pointer is down)
* * "selectionchange" (while a pointer is down)
*
* If nothing is already the "responder", the event propagates to (capture) and from
* (bubble) the event target until a view returns `true` for
* `on*ShouldSetResponder(Capture)`.
*
* If something is already the responder, the event propagates to (capture) and from
* (bubble) the lowest common ancestor of the event target and the current "responder".
* Then negotiation happens between the current "responder" and a view that wants to
* become the "responder": see the timing diagram below.
*
* (NOTE: Scrolled views either automatically become the "responder" or release the
* "interaction lock". A native scroll view that isn't built on top of the responder
* system must result in the current "responder" being notified that it no longer has
* the "interaction lock" - the native system has taken over.
*
* Responder being released:
*
* As soon as there are no more active pointers that *started* inside descendants
* of the *current* "responder", an `onResponderRelease` event is dispatched to the
* current "responder", and the responder lock is released.
*
* Typical sequence of events:
* * startShouldSetResponder
* * responderGrant/Reject
* * responderStart
* * responderMove
* * responderEnd
* * responderRelease
*/
/* Negotiation Performed
+-----------------------+
/ \
Process low level events to + Current Responder + wantsResponderID
determine who to perform negot-| (if any exists at all) |
iation/transition | Otherwise just pass through|
-------------------------------+----------------------------+------------------+
Bubble to find first ID | |
to return true:wantsResponderID| |
| |
+--------------+ | |
| onTouchStart | | |
+------+-------+ none | |
| return| |
+-----------v-------------+true| +------------------------+ |
|onStartShouldSetResponder|----->| onResponderStart (cur) |<-----------+
+-----------+-------------+ | +------------------------+ | |
| | | +--------+-------+
| returned true for| false:REJECT +-------->|onResponderReject
| wantsResponderID | | | +----------------+
| (now attempt | +------------------+-----+ |
| handoff) | | onResponder | |
+------------------->| TerminationRequest | |
| +------------------+-----+ |
| | | +----------------+
| true:GRANT +-------->|onResponderGrant|
| | +--------+-------+
| +------------------------+ | |
| | onResponderTerminate |<-----------+
| +------------------+-----+ |
| | | +----------------+
| +-------->|onResponderStart|
| | +----------------+
Bubble to find first ID | |
to return true:wantsResponderID| |
| |
+-------------+ | |
| onTouchMove | | |
+------+------+ none | |
| return| |
+-----------v-------------+true| +------------------------+ |
|onMoveShouldSetResponder |----->| onResponderMove (cur) |<-----------+
+-----------+-------------+ | +------------------------+ | |
| | | +--------+-------+
| returned true for| false:REJECT +-------->|onResponderReject
| wantsResponderID | | | +----------------+
| (now attempt | +------------------+-----+ |
| handoff) | | onResponder | |
+------------------->| TerminationRequest| |
| +------------------+-----+ |
| | | +----------------+
| true:GRANT +-------->|onResponderGrant|
| | +--------+-------+
| +------------------------+ | |
| | onResponderTerminate |<-----------+
| +------------------+-----+ |
| | | +----------------+
| +-------->|onResponderMove |
| | +----------------+
| |
| |
Some active touch started| |
inside current responder | +------------------------+ |
+------------------------->| onResponderEnd | |
| | +------------------------+ |
+---+---------+ | |
| onTouchEnd | | |
+---+---------+ | |
| | +------------------------+ |
+------------------------->| onResponderEnd | |
No active touches started| +-----------+------------+ |
inside current responder | | |
| v |
| +------------------------+ |
| | onResponderRelease | |
| +------------------------+ |
| |
+ + */
import type { ResponderEvent } from './createResponderEvent';
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
import createResponderEvent from './createResponderEvent';
import {
isCancelish,
isEndish,
isMoveish,
isScroll,
isSelectionChange,
isStartish
} from './ResponderEventTypes';
import {
getLowestCommonAncestor,
getResponderPaths,
hasTargetTouches,
hasValidSelection,
isPrimaryPointerDown,
setResponderId
} from './utils';
import ResponderTouchHistoryStore from './ResponderTouchHistoryStore';
/* ------------ TYPES ------------ */
type ResponderId = number;
type ActiveResponderInstance = {
id: ResponderId,
idPath: Array<number>,
node: any
};
type EmptyResponderInstance = {
id: null,
idPath: null,
node: null
};
type ResponderInstance = ActiveResponderInstance | EmptyResponderInstance;
export type ResponderConfig = {
// Direct responder events dispatched directly to responder. Do not bubble.
onResponderEnd?: ?(e: ResponderEvent) => void,
onResponderGrant?: ?(e: ResponderEvent) => void | boolean,
onResponderMove?: ?(e: ResponderEvent) => void,
onResponderRelease?: ?(e: ResponderEvent) => void,
onResponderReject?: ?(e: ResponderEvent) => void,
onResponderStart?: ?(e: ResponderEvent) => void,
onResponderTerminate?: ?(e: ResponderEvent) => void,
onResponderTerminationRequest?: ?(e: ResponderEvent) => boolean,
// On pointer down, should this element become the responder?
onStartShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onStartShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On pointer move, should this element become the responder?
onMoveShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onMoveShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On scroll, should this element become the responder? Do no bubble
onScrollShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onScrollShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean,
// On text selection change, should this element become the responder?
onSelectionChangeShouldSetResponder?: ?(e: ResponderEvent) => boolean,
onSelectionChangeShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean
};
const emptyObject = {};
/* ------------ IMPLEMENTATION ------------ */
const startRegistration = [
'onStartShouldSetResponderCapture',
'onStartShouldSetResponder',
{ bubbles: true }
];
const moveRegistration = [
'onMoveShouldSetResponderCapture',
'onMoveShouldSetResponder',
{ bubbles: true }
];
const scrollRegistration = [
'onScrollShouldSetResponderCapture',
'onScrollShouldSetResponder',
{ bubbles: false }
];
const shouldSetResponderEvents = {
touchstart: startRegistration,
mousedown: startRegistration,
touchmove: moveRegistration,
mousemove: moveRegistration,
scroll: scrollRegistration
};
const emptyResponder = { id: null, idPath: null, node: null };
const responderListenersMap = new Map();
let isEmulatingMouseEvents = false;
let trackedTouchCount = 0;
let currentResponder: ResponderInstance = {
id: null,
node: null,
idPath: null
};
function changeCurrentResponder(responder: ResponderInstance) {
currentResponder = responder;
}
function getResponderConfig(id: ResponderId): ResponderConfig | Object {
const config = responderListenersMap.get(id);
return config != null ? config : emptyObject;
}
/**
* Process native events
*
* A single event listener is used to manage the responder system.
* All pointers are tracked in the ResponderTouchHistoryStore. Native events
* are interpreted in terms of the Responder System and checked to see if
* the responder should be transferred. Each host node that is attached to
* the Responder System has an ID, which is used to look up its associated
* callbacks.
*/
function eventListener(domEvent: any) {
const eventType = domEvent.type;
const eventTarget = domEvent.target;
/**
* Manage emulated events and early bailout.
* Since PointerEvent is not used yet (lack of support in older Safari), it's
* necessary to manually manage the mess of browser touch/mouse events.
* And bailout early for termination events when there is no active responder.
*/
// Flag when browser may produce emulated events
if (eventType === 'touchstart') {
isEmulatingMouseEvents = true;
}
// Remove flag when browser will not produce emulated events
if (eventType === 'touchmove' || trackedTouchCount > 1) {
isEmulatingMouseEvents = false;
}
// Ignore various events in particular circumstances
if (
// Ignore browser emulated mouse events
(eventType === 'mousedown' && isEmulatingMouseEvents) ||
(eventType === 'mousemove' && isEmulatingMouseEvents) ||
// Ignore mousemove if a mousedown didn't occur first
(eventType === 'mousemove' && trackedTouchCount < 1)
) {
return;
}
// Remove flag after emulated events are finished
if (isEmulatingMouseEvents && eventType === 'mouseup') {
if (trackedTouchCount === 0) {
isEmulatingMouseEvents = false;
}
return;
}
const isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent);
const isMoveEvent = isMoveish(eventType);
const isEndEvent = isEndish(eventType);
const isScrollEvent = isScroll(eventType);
const isSelectionChangeEvent = isSelectionChange(eventType);
const responderEvent = createResponderEvent(domEvent);
/**
* Record the state of active pointers
*/
if (isStartEvent || isMoveEvent || isEndEvent) {
if (domEvent.touches) {
trackedTouchCount = domEvent.touches.length;
} else {
if (isStartEvent) {
trackedTouchCount = 1;
} else if (isEndEvent) {
trackedTouchCount = 0;
}
}
ResponderTouchHistoryStore.recordTouchTrack(eventType, responderEvent.nativeEvent);
}
/**
* Responder System logic
*/
let eventPaths = getResponderPaths(domEvent);
let wasNegotiated = false;
let wantsResponder;
// If an event occured that might change the current responder...
if (isStartEvent || isMoveEvent || (isScrollEvent && trackedTouchCount > 0)) {
// If there is already a responder, prune the event paths to the lowest common ancestor
// of the existing responder and deepest target of the event.
const currentResponderIdPath = currentResponder.idPath;
const eventIdPath = eventPaths.idPath;
if (currentResponderIdPath != null && eventIdPath != null) {
const lowestCommonAncestor = getLowestCommonAncestor(currentResponderIdPath, eventIdPath);
if (lowestCommonAncestor != null) {
const indexOfLowestCommonAncestor = eventIdPath.indexOf(lowestCommonAncestor);
// Skip the current responder so it doesn't receive unexpected "shouldSet" events.
const index =
indexOfLowestCommonAncestor + (lowestCommonAncestor === currentResponder.id ? 1 : 0);
eventPaths = {
idPath: eventIdPath.slice(index),
nodePath: eventPaths.nodePath.slice(index)
};
} else {
eventPaths = null;
}
}
if (eventPaths != null) {
// If a node wants to become the responder, attempt to transfer.
wantsResponder = findWantsResponder(eventPaths, domEvent, responderEvent);
if (wantsResponder != null) {
// Sets responder if none exists, or negotates with existing responder.
attemptTransfer(responderEvent, wantsResponder);
wasNegotiated = true;
}
}
}
// If there is now a responder, invoke its callbacks for the lifecycle of the gesture.
if (currentResponder.id != null && currentResponder.node != null) {
const { id, node } = currentResponder;
const {
onResponderStart,
onResponderMove,
onResponderEnd,
onResponderRelease,
onResponderTerminate,
onResponderTerminationRequest
} = getResponderConfig(id);
responderEvent.bubbles = false;
responderEvent.cancelable = false;
responderEvent.currentTarget = node;
// Start
if (isStartEvent) {
if (onResponderStart != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderStart';
onResponderStart(responderEvent);
}
}
// Move
else if (isMoveEvent) {
if (onResponderMove != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderMove';
onResponderMove(responderEvent);
}
} else {
const isTerminateEvent =
isCancelish(eventType) ||
// native context menu
eventType === 'contextmenu' ||
// window blur
(eventType === 'blur' && eventTarget === window) ||
// responder (or ancestors) blur
(eventType === 'blur' && eventTarget.contains(node) && domEvent.relatedTarget !== node) ||
// native scroll without using a pointer
(isScrollEvent && trackedTouchCount === 0) ||
// native scroll on node that is parent of the responder (allow siblings to scroll)
(isScrollEvent && eventTarget.contains(node) && eventTarget !== node) ||
// native select/selectionchange on node
(isSelectionChangeEvent && hasValidSelection(domEvent));
const isReleaseEvent =
isEndEvent && !isTerminateEvent && !hasTargetTouches(node, domEvent.touches);
// End
if (isEndEvent) {
if (onResponderEnd != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderEnd';
onResponderEnd(responderEvent);
}
}
// Release
if (isReleaseEvent) {
if (onResponderRelease != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderRelease';
onResponderRelease(responderEvent);
}
changeCurrentResponder(emptyResponder);
}
// Terminate
if (isTerminateEvent) {
let shouldTerminate = true;
// Responders can still avoid termination but only for these events.
if (
eventType === 'contextmenu' ||
eventType === 'scroll' ||
eventType === 'selectionchange'
) {
// Only call this function is it wasn't already called during negotiation.
if (wasNegotiated) {
shouldTerminate = false;
} else if (onResponderTerminationRequest != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
shouldTerminate = false;
}
}
}
if (shouldTerminate) {
if (onResponderTerminate != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent);
}
changeCurrentResponder(emptyResponder);
isEmulatingMouseEvents = false;
trackedTouchCount = 0;
}
}
}
}
}
/**
* Walk the event path to/from the target node. At each node, stop and call the
* relevant "shouldSet" functions for the given event type. If any of those functions
* call "stopPropagation" on the event, stop searching for a responder.
*/
function findWantsResponder(eventPaths, domEvent, responderEvent) {
const shouldSetCallbacks = shouldSetResponderEvents[(domEvent.type: any)]; // for Flow
if (shouldSetCallbacks != null) {
const { idPath, nodePath } = eventPaths;
const shouldSetCallbackCaptureName = shouldSetCallbacks[0];
const shouldSetCallbackBubbleName = shouldSetCallbacks[1];
const { bubbles } = shouldSetCallbacks[2];
const check = function (id, node, callbackName) {
const config = getResponderConfig(id);
const shouldSetCallback = config[callbackName];
if (shouldSetCallback != null) {
responderEvent.currentTarget = node;
if (shouldSetCallback(responderEvent) === true) {
// Start the path from the potential responder
const prunedIdPath = idPath.slice(idPath.indexOf(id));
return { id, node, idPath: prunedIdPath };
}
}
};
// capture
for (let i = idPath.length - 1; i >= 0; i--) {
const id = idPath[i];
const node = nodePath[i];
const result = check(id, node, shouldSetCallbackCaptureName);
if (result != null) {
return result;
}
if (responderEvent.isPropagationStopped() === true) {
return;
}
}
// bubble
if (bubbles) {
for (let i = 0; i < idPath.length; i++) {
const id = idPath[i];
const node = nodePath[i];
const result = check(id, node, shouldSetCallbackBubbleName);
if (result != null) {
return result;
}
if (responderEvent.isPropagationStopped() === true) {
return;
}
}
} else {
const id = idPath[0];
const node = nodePath[0];
const target = domEvent.target;
if (target === node) {
return check(id, node, shouldSetCallbackBubbleName);
}
}
}
}
/**
* Attempt to transfer the responder.
*/
function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveResponderInstance) {
const { id: currentId, node: currentNode } = currentResponder;
const { id, node } = wantsResponder;
const { onResponderGrant, onResponderReject } = getResponderConfig(id);
responderEvent.bubbles = false;
responderEvent.cancelable = false;
responderEvent.currentTarget = node;
// Set responder
if (currentId == null) {
if (onResponderGrant != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent);
}
changeCurrentResponder(wantsResponder);
}
// Negotiate with current responder
else {
const { onResponderTerminate, onResponderTerminationRequest } = getResponderConfig(currentId);
let allowTransfer = true;
if (onResponderTerminationRequest != null) {
responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
allowTransfer = false;
}
}
if (allowTransfer) {
// Terminate existing responder
if (onResponderTerminate != null) {
responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent);
}
// Grant next responder
if (onResponderGrant != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent);
}
changeCurrentResponder(wantsResponder);
} else {
// Reject responder request
if (onResponderReject != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderReject';
onResponderReject(responderEvent);
}
}
}
}
/* ------------ PUBLIC API ------------ */
/**
* Attach Listeners
*
* Use native events as ReactDOM doesn't have a non-plugin API to implement
* this system.
*/
const documentEventsCapturePhase = ['blur', 'scroll'];
const documentEventsBubblePhase = [
// mouse
'mousedown',
'mousemove',
'mouseup',
'dragstart',
// touch
'touchstart',
'touchmove',
'touchend',
'touchcancel',
// other
'contextmenu',
'select',
'selectionchange'
];
export function attachListeners() {
if (canUseDOM && window.__reactResponderSystemActive == null) {
window.addEventListener('blur', eventListener);
documentEventsBubblePhase.forEach((eventType) => {
document.addEventListener(eventType, eventListener);
});
documentEventsCapturePhase.forEach((eventType) => {
document.addEventListener(eventType, eventListener, true);
});
window.__reactResponderSystemActive = true;
}
}
/**
* Register a node with the ResponderSystem.
*/
export function addNode(id: ResponderId, node: any, config: ResponderConfig) {
setResponderId(node, id);
responderListenersMap.set(id, config);
}
/**
* Unregister a node with the ResponderSystem.
*/
export function removeNode(id: ResponderId) {
if (currentResponder.id === id) {
terminateResponder();
}
if (responderListenersMap.has(id)) {
responderListenersMap.delete(id);
}
}
/**
* Allow the current responder to be terminated from within components to support
* more complex requirements, such as use with other React libraries for working
* with scroll views, input views, etc.
*/
export function terminateResponder() {
const { id, node } = currentResponder;
if (id != null && node != null) {
const { onResponderTerminate } = getResponderConfig(id);
if (onResponderTerminate != null) {
const event = createResponderEvent({});
event.currentTarget = node;
onResponderTerminate(event);
}
changeCurrentResponder(emptyResponder);
}
isEmulatingMouseEvents = false;
trackedTouchCount = 0;
}
/**
* Allow unit tests to inspect the current responder in the system.
* FOR TESTING ONLY.
*/
export function getResponderNode(): any {
return currentResponder.node;
}