Skip to content

Commit

Permalink
feat: detect scroll was complete with scrollend (#707)
Browse files Browse the repository at this point in the history
  • Loading branch information
piecyk committed Apr 18, 2024
1 parent b4ad45c commit 5580232
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 77 deletions.
154 changes: 77 additions & 77 deletions packages/virtual-core/src/index.ts
@@ -1,4 +1,4 @@
import { approxEqual, memo, notUndefined } from './utils'
import { approxEqual, memo, notUndefined, debounce } from './utils'

export * from './utils'

Expand Down Expand Up @@ -98,6 +98,10 @@ export const observeElementRect = <T extends Element>(
}
}

const addEventListenerOptions = {
passive: true,
}

export const observeWindowRect = (
instance: Virtualizer<Window, any>,
cb: (rect: Rect) => void,
Expand All @@ -112,58 +116,81 @@ export const observeWindowRect = (
}
handler()

element.addEventListener('resize', handler, {
passive: true,
})
element.addEventListener('resize', handler, addEventListenerOptions)

return () => {
element.removeEventListener('resize', handler)
}
}

const supportsScrollend =
typeof window == 'undefined' ? true : 'onscrollend' in window

export const observeElementOffset = <T extends Element>(
instance: Virtualizer<T, any>,
cb: (offset: number) => void,
cb: (offset: number, isScrolling: boolean) => void,
) => {
const element = instance.scrollElement
if (!element) {
return
}

const handler = () => {
cb(element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop'])
let offset = 0
const fallback = supportsScrollend
? () => undefined
: debounce(() => {
cb(offset, false)
}, 150)

const createHandler = (isScrolling: boolean) => () => {
offset = element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop']
fallback()
cb(offset, isScrolling)
}
handler()
const handler = createHandler(true)
const endHandler = createHandler(false)
endHandler()

element.addEventListener('scroll', handler, {
passive: true,
})
element.addEventListener('scroll', handler, addEventListenerOptions)
element.addEventListener('scrollend', endHandler, addEventListenerOptions)

return () => {
element.removeEventListener('scroll', handler)
element.removeEventListener('scrollend', endHandler)
}
}

export const observeWindowOffset = (
instance: Virtualizer<Window, any>,
cb: (offset: number) => void,
cb: (offset: number, isScrolling: boolean) => void,
) => {
const element = instance.scrollElement
if (!element) {
return
}

const handler = () => {
cb(element[instance.options.horizontal ? 'scrollX' : 'scrollY'])
let offset = 0
const fallback = supportsScrollend
? () => undefined
: debounce(() => {
cb(offset, false)
}, 150)

const createHandler = (isScrolling: boolean) => () => {
offset = element[instance.options.horizontal ? 'scrollX' : 'scrollY']
fallback()
cb(offset, isScrolling)
}
handler()
const handler = createHandler(true)
const endHandler = createHandler(false)
endHandler()

element.addEventListener('scroll', handler, {
passive: true,
})
element.addEventListener('scroll', handler, addEventListenerOptions)
element.addEventListener('scrollend', endHandler, addEventListenerOptions)

return () => {
element.removeEventListener('scroll', handler)
element.removeEventListener('scrollend', endHandler)
}
}

Expand Down Expand Up @@ -241,7 +268,7 @@ export interface VirtualizerOptions<
) => void | (() => void)
observeElementOffset: (
instance: Virtualizer<TScrollElement, TItemElement>,
cb: (offset: number) => void,
cb: (offset: number, isScrolling: boolean) => void,
) => void | (() => void)

// Optional
Expand All @@ -267,7 +294,6 @@ export interface VirtualizerOptions<
rangeExtractor?: (range: Range) => number[]
scrollMargin?: number
gap?: number
scrollingDelay?: number
indexAttribute?: string
initialMeasurementsCache?: VirtualItem[]
lanes?: number
Expand All @@ -281,7 +307,6 @@ export class Virtualizer<
options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
scrollElement: TScrollElement | null = null
isScrolling: boolean = false
private isScrollingTimeoutId: ReturnType<typeof setTimeout> | null = null
private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null
measurementsCache: VirtualItem[] = []
private itemSizeCache = new Map<Key, number>()
Expand Down Expand Up @@ -336,7 +361,7 @@ export class Virtualizer<
this.itemSizeCache.set(item.key, item.size)
})

