Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions e2e/tests/issues/issue-1202-blank-page.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { freshAppBeforeEach, expect, test } from '../support/test.ts';
import type { Screen } from '../support/test.ts';
import { openExample } from '../support/app.ts';

freshAppBeforeEach();

/** Focuses the input on `page`, types into it, then pages with Go `next`. */
async function typeThenGo(
screen: Screen,
page: number,
next: number
): Promise<void> {
const input = screen.getByTestId(`issue-1202-input-${page}`);
await input.tap();
await input.pressSequentially(`${page}${page}${page}${page}`);
await screen.getByTestId(`issue-1202-go-${next}`).tap();
await expect(screen.getByTestId(`issue-1202-label-${next}`)).toBeVisible();
}

test(
'a page shown by setPage is not blank',
// iOS-only: the blank page comes from SwiftUI hosting a page twice.
{ tags: ['regression', 'issue-1202'], platforms: ['ios'] },
async ({ app, screen }) => {
await openExample(app, screen, 'Issue #1202 Blank Page Repro');

await typeThenGo(screen, 0, 1);
await typeThenGo(screen, 1, 2);
await typeThenGo(screen, 2, 1);
await typeThenGo(screen, 1, 0);
await typeThenGo(screen, 0, 1);

// Before the fix page 2 was selected here but showed nothing.
await screen.getByTestId('issue-1202-go-2').tap();
await expect(screen.getByTestId('issue-1202-status')).toHaveText(
/^Selected: 2 \|/
);
await expect(screen.getByTestId('issue-1202-label-2')).toBeVisible();
}
);
5 changes: 5 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from './gh-issues/Issue1142SearchBarInsetRepro';
import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro';
import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro';
import { Issue1202BlankPageRepro } from './gh-issues/Issue1202BlankPageRepro';
import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro';
import { Issue965PagesBeforeCurrentRepro } from './gh-issues/Issue965PagesBeforeCurrentRepro';
import { colors, radius } from './theme';
Expand Down Expand Up @@ -188,6 +189,10 @@ const sections: { title: string; data: Example[] }[] = [
component: Issue1156NestedHorizontalScrollRepro,
name: 'Issue #1156 Nested Horizontal Scroll Repro',
},
{
component: Issue1202BlankPageRepro,
name: 'Issue #1202 Blank Page Repro',
},
{
component: Issue424SwipeCancelsPressRepro,
name: 'Issue #424 Swipe Cancels Press Repro',
Expand Down
142 changes: 142 additions & 0 deletions example/src/gh-issues/Issue1202BlankPageRepro.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Repro for #1202: on iOS, a page shown by setPage must not be blank. The
* event log above the pager is cleared by each Go button and grows on every
* pager, focus and keyboard event, so the pager is laid out again around each
* page change. Focusing and typing on several pages and then paging with the
* Go buttons used to leave the selected page empty.
*/
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Keyboard,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import PagerView from 'react-native-pager-view';

const PAGES = ['Page 0', 'Page 1', 'Page 2'];
const PAGE_COLORS = ['#fde2e2', '#e2fde6', '#e2e8fd'];

export function Issue1202BlankPageRepro() {
const pagerRef = useRef<PagerView>(null);
const [selectedPage, setSelectedPage] = useState(0);
const [focusedInput, setFocusedInput] = useState<number | null>(null);
const [events, setEvents] = useState<string[]>([]);
const log = (event: string) =>
setEvents((current) => [...current.slice(-11), event]);

useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => {
log('keyboardShow');
});
const hide = Keyboard.addListener('keyboardDidHide', () => {
log('keyboardHide');
});
return () => {
show.remove();
hide.remove();
};
}, []);

return (
<View style={styles.screen}>
<Text testID="issue-1202-status" style={styles.status}>
Selected: {selectedPage} | Focused:{' '}
{focusedInput === null ? 'none' : `input ${focusedInput}`}
</Text>
<Text testID="issue-1202-events" style={styles.events}>
Events: {events.join(', ')}
</Text>
<View style={styles.buttons}>
{PAGES.map((label, i) => (
<Button
key={label}
testID={`issue-1202-go-${i}`}
title={`Go ${i}`}
onPress={() => {
setEvents([]);
pagerRef.current?.setPage(i);
}}
/>
))}
</View>
<PagerView
ref={pagerRef}
style={styles.pager}
onPageSelected={(e) => {
setSelectedPage(e.nativeEvent.position);
log(`selected ${e.nativeEvent.position}`);
}}
onPageScrollStateChanged={(e) => {
if (e.nativeEvent.pageScrollState === 'idle') {
log('state idle');
}
}}
>
{PAGES.map((label, i) => (
<View
key={label}
style={[styles.page, { backgroundColor: PAGE_COLORS[i] }]}
>
<Text testID={`issue-1202-label-${i}`} style={styles.pageLabel}>
{label}
</Text>
<TextInput
testID={`issue-1202-input-${i}`}
placeholder={`Input on ${label}`}
style={styles.input}
onFocus={() => {
setFocusedInput(i);
log(`focus ${i}`);
}}
onBlur={() => {
setFocusedInput((current) => (current === i ? null : current));
log(`blur ${i}`);
}}
/>
</View>
))}
</PagerView>
</View>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
},
status: {
padding: 12,
fontSize: 16,
},
events: {
paddingHorizontal: 12,
paddingBottom: 12,
fontSize: 12,
},
buttons: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingBottom: 12,
},
pager: {
flex: 1,
},
page: {
flex: 1,
padding: 24,
},
pageLabel: {
fontSize: 24,
fontWeight: '600',
marginBottom: 16,
},
input: {
borderWidth: 1,
borderColor: 'black',
padding: 8,
backgroundColor: 'white',
},
});
12 changes: 12 additions & 0 deletions ios/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ class PageChildViewController: UIViewController {
}
}

/// SwiftUI can keep more than one controller alive for the same page, while
/// the page has a single React Native view. The newest one takes the view in
/// `viewDidLoad`, and once it is gone SwiftUI can show an older one again,
/// with the view left out of the hierarchy. Only a view nothing holds is
/// taken back, so this never takes it from a newer controller.
func reclaimWrappedViewIfOrphaned() {
guard let wrappedView, wrappedView.superview == nil else {
return
}
view.addSubview(wrappedView)
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
propagateSafeArea()
Expand Down
13 changes: 13 additions & 0 deletions ios/PagerScrollDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
originalDelegate?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath)
Self.pageController(in: cell.contentView)?.reclaimWrappedViewIfOrphaned()
}

private static func pageController(in view: UIView) -> PageChildViewController? {
if let controller = view.next as? PageChildViewController {
return controller
}
for subview in view.subviews {
if let controller = pageController(in: subview) {
return controller
}
}
return nil
}

override func responds(to aSelector: Selector!) -> Bool {
Expand Down