Skip to content

Commit 74bde3a

Browse files
chore: split init scroll skip from the vm
1 parent 2449572 commit 74bde3a

2 files changed

Lines changed: 168 additions & 117 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { useCallback, useEffect, useRef } from 'react'
2+
3+
type UseInitialScrollOptions = {
4+
alignment: 'center' | 'default'
5+
currentPage: number
6+
currentSlidesPerView: number
7+
goTo: (page: number, isFirstInit?: boolean) => void
8+
initialIndex: number
9+
numberOfPage: number
10+
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
11+
slidesLength: number
12+
}
13+
14+
/**
15+
* Owns the deferred, one-shot scroll to the initial page and the state flags
16+
* that keep it from fighting the scroll handler. Returns `consumeSnapBackSkip`,
17+
* which the scroll handler calls once to swallow the scroll-snap correction
18+
* that follows our own initial scroll.
19+
*/
20+
export const useInitialScroll = ({
21+
alignment,
22+
currentPage,
23+
currentSlidesPerView,
24+
goTo,
25+
initialIndex,
26+
numberOfPage,
27+
setCurrentPage,
28+
slidesLength,
29+
}: UseInitialScrollOptions): (() => boolean) => {
30+
const hasInitializedRef = useRef(false)
31+
// Pending frame of the deferred initial scroll, `undefined` once it has run.
32+
const initFrameRef = useRef<number>()
33+
// Swallows the scroll-snap correction that follows our own initial scroll.
34+
const skipSnapBackRef = useRef(false)
35+
const skipNextPageScrollRef = useRef(false)
36+
// Read inside the deferred frame so it reflects the viewport-corrected perView.
37+
const firstPageToShowRef = useRef(0)
38+
39+
const pageForInitialIndex =
40+
alignment === 'center'
41+
? // if centeredSlides is true, we calculate which number is the middle page
42+
Math.floor(numberOfPage / 2)
43+
: // if centeredSlides is false, we calculate on which page the number in firstSlideToShow props is
44+
Math.ceil(initialIndex / currentSlidesPerView) - 1
45+
46+
// `initialIndex` is 1-based, so its default of `0` computes to page -1, and a
47+
// value past the last slide would scroll beyond the end and then fight the
48+
// `isLastPage` branch of `updatePage`. An explicit `initialIndex: undefined`
49+
// shadows the default and computes to NaN, so guard that too.
50+
const firstPageToShow = Number.isFinite(pageForInitialIndex)
51+
? Math.min(Math.max(pageForInitialIndex, 0), numberOfPage - 1)
52+
: 0
53+
54+
firstPageToShowRef.current = firstPageToShow
55+
56+
useEffect(() => {
57+
// Only navigate to the initial page once, when slidesLength is first known.
58+
// `hasInitializedRef` is set synchronously as it also gates the
59+
// external-`setCurrentPage` effect below.
60+
if (!slidesLength || hasInitializedRef.current) {
61+
return
62+
}
63+
64+
hasInitializedRef.current = true
65+
66+
// The scroll must land after the first paint: issued during the first layout
67+
// pass it is reverted to 0 by the browser's `scroll-snap-type: x mandatory`
68+
// correction. One frame is not enough — a callback scheduled from a passive
69+
// effect still runs before the next paint. The second frame also lets
70+
// `useViewportSize` commit, so `firstPageToShowRef` is breakpoint-correct.
71+
initFrameRef.current = requestAnimationFrame(() => {
72+
initFrameRef.current = requestAnimationFrame(() => {
73+
initFrameRef.current = undefined
74+
75+
const initialPage = firstPageToShowRef.current
76+
77+
// The track already starts on the first page, so it needs no scroll
78+
if (initialPage <= 0) {
79+
return
80+
}
81+
82+
skipSnapBackRef.current = true
83+
goTo(initialPage, true)
84+
// `currentPage` is otherwise only synced by the scroll handler, which we
85+
// just told to skip this scroll — so set it here, reading the freshest
86+
// value to arm the redundant-scroll skip only if the page really changes.
87+
setCurrentPage(current => {
88+
if (current === initialPage) {
89+
return current
90+
}
91+
92+
skipNextPageScrollRef.current = true
93+
94+
return initialPage
95+
})
96+
})
97+
})
98+
99+
return () => {
100+
if (initFrameRef.current === undefined) {
101+
return
102+
}
103+
104+
// The deferred scroll never ran (unmount, or a StrictMode remount) — let
105+
// the next mount schedule it again
106+
cancelAnimationFrame(initFrameRef.current)
107+
initFrameRef.current = undefined
108+
hasInitializedRef.current = false
109+
}
110+
// eslint-disable-next-line react-hooks/exhaustive-deps
111+
}, [slidesLength])
112+
113+
// Triggers navigation when currentPage is changed by external setCurrentPage calls
114+
useEffect(() => {
115+
if (skipNextPageScrollRef.current) {
116+
// The initial scroll already put us on this page, no need to scroll again
117+
skipNextPageScrollRef.current = false
118+
119+
return
120+
}
121+
122+
if (hasInitializedRef.current) {
123+
goTo(currentPage)
124+
}
125+
}, [currentPage, goTo])
126+
127+
// Swallow exactly one scroll-snap correction following our own initial scroll.
128+
return useCallback(() => {
129+
if (skipSnapBackRef.current) {
130+
skipSnapBackRef.current = false
131+
132+
return true
133+
}
134+
135+
return false
136+
}, [])
137+
}

