Open Merynek opened 3 years ago
I ran into the same issue. After some digging the problem is probably with dissapearing (moved to portal/clone) draggable item after drag start. First touch on drag start scrolls the whole page/list, second touch on draggable item drag the item correctly.
The same problem is on the following stories:
We can simulate this simply on Windows Chrome device emulation, or by opening stories on Android Chrome (iPhone has the same issue).
I tried old versions and it works in version: "12.0.0-beta.10" From version "12.0.0-beta.11" it not works so you can look at commits in this version.
I checked the version and in 12.0.0-beta.11 was rewritten code for detection of dragging element. It makes sense to me that the new code for detection reopens the fix in 12.0.0-alpha.7 😔 So we're stucked at beta.10
I can confirm this behaviour works on 12.0.0-beta.10 and not anything above! Thanks @Merynek, I didn't know how else to fix this problem
Here are the diffs between the versions: https://github.com/atlassian/react-beautiful-dnd/compare/v12.0.0-beta.10...v12.0.0-beta.11
Did you find a solution or hack for this bug?
same issue here! is the current solution to stick with 12.0.0-beta.10
?
thanks!
Still having the same issue. Any update would be great! :)
@nmauersberg, @maxsupera, and anyone else still looking for a fix, years later:
I fixed the issue by cloning the default touch sensor and adding back in the logic they took out with the 12.0.0-beta.11 change. Having both sets of logic side by side should make all scenarios work:
Steps:
Create a file in your repo, called use-touch-sensor.tsx
or whatever you want to name it, then paste in the following code:
(note this code is 99.99% the original use-touch-sensor.js code from this repo, with some slight modifications)
import * as React from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import { DraggableId, FluidDragActions, PreDragActions, SensorAPI } from 'react-beautiful-dnd';
type TouchWithForce = Touch & {
force: number;
};
type Idle = {
type: 'IDLE';
};
type Pending = {
type: 'PENDING';
point: Position;
actions: PreDragActions;
longPressTimerId: NodeJS.Timeout;
};
type Dragging = {
type: 'DRAGGING';
actions: FluidDragActions;
hasMoved: boolean;
};
type Phase = Idle | Pending | Dragging;
type PredicateFn<T> = (value: T) => boolean;
function findIndex<T>(list: T[], predicate: PredicateFn<T>): number {
if (list.findIndex) {
return list.findIndex(predicate);
}
// Using a for loop so that we can exit early
for (let i = 0; i < list.length; i++) {
if (predicate(list[i])) {
return i;
}
}
// Array.prototype.find returns -1 when nothing is found
return -1;
}
function find<T>(list: T[], predicate: PredicateFn<T>): T {
if (list.find) {
return list.find(predicate);
}
const index: number = findIndex(list, predicate);
if (index !== -1) {
return list[index];
}
// Array.prototype.find returns undefined when nothing is found
return undefined;
}
const supportedPageVisibilityEventName: string = ((): string => {
const base: string = 'visibilitychange';
// Server side rendering
if (typeof document === 'undefined') {
return base;
}
// See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
const candidates: string[] = [base, `ms${base}`, `webkit${base}`, `moz${base}`, `o${base}`];
const supported: string = find(
candidates,
(eventName: string): boolean => `on${eventName}` in document
);
return supported || base;
})();
const idle: Idle = { type: 'IDLE' };
// Decreased from 150 as a work around for an issue for forcepress on iOS
// https://github.com/atlassian/react-beautiful-dnd/issues/1401
export const timeForLongPress: number = 120;
export const forcePressThreshold: number = 0.15;
type GetBindingArgs = {
cancel: () => void;
completed: () => void;
getPhase: () => Phase;
};
function getWindowBindings({ cancel, getPhase }: GetBindingArgs): EventBinding[] {
return [
// If the orientation of the device changes - kill the drag
// https://davidwalsh.name/orientation-change
{
eventName: 'orientationchange' as keyof HTMLElementEventMap,
fn: cancel,
},
// some devices fire resize if the orientation changes
{
eventName: 'resize',
fn: cancel,
},
// Long press can bring up a context menu
// need to opt out of this behavior
{
eventName: 'contextmenu',
fn: (event: Event) => {
// always opting out of context menu events
event.preventDefault();
},
},
// On some devices it is possible to have a touch interface with a keyboard.
// On any keyboard event we cancel a touch drag
{
eventName: 'keydown',
fn: (event: KeyboardEvent) => {
if (getPhase().type !== 'DRAGGING') {
cancel();
return;
}
// direct cancel: we are preventing the default action
// indirect cancel: we are not preventing the default action
// escape is a direct cancel
if (event.key === 'Escape') {
event.preventDefault();
}
cancel();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
fn: cancel,
},
];
}
// All of the touch events get applied to the drag handle of the touch interaction
// This plays well with the event.target being unmounted during a drag
function getHandleBindings({ cancel, completed, getPhase }: GetBindingArgs): EventBinding[] {
return [
{
eventName: 'touchmove',
// Opting out of passive touchmove (default) so as to prevent scrolling while moving
// Not worried about performance as effect of move is throttled in requestAnimationFrame
// Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393
options: { capture: false },
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// Drag has not yet started and we are waiting for a long press.
if (phase.type !== 'DRAGGING') {
cancel();
return;
}
// At this point we are dragging
phase.hasMoved = true;
const { clientX, clientY } = event.touches[0];
const point: Position = {
x: clientX,
y: clientY,
};
// We need to prevent the default event in order to block native scrolling
// Also because we are using it as part of a drag we prevent the default action
// as a sign that we are using the event
event.preventDefault();
phase.actions.move(point);
},
},
{
eventName: 'touchend',
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// drag had not started yet - do not prevent the default action
if (phase.type !== 'DRAGGING') {
cancel();
return;
}
// ending the drag
event.preventDefault();
phase.actions.drop({ shouldBlockNextClick: true });
completed();
},
},
{
eventName: 'touchcancel',
fn: (event: TouchEvent) => {
// drag had not started yet - do not prevent the default action
if (getPhase().type !== 'DRAGGING') {
cancel();
return;
}
// already dragging - this event is directly ending a drag
event.preventDefault();
cancel();
},
},
// Need to opt out of dragging if the user is a force press
// Only for webkit which has decided to introduce its own custom way of doing things
// https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
{
eventName: 'touchforcechange' as keyof HTMLElementEventMap,
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// needed to use phase.actions
if (phase.type === 'IDLE') {
throw Error('invariant');
}
// This is not fantastic logic, but it is done to account for
// and issue with forcepress on iOS
// Calling event.preventDefault() will currently opt out of scrolling and clicking
// https://github.com/atlassian/react-beautiful-dnd/issues/1401
const touch: TouchWithForce = event.touches[0] as TouchWithForce;
if (!touch) {
return;
}
const isForcePress: boolean = touch.force >= forcePressThreshold;
if (!isForcePress) {
return;
}
const shouldRespect: boolean = phase.actions.shouldRespectForcePress();
if (phase.type === 'PENDING') {
if (shouldRespect) {
cancel();
}
// If not respecting we just let the event go through
// It will not have an impact on the browser until
// there has been a sufficient time ellapsed
return;
}
// 'DRAGGING'
if (shouldRespect) {
if (phase.hasMoved) {
// After the user has moved we do not allow the dragging item to be force pressed
// This prevents strange behaviour such as a link preview opening mid drag
event.preventDefault();
return;
}
// indirect cancel
cancel();
return;
}
// not respecting during a drag
event.preventDefault();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
fn: cancel,
},
// Not adding a cancel on touchstart as this handler will pick up the initial touchstart event
];
}
type EventOptions = {
passive?: boolean;
capture?: boolean;
// sometimes an event might only event want to be bound once
once?: boolean;
};
type EventBinding = {
eventName: keyof HTMLElementEventMap;
fn: EventListenerOrEventListenerObject;
options?: EventOptions;
};
function getOptions(shared: EventOptions, fromBinding: EventOptions): EventOptions {
return {
...shared,
...fromBinding,
};
}
type UnbindFn = () => void;
function bindEvents(
el: HTMLElement | Window,
bindings: EventBinding[],
sharedOptions?: EventOptions
) {
const unbindings: UnbindFn[] = bindings.map(
(binding: EventBinding): UnbindFn => {
const options: Object = getOptions(sharedOptions, binding.options);
el.addEventListener(binding.eventName, binding.fn, options);
return function unbind() {
el.removeEventListener(binding.eventName, binding.fn, options);
};
}
);
// Return a function to unbind events
return function unbindAll() {
unbindings.forEach((unbind: UnbindFn) => {
unbind();
});
};
}
export default function useTouchSensor(api: SensorAPI) {
const phaseRef = React.useRef<Phase>(idle);
const unbindEventsRef = React.useRef<() => void>(() => null);
const getPhase = useCallback(function getPhase(): Phase {
return phaseRef.current;
}, []);
const setPhase = useCallback(function setPhase(phase: Phase) {
phaseRef.current = phase;
}, []);
const startCaptureBinding = useMemo(
() => ({
eventName: 'touchstart' as keyof HTMLElementEventMap,
fn: function onTouchStart(event: TouchEvent) {
// Event already used by something else
if (event.defaultPrevented) {
return;
}
// We need to NOT call event.preventDefault() so as to maintain as much standard
// browser interactions as possible.
// This includes navigation on anchors which we want to preserve
const draggableId: DraggableId = api.findClosestDraggableId(event);
if (!draggableId) {
return;
}
const actions: PreDragActions = api.tryGetLock(
draggableId,
// eslint-disable-next-line no-use-before-define
stop,
{ sourceEvent: event }
);
// could not start a drag
if (!actions) {
return;
}
const touch: Touch = event.touches[0];
const { clientX, clientY } = touch;
const point: Position = {
x: clientX,
y: clientY,
};
const dragHandleId = api.findClosestDraggableId(event);
if (!dragHandleId) {
throw Error('Touch sensor unable to find drag dragHandleId');
}
const handle: HTMLElement = document.querySelector(
`[data-rbd-drag-handle-draggable-id='${dragHandleId}']`
);
if (!handle) {
throw Error('Touch sensor unable to find drag handle');
}
// unbind this event handler
unbindEventsRef.current();
// eslint-disable-next-line no-use-before-define
startPendingDrag(actions, point, handle);
},
}),
// not including stop or startPendingDrag as it is not defined initially
[api]
);
const listenForCapture = useCallback(
function listenForCapture() {
const options = {
capture: true,
passive: false,
};
unbindEventsRef.current = bindEvents(window, [startCaptureBinding], options);
},
[startCaptureBinding]
);
const stop = useCallback(() => {
const { current } = phaseRef;
if (current.type === 'IDLE') {
return;
}
// aborting any pending drag
if (current.type === 'PENDING') {
clearTimeout(current.longPressTimerId);
}
setPhase(idle);
unbindEventsRef.current();
listenForCapture();
}, [listenForCapture, setPhase]);
const cancel = useCallback(() => {
const phase: Phase = phaseRef.current;
stop();
if (phase.type === 'DRAGGING') {
phase.actions.cancel({ shouldBlockNextClick: true });
}
if (phase.type === 'PENDING') {
phase.actions.abort();
}
}, [stop]);
const bindCapturingEvents = useCallback(
function bindCapturingEvents(target: HTMLElement) {
const options = { capture: true, passive: false };
const args: GetBindingArgs = {
cancel,
completed: stop,
getPhase,
};
// In prior versions of iOS it was required that touch listeners be added
// to the handle to work correctly (even if the handle got removed in a portal / clone)
// In the latest version it appears to be the opposite: for reparenting to work
// the events need to be attached to the window.
// For now i'll keep these two functions seperate in case we need to swap it back again
// Old behaviour:
// https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
const unbindTarget = bindEvents(target, getHandleBindings(args), options);
const unbindTargetWindow = bindEvents(window, getHandleBindings(args), options);
const unbindWindow = bindEvents(window, getWindowBindings(args), options);
unbindEventsRef.current = function unbindAll() {
unbindTarget();
unbindTargetWindow();
unbindWindow();
};
},
[cancel, getPhase, stop]
);
const startDragging = useCallback(
function startDragging() {
const phase: Phase = getPhase();
if (phase.type !== 'PENDING') {
throw Error(`Cannot start dragging from phase ${phase.type}`);
}
const actions: FluidDragActions = phase.actions.fluidLift(phase.point);
setPhase({
type: 'DRAGGING',
actions,
hasMoved: false,
});
},
[getPhase, setPhase]
);
const startPendingDrag = useCallback(
function startPendingDrag(actions: PreDragActions, point: Position, target: HTMLElement) {
if (getPhase().type !== 'IDLE') {
throw Error('Expected to move from IDLE to PENDING drag');
}
const longPressTimerId = setTimeout(startDragging, timeForLongPress);
setPhase({
type: 'PENDING',
point,
actions,
longPressTimerId,
});
bindCapturingEvents(target);
},
[bindCapturingEvents, getPhase, setPhase, startDragging]
);
React.useLayoutEffect(
function mount() {
listenForCapture();
return function unmount() {
// remove any existing listeners
unbindEventsRef.current();
// need to kill any pending drag start timer
const phase: Phase = getPhase();
if (phase.type === 'PENDING') {
clearTimeout(phase.longPressTimerId);
setPhase(idle);
}
};
},
[getPhase, listenForCapture, setPhase]
);
// This is needed for safari
// Simply adding a non capture, non passive 'touchmove' listener.
// This forces event.preventDefault() in dynamically added
// touchmove event handlers to actually work
// https://github.com/atlassian/react-beautiful-dnd/issues/1374
React.useLayoutEffect(function webkitHack() {
const unbind = bindEvents(window, [
{
eventName: 'touchmove',
// using a new noop function for each usage as a single `removeEventListener()`
// call will remove all handlers with the same reference
// https://codesandbox.io/s/removing-multiple-handlers-with-same-reference-fxe15
fn: () => {},
options: { capture: false, passive: false },
},
]);
return unbind;
}, []);
}
Then import it into wherever your DragDropContext lives and use it like so:
import {
DragDropContext,
useKeyboardSensor,
useMouseSensor,
} from 'react-beautiful-dnd';
import useTouchSensor from './custom-sensors/use-touch-sensor';
...
return (
<DragDropContext
enableDefaultSensors={false}
sensors={[useMouseSensor, useKeyboardSensor, useTouchSensor]}
....
</DragDropContext>
);
....
@stevenmcountryman You save my day :) really appreciated.
And there were some kinda type errors so I fixed a little bit.
import * as React from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import {
DraggableId,
FluidDragActions,
PreDragActions,
SensorAPI,
} from 'react-beautiful-dnd';
type TouchWithForce = Touch & {
force: number;
};
type Idle = {
type: 'IDLE';
};
type Pending = {
type: 'PENDING';
point: Position;
actions: PreDragActions;
longPressTimerId: NodeJS.Timeout;
};
type Dragging = {
type: 'DRAGGING';
actions: FluidDragActions;
hasMoved: boolean;
};
type Phase = Idle | Pending | Dragging;
type PredicateFn<T> = (value: T) => boolean;
function findIndex<T>(list: T[], predicate: PredicateFn<T>): number {
if (list.findIndex) {
return list.findIndex(predicate);
}
// Using a for loop so that we can exit early
for (let i = 0; i < list.length; i++) {
if (predicate(list[i])) {
return i;
}
}
// Array.prototype.find returns -1 when nothing is found
return -1;
}
function find<T>(list: T[], predicate: PredicateFn<T>): T | undefined {
if (list.find) {
return list.find(predicate);
}
const index: number = findIndex(list, predicate);
if (index !== -1) {
return list[index];
}
// Array.prototype.find returns undefined when nothing is found
return undefined;
}
const supportedPageVisibilityEventName: string = ((): string => {
const base: string = 'visibilitychange';
// Server side rendering
if (typeof document === 'undefined') {
return base;
}
// See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
const candidates: string[] = [
base,
`ms${base}`,
`webkit${base}`,
`moz${base}`,
`o${base}`,
];
const supported: string | undefined = find(
candidates,
(eventName: string): boolean => `on${eventName}` in document,
);
return supported || base;
})();
const idle: Idle = { type: 'IDLE' };
// Decreased from 150 as a work around for an issue for forcepress on iOS
// https://github.com/atlassian/react-beautiful-dnd/issues/1401
export const timeForLongPress: number = 120;
export const forcePressThreshold: number = 0.15;
type GetBindingArgs = {
cancel: () => void;
completed: () => void;
getPhase: () => Phase;
};
function getWindowBindings({
cancel,
getPhase,
}: GetBindingArgs): EventBinding[] {
return [
// If the orientation of the device changes - kill the drag
// https://davidwalsh.name/orientation-change
{
eventName: 'orientationchange' as keyof HTMLElementEventMap,
fn: cancel,
},
// some devices fire resize if the orientation changes
{
eventName: 'resize',
fn: cancel,
},
// Long press can bring up a context menu
// need to opt out of this behavior
{
eventName: 'contextmenu',
fn: (event: Event) => {
// always opting out of context menu events
event.preventDefault();
},
},
// On some devices it is possible to have a touch interface with a keyboard.
// On any keyboard event we cancel a touch drag
{
eventName: 'keydown',
fn: (event: KeyboardEvent) => {
if (getPhase().type !== 'DRAGGING') {
cancel();
return;
}
// direct cancel: we are preventing the default action
// indirect cancel: we are not preventing the default action
// escape is a direct cancel
if (event.key === 'Escape') {
event.preventDefault();
}
cancel();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
fn: cancel,
},
];
}
// All of the touch events get applied to the drag handle of the touch interaction
// This plays well with the event.target being unmounted during a drag
function getHandleBindings({
cancel,
completed,
getPhase,
}: GetBindingArgs): EventBinding[] {
return [
{
eventName: 'touchmove',
// Opting out of passive touchmove (default) so as to prevent scrolling while moving
// Not worried about performance as effect of move is throttled in requestAnimationFrame
// Using `capture: false` due to a recent horrible firefox bug: https://twitter.com/alexandereardon/status/1125904207184187393
options: { capture: false },
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// Drag has not yet started and we are waiting for a long press.
if (phase.type !== 'DRAGGING') {
cancel();
return;
}
// At this point we are dragging
phase.hasMoved = true;
const { clientX, clientY } = event.touches[0];
const point: Position = {
x: clientX,
y: clientY,
};
// We need to prevent the default event in order to block native scrolling
// Also because we are using it as part of a drag we prevent the default action
// as a sign that we are using the event
event.preventDefault();
phase.actions.move(point);
},
},
{
eventName: 'touchend',
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// drag had not started yet - do not prevent the default action
if (phase.type !== 'DRAGGING') {
cancel();
return;
}
// ending the drag
event.preventDefault();
phase.actions.drop({ shouldBlockNextClick: true });
completed();
},
},
{
eventName: 'touchcancel',
fn: (event: TouchEvent) => {
// drag had not started yet - do not prevent the default action
if (getPhase().type !== 'DRAGGING') {
cancel();
return;
}
// already dragging - this event is directly ending a drag
event.preventDefault();
cancel();
},
},
// Need to opt out of dragging if the user is a force press
// Only for webkit which has decided to introduce its own custom way of doing things
// https://developer.apple.com/library/content/documentation/AppleApplications/Conceptual/SafariJSProgTopics/RespondingtoForceTouchEventsfromJavaScript.html
{
eventName: 'touchforcechange' as keyof HTMLElementEventMap,
fn: (event: TouchEvent) => {
const phase: Phase = getPhase();
// needed to use phase.actions
if (phase.type === 'IDLE') {
throw Error('invariant');
}
// This is not fantastic logic, but it is done to account for
// and issue with forcepress on iOS
// Calling event.preventDefault() will currently opt out of scrolling and clicking
// https://github.com/atlassian/react-beautiful-dnd/issues/1401
const touch: TouchWithForce = event.touches[0] as TouchWithForce;
if (!touch) {
return;
}
const isForcePress: boolean = touch.force >= forcePressThreshold;
if (!isForcePress) {
return;
}
const shouldRespect: boolean = phase.actions.shouldRespectForcePress();
if (phase.type === 'PENDING') {
if (shouldRespect) {
cancel();
}
// If not respecting we just let the event go through
// It will not have an impact on the browser until
// there has been a sufficient time ellapsed
return;
}
// 'DRAGGING'
if (shouldRespect) {
if (phase.hasMoved) {
// After the user has moved we do not allow the dragging item to be force pressed
// This prevents strange behaviour such as a link preview opening mid drag
event.preventDefault();
return;
}
// indirect cancel
cancel();
return;
}
// not respecting during a drag
event.preventDefault();
},
},
// Cancel on page visibility change
{
eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
fn: cancel,
},
// Not adding a cancel on touchstart as this handler will pick up the initial touchstart event
];
}
type EventOptions = {
passive?: boolean;
capture?: boolean;
// sometimes an event might only event want to be bound once
once?: boolean;
};
type NewType = (
event: KeyboardEvent & TouchEvent & EventListenerOrEventListenerObject,
) => void;
type EventBinding = {
eventName: keyof HTMLElementEventMap;
fn: NewType;
options?: EventOptions;
};
function getOptions(
shared?: EventOptions,
fromBinding?: EventOptions,
): EventOptions {
return {
...shared,
...fromBinding,
};
}
type UnbindFn = () => void;
function bindEvents(
el: HTMLElement | Window,
bindings: EventBinding[],
sharedOptions?: EventOptions,
) {
const unbindings: UnbindFn[] = bindings.map(
(binding: EventBinding): UnbindFn => {
const options: Object = getOptions(sharedOptions, binding.options);
el.addEventListener(
binding.eventName,
binding.fn as EventListenerOrEventListenerObject,
options,
);
return function unbind() {
el.removeEventListener(
binding.eventName,
binding.fn as EventListenerOrEventListenerObject,
options,
);
};
},
);
// Return a function to unbind events
return function unbindAll() {
unbindings.forEach((unbind: UnbindFn) => {
unbind();
});
};
}
export default function useTouchSensor(api: SensorAPI) {
const phaseRef = React.useRef<Phase>(idle);
const unbindEventsRef = React.useRef<() => void>(() => null);
const getPhase = useCallback(function getPhase(): Phase {
return phaseRef.current;
}, []);
const setPhase = useCallback(function setPhase(phase: Phase) {
phaseRef.current = phase;
}, []);
const startCaptureBinding = useMemo(
() => ({
eventName: 'touchstart' as keyof HTMLElementEventMap,
fn: function onTouchStart(event: TouchEvent) {
// Event already used by something else
if (event.defaultPrevented) {
return;
}
// We need to NOT call event.preventDefault() so as to maintain as much standard
// browser interactions as possible.
// This includes navigation on anchors which we want to preserve
const draggableId: DraggableId | null =
api.findClosestDraggableId(event);
if (!draggableId) {
return;
}
const actions: PreDragActions | null = api.tryGetLock(
draggableId,
// eslint-disable-next-line no-use-before-define
stop,
{ sourceEvent: event },
);
// could not start a drag
if (!actions) {
return;
}
const touch: Touch = event.touches[0];
const { clientX, clientY } = touch;
const point: Position = {
x: clientX,
y: clientY,
};
const dragHandleId = api.findClosestDraggableId(event);
if (!dragHandleId) {
throw Error('Touch sensor unable to find drag dragHandleId');
}
const handle: HTMLElement | null = document.querySelector(
`[data-rbd-drag-handle-draggable-id='${dragHandleId}']`,
);
if (!handle) {
throw Error('Touch sensor unable to find drag handle');
}
// unbind this event handler
unbindEventsRef.current();
// eslint-disable-next-line no-use-before-define
startPendingDrag(actions, point, handle);
},
}),
// not including stop or startPendingDrag as it is not defined initially
[api],
);
const listenForCapture = useCallback(
function listenForCapture() {
const options = {
capture: true,
passive: false,
};
unbindEventsRef.current = bindEvents(
window,
[startCaptureBinding],
options,
);
},
[startCaptureBinding],
);
const stop = useCallback(() => {
const { current } = phaseRef;
if (current.type === 'IDLE') {
return;
}
// aborting any pending drag
if (current.type === 'PENDING') {
clearTimeout(current.longPressTimerId);
}
setPhase(idle);
unbindEventsRef.current();
listenForCapture();
}, [listenForCapture, setPhase]);
const cancel = useCallback(() => {
const phase: Phase = phaseRef.current;
stop();
if (phase.type === 'DRAGGING') {
phase.actions.cancel({ shouldBlockNextClick: true });
}
if (phase.type === 'PENDING') {
phase.actions.abort();
}
}, [stop]);
const bindCapturingEvents = useCallback(
function bindCapturingEvents(target: HTMLElement) {
const options = { capture: true, passive: false };
const args: GetBindingArgs = {
cancel,
completed: stop,
getPhase,
};
// In prior versions of iOS it was required that touch listeners be added
// to the handle to work correctly (even if the handle got removed in a portal / clone)
// In the latest version it appears to be the opposite: for reparenting to work
// the events need to be attached to the window.
// For now i'll keep these two functions seperate in case we need to swap it back again
// Old behaviour:
// https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
const unbindTarget = bindEvents(target, getHandleBindings(args), options);
const unbindTargetWindow = bindEvents(
window,
getHandleBindings(args),
options,
);
const unbindWindow = bindEvents(window, getWindowBindings(args), options);
unbindEventsRef.current = function unbindAll() {
unbindTarget();
unbindTargetWindow();
unbindWindow();
};
},
[cancel, getPhase, stop],
);
const startDragging = useCallback(
function startDragging() {
const phase: Phase = getPhase();
if (phase.type !== 'PENDING') {
throw Error(`Cannot start dragging from phase ${phase.type}`);
}
const actions: FluidDragActions = phase.actions.fluidLift(phase.point);
setPhase({
type: 'DRAGGING',
actions,
hasMoved: false,
});
},
[getPhase, setPhase],
);
const startPendingDrag = useCallback(
function startPendingDrag(
actions: PreDragActions,
point: Position,
target: HTMLElement,
) {
if (getPhase().type !== 'IDLE') {
throw Error('Expected to move from IDLE to PENDING drag');
}
const longPressTimerId = setTimeout(startDragging, timeForLongPress);
setPhase({
type: 'PENDING',
point,
actions,
longPressTimerId,
});
bindCapturingEvents(target);
},
[bindCapturingEvents, getPhase, setPhase, startDragging],
);
React.useLayoutEffect(
function mount() {
listenForCapture();
return function unmount() {
// remove any existing listeners
unbindEventsRef.current();
// need to kill any pending drag start timer
const phase: Phase = getPhase();
if (phase.type === 'PENDING') {
clearTimeout(phase.longPressTimerId);
setPhase(idle);
}
};
},
[getPhase, listenForCapture, setPhase],
);
// This is needed for safari
// Simply adding a non capture, non passive 'touchmove' listener.
// This forces event.preventDefault() in dynamically added
// touchmove event handlers to actually work
// https://github.com/atlassian/react-beautiful-dnd/issues/1374
React.useLayoutEffect(function webkitHack() {
const unbind = bindEvents(window, [
{
eventName: 'touchmove',
// using a new noop function for each usage as a single `removeEventListener()`
// call will remove all handlers with the same reference
// https://codesandbox.io/s/removing-multiple-handlers-with-same-reference-fxe15
fn: () => {},
options: { capture: false, passive: false },
},
]);
return unbind;
}, []);
}
@stevenmcountryman
Why don't you make a PR for this solution?
I cant dragging with items on mobile when I use portals. You can check this on your own examples. I found out that you solved this in version 12.0.0-alpha.7 so mby its regression.
Relase: https://github.com/atlassian/react-beautiful-dnd/issues/1317
Issue: https://github.com/atlassian/react-beautiful-dnd/issues/582
Example: https://react-beautiful-dnd.netlify.app/iframe.html?id=portals--using-your-own-portal&viewMode=story