Skip to content

Commit

Permalink
Merge pull request #1649 from didi/feat-intersection-observer
Browse files Browse the repository at this point in the history
Feat intersection observer
  • Loading branch information
hiyuki authored Nov 1, 2024
2 parents 850c554 + 56921fe commit 5ce6e23
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ENV_OBJ } from '../../../common/js'

function createIntersectionObserver (component, options = {}) {
if (options.observeAll) {
options.selectAll = options.observeAll
}
return ENV_OBJ.createIntersectionObserver(options)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import IntersectionObserver from './rnIntersectionObserver'

function createIntersectionObserver (comp, opt, config) {
return new IntersectionObserver(comp, opt, config)
}

export {
createIntersectionObserver
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { isArray, isObject, isString, noop } from '@mpxjs/utils'
import throttle from 'lodash/throttle'
import { Dimensions } from 'react-native'
import { getFocusedNavigation } from '../../../common/js'

const WindowRefStr = 'window'
const IgnoreTarget = 'ignore'
const DefaultMargin = { top: 0, bottom: 0, left: 0, right: 0 }
let idCount = 0

class RNIntersectionObserver {
constructor (component, options, intersectionCtx) {
this.id = idCount++
this.component = component
this.options = options
this.thresholds = options.thresholds.sort((a, b) => a - b) || [0]
this.initialRatio = options.initialRatio || 0
this.observeAll = options.observeAll || false

// 组件上挂载对应的observers,用于在组件销毁的时候进行批量disconnect
this.component._intersectionObservers = this.component.__intersectionObservers || []
this.component._intersectionObservers.push(this)

this.observerRefs = null
this.relativeRef = null
this.margins = DefaultMargin
this.callback = noop

this.throttleMeasure = this.getThrottleMeasure(options.throttleTime || 100)

// 记录上一次相交的比例
this.previousIntersectionRatio = []

// 添加实例添加到上下文中,滚动组件可以获取到上下文内的实例从而触发滚动
if (intersectionCtx && isObject(intersectionCtx)) {
this.intersectionCtx = intersectionCtx
this.intersectionCtx[this.id] = this
}
return this
}

// 支持传递ref 或者 selector
relativeTo (selector, margins = {}) {
let relativeRef
if (isString(selector)) {
relativeRef = this.component.__selectRef(selector, 'node')
}
if (isObject(selector)) {
relativeRef = selector.nodeRefs?.[0]
}
if (relativeRef) {
this.relativeRef = relativeRef
this.margins = Object.assign({}, DefaultMargin, margins)
} else {
console.warn(`node ${selector}is not found. The relative node for intersection observer will be ignored`)
}
return this
}

relativeToViewport (margins = {}) {
this.relativeRef = WindowRefStr
this.margins = Object.assign({}, DefaultMargin, margins)
return this
}

observe (selector, callback) {
if (this.observerRefs) {
console.error('"observe" call can be only called once in IntersectionObserver')
return
}
let targetRef = null
if (this.observeAll) {
targetRef = this.component.__selectRef(selector, 'node', true)
} else {
targetRef = this.component.__selectRef(selector, 'node')
}
if (!targetRef || targetRef.length === 0) {
console.error('intersection observer target not found')
return
}
this.observerRefs = isArray(targetRef) ? targetRef : [targetRef]
this.callback = callback
this._measureTarget(true)
}

_getWindowRect () {
if (this.windowRect) return this.windowRect
const navigation = getFocusedNavigation()
const screen = Dimensions.get('screen')
const navigationLayout = navigation.layout || {
x: 0,
y: 0,
width: screen.width,
height: screen.height
}

const windowRect = {
top: navigationLayout.y + this.margins.top,
left: navigationLayout.x + this.margins.left,
right: navigationLayout.width - this.margins.right,
bottom: navigationLayout.y + navigationLayout.height - this.margins.bottom
}

this.windowRect = windowRect
return this.windowRect
}

_getReferenceRect (targetRef) {
const targetRefs = isArray(targetRef) ? targetRef : [targetRef]
const targetPromiseQueue = []
targetRefs.forEach((targetRefItem) => {
if (targetRefItem === WindowRefStr) {
targetPromiseQueue.push(this._getWindowRect())
return
}
// 当节点前面存在后面移除的时候可能会存在拿不到target的情况,此处直接忽略留一个占位不用做计算即可
// 测试节点移除之后 targetRefItem.getNodeInstance().nodeRef都存在,只是current不存在了
if (!targetRefItem || !targetRefItem.getNodeInstance().nodeRef.current) {
targetPromiseQueue.push(Promise.resolve(IgnoreTarget))
return
}
const target = targetRefItem.getNodeInstance().nodeRef.current
targetPromiseQueue.push(new Promise((resolve) => {
target.measureInWindow(
(x, y, width, height) => {
const boundingClientRect = {
left: x,
top: y,
right: x + width,
bottom: y + height,
width: width,
height: height
}
resolve(boundingClientRect)
}
)
}))
})

if (isArray(targetRef)) {
return Promise.all(targetPromiseQueue)
} else {
return targetPromiseQueue[0]
}
}

_restrictValueInRange (start = 0, end = 0, value = 0) {
return Math.min(Math.max(start, value), end)
}

_isInsectedFn (intersectionRatio, previousIntersectionRatio, thresholds) {
// console.log('nowintersectionRatio, previousIntersectionRatio', [intersectionRatio, previousIntersectionRatio])
let nowIndex = -1
let previousIndex = -1
thresholds.forEach((item, index) => {
if (intersectionRatio >= item) {
nowIndex = index
}
if (previousIntersectionRatio >= item) {
previousIndex = index
}
})
return !(nowIndex === previousIndex)
}

// 计算相交区域
_measureIntersection ({ observeRect, relativeRect, observeIndex, isInit }) {
const visibleRect = {
left: this._restrictValueInRange(relativeRect.left, relativeRect.right, observeRect.left),
top: this._restrictValueInRange(relativeRect.top, relativeRect.bottom, observeRect.top),
right: this._restrictValueInRange(relativeRect.left, relativeRect.right, observeRect.right),
bottom: this._restrictValueInRange(relativeRect.top, relativeRect.bottom, observeRect.bottom)
}

const targetArea = (observeRect.bottom - observeRect.top) * (observeRect.right - observeRect.left)
const visibleArea = (visibleRect.bottom - visibleRect.top) * (visibleRect.right - visibleRect.left)
const intersectionRatio = targetArea ? visibleArea / targetArea : 0

const isInsected = isInit ? intersectionRatio > this.initialRatio : this._isInsectedFn(intersectionRatio, this.previousIntersectionRatio[observeIndex], this.thresholds)
this.previousIntersectionRatio[observeIndex] = intersectionRatio

return {
intersectionRatio,
intersectionRect: {
top: visibleRect.top,
bottom: relativeRect.bottom,
left: visibleRect.left,
right: relativeRect.right
},
isInsected
}
}

getThrottleMeasure (throttleTime) {
return throttle(() => {
this._measureTarget()
}, throttleTime)
}

// 计算节点的rect信息
_measureTarget (isInit = false) {
Promise.all([
this._getReferenceRect(this.observerRefs),
this._getReferenceRect(this.relativeRef)
]).then(([observeRects, relativeRect]) => {
if (relativeRect === IgnoreTarget) return
observeRects.forEach((observeRect, index) => {
if (observeRect === IgnoreTarget) return
const { intersectionRatio, intersectionRect, isInsected } = this._measureIntersection({
observeRect,
observeIndex: index,
relativeRect,
isInit
})
// 初次调用的
if (isInsected) {
this.callback({
// index: index,
id: this.observerRefs[index].getNodeInstance().props?.current?.id,
dataset: this.observerRefs[index].getNodeInstance().props?.current?.dataset || {},
intersectionRatio: Math.round(intersectionRatio * 100) / 100,
intersectionRect,
boundingClientRect: observeRect,
relativeRect: relativeRect,
time: Date.now()
})
}
})
}).catch((e) => {
console.log('_measureTarget fail', e)
})
}

disconnect () {
if (this.intersectionCtx) delete this.intersectionCtx[this.id]
}
}

export default RNIntersectionObserver
5 changes: 5 additions & 0 deletions packages/core/src/core/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ export default class MpxProxy {
if (this.update) this.update.active = false
this.callHook(UNMOUNTED)
this.state = UNMOUNTED
if (this._intersectionObservers) {
this._intersectionObservers.forEach((observer) => {
observer.disconnect()
})
}
}

isUnmounted () {
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/platform/export/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

export {
watchEffect,
watchSyncEffect,
Expand Down
33 changes: 20 additions & 13 deletions packages/core/src/platform/patch/react/getDefaultOptions.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import * as ReactNative from 'react-native'
import { ReactiveEffect } from '../../../observer/effect'
import { watch } from '../../../observer/watch'
import { reactive, set, del } from '../../../observer/reactive'
import { hasOwn, isFunction, noop, isObject, error, getByPath, collectDataset, hump2dash } from '@mpxjs/utils'
import { hasOwn, isFunction, noop, isObject, getByPath, collectDataset, hump2dash } from '@mpxjs/utils'
import MpxProxy from '../../../core/proxy'
import { BEFOREUPDATE, ONLOAD, UPDATED, ONSHOW, ONHIDE, ONRESIZE, REACTHOOKSEXEC } from '../../../core/innerLifecycle'
import mergeOptions from '../../../core/mergeOptions'
import { queueJob } from '../../../observer/scheduler'
import { createSelectorQuery } from '@mpxjs/api-proxy'
import { createSelectorQuery, createIntersectionObserver } from '@mpxjs/api-proxy'
import { IntersectionObserverContext } from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/context'

function getSystemInfo () {
const window = ReactNative.Dimensions.get('window')
Expand Down Expand Up @@ -68,7 +69,7 @@ function getRootProps (props) {
return rootProps
}

function createInstance ({ propsRef, type, rawOptions, currentInject, validProps, components, pageId }) {
function createInstance ({ propsRef, type, rawOptions, currentInject, validProps, components, pageId, intersectionCtx }) {
const instance = Object.create({
setData (data, callback) {
return this.__mpxProxy.forceUpdate(data, { sync: true }, callback)
Expand Down Expand Up @@ -183,8 +184,8 @@ function createInstance ({ propsRef, type, rawOptions, currentInject, validProps
createSelectorQuery () {
return createSelectorQuery().in(this)
},
createIntersectionObserver () {
error('createIntersectionObserver is not supported in react native, please use ref instead')
createIntersectionObserver (opt) {
return createIntersectionObserver(this, opt, intersectionCtx)
},
...rawOptions.methods
}, {
Expand Down Expand Up @@ -349,12 +350,13 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) {
const defaultOptions = memo(forwardRef((props, ref) => {
const instanceRef = useRef(null)
const propsRef = useRef(null)
const intersectionCtx = useContext(IntersectionObserverContext)
const pageId = useContext(RouteContext)
propsRef.current = props
let isFirst = false
if (!instanceRef.current) {
isFirst = true
instanceRef.current = createInstance({ propsRef, type, rawOptions, currentInject, validProps, components, pageId })
instanceRef.current = createInstance({ propsRef, type, rawOptions, currentInject, validProps, components, pageId, intersectionCtx })
}
const instance = instanceRef.current
useImperativeHandle(ref, () => {
Expand Down Expand Up @@ -418,6 +420,7 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) {
const pageConfig = Object.assign({}, global.__mpxPageConfig, currentInject.pageConfig)
const Page = ({ navigation, route }) => {
const currentPageId = useMemo(() => ++pageId, [])
const intersectionObservers = useRef({})
usePageStatus(navigation, currentPageId)

useLayoutEffect(() => {
Expand Down Expand Up @@ -472,12 +475,17 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) {
{
value: currentPageId
},
createElement(defaultOptions,
{
navigation,
route,
id: currentPageId
}
createElement(IntersectionObserverContext.Provider,
{
value: intersectionObservers.current
},
createElement(defaultOptions,
{
navigation,
route,
id: currentPageId
}
)
)
)
)
Expand All @@ -487,6 +495,5 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) {
}
return Page
}

return defaultOptions
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface FormContextValue {
reset: () => void
}

export interface IntersectionObserver {
[key: number]: {
throttleMeasure: () => void
}
}

export const MovableAreaContext = createContext({ width: 0, height: 0 })

export const FormContext = createContext<FormContextValue | null>(null)
Expand All @@ -38,3 +44,5 @@ export const LabelContext = createContext<LabelContextValue | null>(null)
export const PickerContext = createContext(null)

export const VarContext = createContext({})

export const IntersectionObserverContext = createContext<IntersectionObserver | null>(null)
Loading

0 comments on commit 5ce6e23

Please sign in to comment.