lib/src/components/Swiper/useSwiperViewModel.ts

Lines changed: 31 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,13 @@ import debounce from 'lodash.debounce'
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33

44
import type { SwiperContextValue, SwiperProps } from './types'
5+
import { useInitialScroll } from './useInitialScroll'
56
import { useInterval } from './utils'
67

78
export const useSwiperViewModel = ({ children, store }: SwiperProps): SwiperContextValue => {
89
const { autoplay, navigation, slides } = store
910
const { currentPage, setCurrentPage } = slides
1011
const ref = useRef<HTMLUListElement | null>(null)
11-
const hasInitializedRef = useRef(false)
12-
// Pending frame of the deferred initial scroll, `undefined` once it has run.
13-
const initFrameRef = useRef<number>()
14-
// Swallows the scroll-snap correction that follows our own initial scroll.
15-
const skipSnapBackRef = useRef(false)
16-
const skipNextPageScrollRef = useRef(false)
17-
// Read inside the deferred frame so it reflects the viewport-corrected perView.
18-
const firstPageToShowRef = useRef(0)
1912

2013
const [slidesLength, setSlidesLength] = useState(0)
2114
const [isPrevDisabled, setIsPrevDisabled] = useState(false)
@@ -56,21 +49,47 @@ export const useSwiperViewModel = ({ children, store }: SwiperProps): SwiperCont
5649
}
5750
}, [numberOfPage, currentPage, slides.currentSlidesPerView, ref, setCurrentPage, slides.gap])
5851

52+
// Navigation functions
53+
54+
const goTo = useCallback(
55+
(page: number, isFirstInit = false) => {
56+
const sliderContainer = ref?.current
57+
const childWidth = sliderContainer?.children?.[0]?.getBoundingClientRect()?.width || 0
58+
59+
sliderContainer?.scrollTo({
60+
// We don't want to have a scroll effect when we first render the swiper
61+
behavior: !isFirstInit ? 'smooth' : 'auto',
62+
left: page * (childWidth + slides.gap) * slides.currentSlidesPerView,
63+
top: 0,
64+
})
65+
},
66+
[slides.currentSlidesPerView, slides.gap, ref]
67+
)
68+
69+
const consumeSnapBackSkip = useInitialScroll({
70+
alignment: slides.alignment,
71+
currentPage,
72+
currentSlidesPerView: slides.currentSlidesPerView,
73+
goTo,
74+
initialIndex: slides.initialIndex,
75+
numberOfPage,
76+
setCurrentPage,
77+
slidesLength,
78+
})
79+
5980
const handleScroll = useMemo(
6081
() =>
6182
debounce(() => {
6283
// Navigation state should always reflect the real geometry, guard or not
6384
getNavigationState()
6485

65-
if (skipSnapBackRef.current) {
66-
skipSnapBackRef.current = false
67-
86+
if (consumeSnapBackSkip()) {
6887
return
6988
}
7089

7190
updatePage()
7291
}, 100),
73-
[getNavigationState, updatePage]
92+
[getNavigationState, updatePage, consumeSnapBackSkip]
7493
)
7594

7695
// Cancel exactly one pending call on unmount. Depending on `[handleScroll]`
@@ -81,23 +100,6 @@ export const useSwiperViewModel = ({ children, store }: SwiperProps): SwiperCont
81100

82101
useEffect(() => () => handleScrollRef.current.cancel(), [])
83102

84-
// Navigation functions
85-
86-
const goTo = useCallback(
87-
(page: number, isFirstInit = false) => {
88-
const sliderContainer = ref?.current
89-
const childWidth = sliderContainer?.children?.[0]?.getBoundingClientRect()?.width || 0
90-
91-
sliderContainer?.scrollTo({
92-
// We don't want to have a scroll effect when we first render the swiper
93-
behavior: !isFirstInit ? 'smooth' : 'auto',
94-
left: page * (childWidth + slides.gap) * slides.currentSlidesPerView,
95-
top: 0,
96-
})
97-
},
98-
[slides.currentSlidesPerView, slides.gap, ref]
99-
)
100-
101103
const isFirstPage = currentPage === 0
102104
const isLastPage = currentPage === numberOfPage - 1
103105

@@ -143,99 +145,11 @@ export const useSwiperViewModel = ({ children, store }: SwiperProps): SwiperCont
143145
return () => window.removeEventListener('keydown', handleKeys)
144146
}, [goPrev, goNext])
145147