this.maybeNotify()
this.notify(false, false)
}

setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
Expand All @@ -360,42 +385,29 @@ export class Virtualizer<
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
gap: 0,
scrollingDelay: 150,
indexAttribute: 'data-index',
initialMeasurementsCache: [],
lanes: 1,
...opts,
}
}

private notify = (sync: boolean) => {
this.options.onChange?.(this, sync)
private notify = (force: boolean, sync: boolean) => {
const { startIndex, endIndex } = this.range ?? {
startIndex: undefined,
endIndex: undefined,
}
const range = this.calculateRange()

if (
force ||
startIndex !== range?.startIndex ||
endIndex !== range?.endIndex
) {
this.options.onChange?.(this, sync)
}
}

private maybeNotify = memo(
() => {
this.calculateRange()

return [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
]
},
(isScrolling) => {
this.notify(isScrolling)
},
{
key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
debug: () => this.options.debug,
initialDeps: [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
] as [boolean, number | null, number | null],
},
)

private cleanup = () => {
this.unsubs.filter(Boolean).forEach((d) => d!())
this.unsubs = []
Expand Down Expand Up @@ -426,37 +438,24 @@ export class Virtualizer<
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect
this.maybeNotify()
this.notify(false, false)
}),
)

this.unsubs.push(
this.options.observeElementOffset(this, (offset) => {
this.options.observeElementOffset(this, (offset, isScrolling) => {
this.scrollAdjustments = 0

if (this.scrollOffset === offset) {
return
}

if (this.isScrollingTimeoutId !== null) {
clearTimeout(this.isScrollingTimeoutId)
this.isScrollingTimeoutId = null
}

this.isScrolling = true
this.scrollDirection =
this.scrollOffset < offset ? 'forward' : 'backward'
this.scrollDirection = isScrolling
? this.scrollOffset < offset
? 'forward'
: 'backward'
: null
this.scrollOffset = offset

this.maybeNotify()

this.isScrollingTimeoutId = setTimeout(() => {
this.isScrollingTimeoutId = null
this.isScrolling = false
this.scrollDirection = null
const prevIsScrolling = this.isScrolling
this.isScrolling = isScrolling

this.maybeNotify()
}, this.options.scrollingDelay)
this.notify(prevIsScrolling !== isScrolling, isScrolling)
}),
)
}
Expand All @@ -466,7 +465,7 @@ export class Virtualizer<
return this.scrollRect[this.options.horizontal ? 'width' : 'height']
}

private memoOptions = memo(
private getMeasurementOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
Expand Down Expand Up @@ -529,7 +528,7 @@ export class Virtualizer<
}

private getMeasurements = memo(
() => [this.memoOptions(), this.itemSizeCache],
() => [this.getMeasurementOptions(), this.itemSizeCache],
({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
const min =
this.pendingMeasuredCacheIndexes.length > 0
Expand Down Expand Up @@ -612,7 +611,8 @@ export class Virtualizer<
return range === null
? []
: rangeExtractor({
...range,
startIndex: range.startIndex,
endIndex: range.endIndex,
overscan,
count,
})
Expand Down Expand Up @@ -691,7 +691,7 @@ export class Virtualizer<
this.pendingMeasuredCacheIndexes.push(item.index)
this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))

this.notify(false)
this.notify(true, false)
}
}

Expand Down Expand Up @@ -918,7 +918,7 @@ export class Virtualizer<

measure = () => {
this.itemSizeCache = new Map()
this.notify(false)
this.options.onChange?.(this, false)
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/virtual-core/src/utils.ts
Expand Up @@ -77,3 +77,11 @@ export function notUndefined<T>(value: T | undefined, msg?: string): T {
}

export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1

export const debounce = (fn: Function, ms: number) => {
let timeoutId: ReturnType<typeof setTimeout>
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), ms)
}
}

0 comments on commit 5580232

Please sign in to comment.