diff --git a/client/src/mobile/components/MDayPager.test.tsx b/client/src/mobile/components/MDayPager.test.tsx index 1282e6f00..5b6df6bea 100644 --- a/client/src/mobile/components/MDayPager.test.tsx +++ b/client/src/mobile/components/MDayPager.test.tsx @@ -8,6 +8,7 @@ import { pagesFor } from './dayPagerPages'; vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[]; +const LONG = Array.from({ length: 10 }, (_, k) => ({ id: k + 1 })) as unknown as Day[]; function setup(selectedDayId: number | null, over: Partial[0]> = {}) { const onSelectDay = vi.fn(); @@ -59,6 +60,19 @@ describe('pagesFor', () => { ).toEqual([4, 5, 6]); }); + it('also draws the days around the page a swipe heads for, so a run of swipes never meets blank pages', () => { + const drawn = (focusDayId: number | null) => + pagesFor(LONG, 1, focusDayId) + .filter((p) => p.near) + .map((p) => p.dayId); + expect(drawn(6)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + // Still only the selection's own window where nothing is previewed or the preview is gone. + expect(drawn(null)).toEqual([1, 2, 3]); + expect(drawn(99)).toEqual([1, 2, 3]); + // The focus never changes which page is current. + expect(pagesFor(LONG, 1, 6).find((p) => p.active)?.dayId).toBe(1); + }); + it('keeps a page for a selection that points at no day, with the first day as the way back', () => { expect(pagesFor(DAYS, null).map((p) => [p.key, p.dayId, p.active])).toEqual([ ['none', null, true], @@ -129,6 +143,19 @@ describe('MDayPager', () => { expect(onPreview).toHaveBeenLastCalledWith(null); }); + it('draws the days ahead of a swipe while the strip still moves, before the selection lands', () => { + const { strip, onSelectDay } = setup(1); + expect(screen.queryByText('page 5')).not.toBeInTheDocument(); + + place(strip, 3, true); + fireEvent.scroll(strip); + + expect(onSelectDay).not.toHaveBeenCalled(); + expect(screen.getByText('page 1 live')).toBeInTheDocument(); + expect(screen.getByText('page 5')).toBeInTheDocument(); + expect(screen.queryByText('page 6')).not.toBeInTheDocument(); + }); + it('asks once, however many events report the same landing', () => { const { strip, onSelectDay } = setup(2); diff --git a/client/src/mobile/components/MDayPager.tsx b/client/src/mobile/components/MDayPager.tsx index 166ba6107..e1b8fea36 100644 --- a/client/src/mobile/components/MDayPager.tsx +++ b/client/src/mobile/components/MDayPager.tsx @@ -16,7 +16,10 @@ import { pagesFor, type PagerItem } from './dayPagerPages'; * the swipe is decided, not once the snap has settled, which on iOS comes * a good while later. The day only changes once the strip has come to rest on * another page. Then - * the selection moves and the days around it draw their content. + * the selection moves and the days around it draw their content. The days + * around the page a swipe is heading for draw theirs as soon as it becomes the + * nearer one, so a run of quick swipes that never rests finds them drawn + * instead of blank until the snap lands (#3067). * Keys follow the day ids, so the page that slid in keeps its state (scroll * position, fetched route and weather) as it becomes the current one. * @@ -69,7 +72,10 @@ export default function MDayPager({ }: MDayPagerProps) { const stripRef = useRef(null); const [announcement, setAnnouncement] = useState(''); - const pages = pagesFor(days, selectedDayId); + /** The page a swipe last headed for. It stays after the landing, so swiping + * back over the same days finds them drawn too. */ + const [focusDayId, setFocusDayId] = useState(null); + const pages = pagesFor(days, selectedDayId, focusDayId); // Which page is current belongs in the key: on a trip of up to five days a // chip tap can keep the same pages, and the strip then stayed on the old day // while the rail already showed the new one. @@ -135,6 +141,8 @@ export default function MDayPager({ const next = dayId != null && Number.isFinite(dayId) ? dayId : null; if (next === previewed) return; previewed = next; + // Draw ahead of the finger: the days around this one, before it lands. + if (next !== null) setFocusDayId(next); if (next !== null) tickHaptic(); latest.current.onPreview?.(next); }; diff --git a/client/src/mobile/components/dayPagerPages.ts b/client/src/mobile/components/dayPagerPages.ts index 5f9d5a474..32213aa4e 100644 --- a/client/src/mobile/components/dayPagerPages.ts +++ b/client/src/mobile/components/dayPagerPages.ts @@ -11,17 +11,20 @@ interface Page { near: boolean; } -/** Pages drawn with their content either side of the selection. */ +/** Pages drawn with their content either side of the selection, and of the page a swipe heads for. */ const REACH = 2; /** * A page for every day, so quick swipes in a row never run into the end of the * strip before the first has settled (they used to spring back after two days). - * Only the selection and REACH days either side draw their content. A selection + * Only the selection and REACH days either side draw their content, and as + * many either side of `focusDayId`, the page a swipe is heading for: a run of + * quick swipes that has not landed yet then meets drawn pages instead of blank + * paper (#3067). Two windows at most, however long the trip. A selection * that points at no day (the whole-trip view, or a day deleted remotely) still * gets its page, with the first day next to it as a way back. */ -export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] { +export function pagesFor(days: PagerItem[], selectedDayId: number | null, focusDayId: number | null = null): Page[] { const i = days.findIndex((d) => d.id === selectedDayId); if (i < 0) { const first = days[0]; @@ -30,5 +33,7 @@ export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[ ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []), ]; } - return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: Math.abs(j - i) <= REACH })); + const f = focusDayId == null ? -1 : days.findIndex((d) => d.id === focusDayId); + const near = (j: number) => Math.abs(j - i) <= REACH || (f >= 0 && Math.abs(j - f) <= REACH); + return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: near(j) })); }