146-
const pageForInitialIndex =
147-
slides.alignment === 'center'
148-
? // if centeredSlides is true, we calculate which number is the middle page
149-
Math.floor(numberOfPage / 2)
150-
: // if centeredSlides is false, we calculate on which page the number in firstSlideToShow props is
151-
Math.ceil(slides.initialIndex / slides.currentSlidesPerView) - 1
152-
153-
// `initialIndex` is 1-based, so its default of `0` computes to page -1, and a
154-
// value past the last slide would scroll beyond the end and then fight the
155-
// `isLastPage` branch of `updatePage`. An explicit `initialIndex: undefined`
156-
// shadows the default and computes to NaN, so guard that too.
157-
const firstPageToShow = Number.isFinite(pageForInitialIndex)
158-
? Math.min(Math.max(pageForInitialIndex, 0), numberOfPage - 1)
159-
: 0
160-
161-
firstPageToShowRef.current = firstPageToShow
162-
163-
useEffect(() => {
164-
// Only navigate to the initial page once, when slidesLength is first known.
165-
// `hasInitializedRef` is set synchronously as it also gates the
166-
// external-`setCurrentPage` effect below.
167-
if (!slidesLength || hasInitializedRef.current) {
168-
return
169-
}
170-
171-
hasInitializedRef.current = true
172-
173-
// The scroll must land after the first paint: issued during the first layout
174-
// pass it is reverted to 0 by the browser's `scroll-snap-type: x mandatory`
175-
// correction. One frame is not enough — a callback scheduled from a passive
176-
// effect still runs before the next paint. The second frame also lets
177-
// `useViewportSize` commit, so `firstPageToShowRef` is breakpoint-correct.
178-
initFrameRef.current = requestAnimationFrame(() => {
179-
initFrameRef.current = requestAnimationFrame(() => {
180-
initFrameRef.current = undefined
181-
182-
const initialPage = firstPageToShowRef.current
183-
184-
// The track already starts on the first page, so it needs no scroll
185-
if (initialPage <= 0) {
186-
return
187-
}
188-
189-
skipSnapBackRef.current = true
190-
goTo(initialPage, true)
191-
// `currentPage` is otherwise only synced by the scroll handler, which we
192-
// just told to skip this scroll — so set it here, reading the freshest
193-
// value to arm the redundant-scroll skip only if the page really changes.
194-
setCurrentPage(current => {
195-
if (current === initialPage) {
196-
return current
197-
}
198-
199-
skipNextPageScrollRef.current = true
200-
201-
return initialPage
202-
})
203-
})
204-
})
205-
206-
return () => {
207-
if (initFrameRef.current === undefined) {
208-
return
209-
}
210-
211-
// The deferred scroll never ran (unmount, or a StrictMode remount) — let
212-
// the next mount schedule it again
213-
cancelAnimationFrame(initFrameRef.current)
214-
initFrameRef.current = undefined
215-
hasInitializedRef.current = false
216-
}
217-
// eslint-disable-next-line react-hooks/exhaustive-deps
218-
}, [slidesLength])
219-
220148
// if the childrens changed we need to check again the arrow states
221149
useEffect(() => {
222150
getNavigationState()
223151
}, [getNavigationState, children])
224152

225-
// Triggers navigation when currentPage is changed by external setCurrentPage calls
226-
useEffect(() => {
227-
if (skipNextPageScrollRef.current) {
228-
// The initial scroll already put us on this page, no need to scroll again
229-
skipNextPageScrollRef.current = false
230-
231-
return
232-
}
233-
234-
if (hasInitializedRef.current) {
235-
goTo(currentPage)
236-
}
237-
}, [currentPage, goTo])
238-
239153
const contextValue = useMemo(
240154
() => ({
241155
navigation: {

0 commit comments

Comments
 (0)