Skip to content

Commit 4a7d563

Browse files
authored
fix: Allow pinch zooming and text selection in modals on iOS (#8922)
1 parent a325610 commit 4a7d563

File tree

2 files changed

+39
-7
lines changed

2 files changed

+39
-7
lines changed

packages/@react-aria/overlays/src/usePreventScroll.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {chain, getScrollParent, isIOS, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
13+
import {chain, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
1414

1515
interface PreventScrollOptions {
1616
/** Whether the scroll lock is disabled. */
@@ -85,18 +85,35 @@ function preventScrollStandard() {
8585
// on the window.
8686
// 2. Set `overscroll-behavior: contain` on nested scrollable regions so they do not scroll the page when at
8787
// the top or bottom. Work around a bug where this does not work when the element does not actually overflow
88-
// by preventing default in a `touchmove` event.
88+
// by preventing default in a `touchmove` event. This is best effort: we can't prevent default when pinch
89+
// zooming or when an element contains text selection, which may allow scrolling in some cases.
8990
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
9091
// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents
9192
// Safari from scrolling the page. After a small delay, focus the real input and scroll it into view
9293
// ourselves, without scrolling the whole page.
9394
function preventScrollMobileSafari() {
9495
let scrollable: Element;
96+
let allowTouchMove = false;
9597
let onTouchStart = (e: TouchEvent) => {
9698
// Store the nearest scrollable parent element from the element that the user touched.
97-
scrollable = getScrollParent(e.target as Element, true);
98-
if (scrollable === document.documentElement && scrollable === document.body) {
99-
return;
99+
let target = e.target as Element;
100+
scrollable = isScrollable(target) ? target : getScrollParent(target, true);
101+
allowTouchMove = false;
102+
103+
// If the target is selected, don't preventDefault in touchmove to allow user to adjust selection.
104+
let selection = target.ownerDocument.defaultView!.getSelection();
105+
if (selection && !selection.isCollapsed && selection.containsNode(target, true)) {
106+
allowTouchMove = true;
107+
}
108+
109+
// If this is a focused input element with a selected range, allow user to drag the selection handles.
110+
if (
111+
'selectionStart' in target &&
112+
'selectionEnd' in target &&
113+
(target.selectionStart as number) < (target.selectionEnd as number) &&
114+
target.ownerDocument.activeElement === target
115+
) {
116+
allowTouchMove = true;
100117
}
101118
};
102119

@@ -114,6 +131,11 @@ function preventScrollMobileSafari() {
114131
document.head.prepend(style);
115132

116133
let onTouchMove = (e: TouchEvent) => {
134+
// Allow pinch-zooming.
135+
if (e.touches.length === 2 || allowTouchMove) {
136+
return;
137+
}
138+
117139
// Prevent scrolling the window.
118140
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
119141
e.preventDefault();

packages/@react-aria/utils/src/useViewportSize.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export function useViewportSize(): ViewportSize {
2828
useEffect(() => {
2929
// Use visualViewport api to track available height even on iOS virtual keyboard opening
3030
let onResize = () => {
31+
// Ignore updates when zoomed.
32+
if (visualViewport && visualViewport.scale > 1) {
33+
return;
34+
}
35+
3136
setSize(size => {
3237
let newSize = getViewportSize();
3338
if (newSize.width === size.width && newSize.height === size.height) {
@@ -41,6 +46,10 @@ export function useViewportSize(): ViewportSize {
4146
// We can anticipate this and resize early by handling the blur event and using the layout size.
4247
let frame: number;
4348
let onBlur = (e: FocusEvent) => {
49+
if (visualViewport && visualViewport.scale > 1) {
50+
return;
51+
}
52+
4453
if (willOpenKeyboard(e.target as Element)) {
4554
// Wait one frame to see if a new element gets focused.
4655
frame = requestAnimationFrame(() => {
@@ -81,7 +90,8 @@ export function useViewportSize(): ViewportSize {
8190

8291
function getViewportSize(): ViewportSize {
8392
return {
84-
width: (visualViewport && visualViewport?.width) || window.innerWidth,
85-
height: (visualViewport && visualViewport?.height) || window.innerHeight
93+
// Multiply by the visualViewport scale to get the "natural" size, unaffected by pinch zooming.
94+
width: visualViewport ? visualViewport.width * visualViewport.scale : window.innerWidth,
95+
height: visualViewport ? visualViewport.height * visualViewport.scale : window.innerHeight
8696
};
8797
}

0 commit comments

Comments
 (0)