@rific/scroll-view 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +69 -36
- package/dist/index.mjs +72 -39
- package/package.json +4 -3
- package/src/CustomList.tsx +9 -1
- package/src/PullSearch.tsx +5 -2
- package/src/ScrollView.tsx +16 -1
- package/src/ScrollViewContext.ts +3 -1
- package/src/ScrollViewFooter.tsx +35 -4
- package/src/ScrollViewHeader.tsx +25 -12
- package/src/ScrollViewProvider.tsx +10 -3
- package/src/SectionList.tsx +10 -2
- package/src/internal/HorizontalDots.tsx +13 -5
- package/src/internal/useScrollList.ts +37 -8
- package/src/internal/useStickyHeaders.ts +13 -3
- package/src/useScrollInit.ts +1 -0
|
@@ -10,6 +10,7 @@ export type ScrollViewProviderProps = {
|
|
|
10
10
|
blur?: boolean
|
|
11
11
|
children: ReactNode
|
|
12
12
|
fixed?: boolean
|
|
13
|
+
footerAboveKeyboard?: boolean
|
|
13
14
|
footerFixed?: boolean
|
|
14
15
|
headerFixed?: boolean
|
|
15
16
|
snapBack?: boolean
|
|
@@ -18,7 +19,7 @@ export type ScrollViewProviderProps = {
|
|
|
18
19
|
tabBarHeight?: number
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight =
|
|
22
|
+
export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveKeyboard = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 0 }: ScrollViewProviderProps) => {
|
|
22
23
|
const { settings } = useContext(ScrollViewSettingsContext)
|
|
23
24
|
const effectiveBlur = useBlur(blur)
|
|
24
25
|
const effectiveSnapBack = snapBack ?? settings.snapBack
|
|
@@ -44,10 +45,16 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
|
|
|
44
45
|
}, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
|
|
45
46
|
const setHeaderHeight = useCallback(
|
|
46
47
|
(h: number | null) => {
|
|
48
|
+
// react-native-screens' web shim hides an inactive screen via display:none on an
|
|
49
|
+
// ancestor instead of unmounting it, so the header's ResizeObserver-backed onLayout
|
|
50
|
+
// keeps firing while hidden and reports a spurious 0 (no layout box), then again once
|
|
51
|
+
// revealed. Never let a spurious 0 clobber a real height — only a true remount
|
|
52
|
+
// (headerHeight reset to null) should ever legitimately shrink it back to 0.
|
|
53
|
+
if (h === 0 && headerHeight) return
|
|
47
54
|
setHeaderHeightState(h)
|
|
48
55
|
headerHeightShared.value = h ?? 0
|
|
49
56
|
},
|
|
50
|
-
[headerHeightShared]
|
|
57
|
+
[headerHeight, headerHeightShared]
|
|
51
58
|
)
|
|
52
59
|
const onListUnmount = useCallback(() => {
|
|
53
60
|
listGeneration.value += 1
|
|
@@ -73,6 +80,6 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
|
|
|
73
80
|
const scrollHeight = useMemo(() => Dimensions.get('window').height - (headerHeight ?? 0) - footerHeight, [headerHeight, footerHeight])
|
|
74
81
|
const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
|
|
75
82
|
const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
|
|
76
|
-
const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
|
|
83
|
+
const value = useMemo(() => ({ blur: effectiveBlur, footerAboveKeyboard, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, footerAboveKeyboard, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
|
|
77
84
|
return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
|
|
78
85
|
}
|
package/src/SectionList.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
|
|
2
|
+
import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, Platform, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
|
|
3
3
|
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
4
|
import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
|
|
5
5
|
|
|
@@ -34,7 +34,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
34
34
|
|
|
35
35
|
const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
|
|
36
36
|
|
|
37
|
-
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
|
|
37
|
+
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured: true, keyboardAware, pullSearchHeight, style })
|
|
38
38
|
const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
|
|
39
39
|
// -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
|
|
40
40
|
// corrects it before any scroll event could observe the placeholder.
|
|
@@ -51,8 +51,16 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
51
51
|
// imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
|
|
52
52
|
// own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
|
|
53
53
|
// only be queued after this component finishes mounting.
|
|
54
|
+
//
|
|
55
|
+
// Web has no such thread split, though — see ScrollView.tsx's own doc comment on this same
|
|
56
|
+
// pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
|
|
57
|
+
// synchronous assignment is the correct fix rather than a workaround.
|
|
54
58
|
useLayoutEffect(() => {
|
|
55
59
|
const generation = listGeneration.value
|
|
60
|
+
if (Platform.OS === 'web') {
|
|
61
|
+
capturedGeneration.value = generation
|
|
62
|
+
return
|
|
63
|
+
}
|
|
56
64
|
runOnUI((gen: number) => {
|
|
57
65
|
'worklet'
|
|
58
66
|
capturedGeneration.value = gen
|
|
@@ -29,6 +29,11 @@ type DotProps = {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: DotProps) => {
|
|
32
|
+
// Explicit array required on web even though nothing here looks platform-specific: without
|
|
33
|
+
// reanimated's Babel plugin active for the web bundle, useAnimatedStyle has no way to
|
|
34
|
+
// auto-extract dependencies and throws outright rather than silently under-tracking — omitting
|
|
35
|
+
// this array isn't a lesser version of the header/footer bug elsewhere in this package, it's a
|
|
36
|
+
// hard crash (confirmed live on the horizontal-pager demo).
|
|
32
37
|
const circleStyle = useAnimatedStyle(() => {
|
|
33
38
|
const page = scrollPosition.value / pageWidth
|
|
34
39
|
const distance = Math.abs(index - page)
|
|
@@ -36,7 +41,7 @@ const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: D
|
|
|
36
41
|
const size = DOT_FULL + (DOT_SMALL - DOT_FULL) * t
|
|
37
42
|
const backgroundColor = interpolateColor(t, [0, 1], [activeColor, inactiveColor])
|
|
38
43
|
return { backgroundColor, borderRadius: size / 2, height: size, width: size }
|
|
39
|
-
})
|
|
44
|
+
}, [scrollPosition, pageWidth, index, activeColor, inactiveColor])
|
|
40
45
|
return (
|
|
41
46
|
<View style={styles.dotContainer}>
|
|
42
47
|
<Animated.View style={circleStyle} />
|
|
@@ -56,16 +61,19 @@ export const HorizontalDots = ({ total }: Props) => {
|
|
|
56
61
|
const activeColor = theme.colors.primary
|
|
57
62
|
const inactiveColor = theme.colors.outlineVariant
|
|
58
63
|
|
|
59
|
-
const pillContainerStyle = useAnimatedStyle(
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const pillContainerStyle = useAnimatedStyle(
|
|
65
|
+
() => ({
|
|
66
|
+
bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
|
|
67
|
+
}),
|
|
68
|
+
[footerHeightShared, insetsBottom]
|
|
69
|
+
)
|
|
62
70
|
|
|
63
71
|
const rowStyle = useAnimatedStyle(() => {
|
|
64
72
|
const page = scrollPosition.value / width
|
|
65
73
|
const maxOffset = Math.max(0, (total - CLIP_COUNT) * DOT_STEP)
|
|
66
74
|
const offset = Math.max(0, Math.min((page - (CLIP_COUNT - 1) / 2) * DOT_STEP, maxOffset))
|
|
67
75
|
return { transform: [{ translateX: -offset }] }
|
|
68
|
-
})
|
|
76
|
+
}, [scrollPosition, width, total])
|
|
69
77
|
|
|
70
78
|
return (
|
|
71
79
|
<Animated.View pointerEvents='none' style={[styles.pillContainer, pillContainerStyle]}>
|
|
@@ -15,16 +15,17 @@ const CHIP_SLIDE = 48
|
|
|
15
15
|
export type UseScrollListOptions = {
|
|
16
16
|
footerFixed?: boolean
|
|
17
17
|
headerFixed?: boolean
|
|
18
|
+
hideUntilMeasured?: boolean
|
|
18
19
|
isHorizontal?: boolean
|
|
19
20
|
keyboardAware?: boolean
|
|
20
21
|
pullSearchHeight?: number
|
|
21
22
|
style?: StyleProp<ViewStyle>
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
|
|
25
|
+
export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
|
|
25
26
|
const insets = useSafeAreaInsets()
|
|
26
27
|
const keyboardHeight = useKeyboardInset()
|
|
27
|
-
const { footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
28
|
+
const { footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
|
|
28
29
|
|
|
29
30
|
const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
|
|
30
31
|
const footerFixed = isHorizontal ? true : (footerFixedProp ?? contextFooterFixed)
|
|
@@ -33,12 +34,36 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
33
34
|
pullSearchHeightShared.value = usesContentInset ? (pullSearchHeight ?? 0) : 0
|
|
34
35
|
}, [pullSearchHeight, pullSearchHeightShared])
|
|
35
36
|
|
|
36
|
-
// First render (headerHeight===
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
37
|
+
// First render (headerHeight===null): flex:1, sitting naturally below the in-flow header —
|
|
38
|
+
// same visual position content ends up in after the swap below (absoluteFill + paddingTop
|
|
39
|
+
// matching the now-measured headerHeight), so no hiding is needed for this transition on its
|
|
40
|
+
// own. hideUntilMeasured is only for SectionList: its key={headerHeight>0?1:0} forces a real
|
|
41
|
+
// remount of the underlying native list on this exact transition, and that remount is what
|
|
42
|
+
// needs hiding, not this container swap itself — ScrollView/FlatList/CustomList never remount
|
|
43
|
+
// here, so unconditionally hiding this container regressed a real "flash of blank" on every
|
|
44
|
+
// fresh mount for them (2026-08-03).
|
|
45
|
+
const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: hideUntilMeasured ? 0 : 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, hideUntilMeasured, style])
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
// tabBarHeight reserves space for a consuming app's own persistent tab bar - a fixture outside
|
|
48
|
+
// this package's header/footer concepts entirely, so it's added on top of whichever base the
|
|
49
|
+
// footerFixed branch below resolves to (footerHeight already bakes in insets.bottom itself; see
|
|
50
|
+
// ScrollViewFooter's own paddingBottom).
|
|
51
|
+
//
|
|
52
|
+
// When footerAboveKeyboard opts the (fixed) footer into floating above the keyboard instead of
|
|
53
|
+
// sitting behind it, footerHeight ALREADY includes the current keyboardHeight — ScrollViewFooter
|
|
54
|
+
// grows its own container by keyboardHeight to float (see its containerPaddingBottom), rather
|
|
55
|
+
// than translating a fixed-height bar, so onLayout reports the combined height. Adding
|
|
56
|
+
// keyboardHeight again here would double-count it. Otherwise (not floating) ScrollViewFooter pins
|
|
57
|
+
// itself with plain `position: 'absolute', bottom: 0` with no keyboard-awareness of its own, so
|
|
58
|
+
// an open keyboard simply covers it rather than stacking above it - reserving
|
|
59
|
+
// footerReserve + keyboardHeight in that case would leave a gap the size of the now-hidden footer
|
|
60
|
+
// between the focused input and the keyboard, so max() reserves only whichever bottom obstruction
|
|
61
|
+
// is actually taller right now.
|
|
62
|
+
const insetGeometry = useMemo(() => {
|
|
63
|
+
const footerReserve = footerFixed ? footerHeight || insets.bottom : insets.bottom
|
|
64
|
+
const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + tabBarHeight
|
|
65
|
+
return { bottom, top: headerHeight ?? 0 }
|
|
66
|
+
}, [footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
|
|
42
67
|
// Outside inset mode the same geometry is applied as content padding instead; contentInset is
|
|
43
68
|
// zeroed (not omitted) so consumers' offset math — scroll-to-top targets, minHeight — stays
|
|
44
69
|
// correct in the raw 0-based coordinate space those platforms actually scroll in.
|
|
@@ -54,6 +79,10 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
54
79
|
const contentOffset = usesContentInset ? insetContentOffset : ZERO_OFFSET
|
|
55
80
|
|
|
56
81
|
const chipHidden = useSharedValue(1)
|
|
82
|
+
// chipHidden/snapBackHeaderShared/headerOffset/scrollPosition/pullSearchHeightShared all need to
|
|
83
|
+
// be listed here too, not just read — see ScrollViewHeader's translateStyle for why (web-only
|
|
84
|
+
// reanimated reactivity gap: a SharedValue's mutations don't retrigger useAnimatedStyle unless
|
|
85
|
+
// the value itself is in this array).
|
|
57
86
|
const chipStyle = useAnimatedStyle(() => {
|
|
58
87
|
const pointerEvents = chipHidden.value ? ('none' as const) : ('box-none' as const)
|
|
59
88
|
if (isHorizontal) {
|
|
@@ -73,7 +102,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
73
102
|
top,
|
|
74
103
|
transform: [{ translateY: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
|
|
75
104
|
}
|
|
76
|
-
}, [isHorizontal, headerFixed, headerHeight, insets.top])
|
|
105
|
+
}, [isHorizontal, headerFixed, headerHeight, insets.top, chipHidden, snapBackHeaderShared, headerOffset, scrollPosition, pullSearchHeightShared])
|
|
77
106
|
|
|
78
107
|
return { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight, insets, isHorizontal: isHorizontal ?? false }
|
|
79
108
|
}
|
|
@@ -62,11 +62,21 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
62
62
|
(curr, prev) => {
|
|
63
63
|
if (curr !== prev) runOnJS(setActiveIndex)(curr)
|
|
64
64
|
},
|
|
65
|
-
|
|
65
|
+
// Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
|
|
66
|
+
// function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
|
|
67
|
+
// snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
|
|
68
|
+
// — confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
|
|
69
|
+
// then never again on web, permanently freezing activeIndex/the active sticky section.
|
|
70
|
+
[enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
|
|
66
71
|
)
|
|
67
72
|
|
|
68
73
|
// Positions the clip container at stickyTop so overflow:hidden hides the outgoing
|
|
69
74
|
// header as it slides above the sticky line during a push transition.
|
|
75
|
+
// Every SharedValue read below (headerHeightShared, scrollPosition, snapBackHeaderShared,
|
|
76
|
+
// headerOffset, pullSearchHeightShared, and for overlayStyle also contentIndexShared/
|
|
77
|
+
// sectionYPositions/sectionHeights) has to be listed in the array too — see
|
|
78
|
+
// ScrollViewHeader's translateStyle for why: on web, reanimated doesn't treat mutations to a
|
|
79
|
+
// SharedValue as a reactive trigger for useAnimatedStyle unless it's in this array.
|
|
70
80
|
const clipStyle = useAnimatedStyle(() => {
|
|
71
81
|
const hHeight = headerHeightShared.value
|
|
72
82
|
const scrollPos = scrollPosition.value
|
|
@@ -80,7 +90,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
80
90
|
stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective)
|
|
81
91
|
}
|
|
82
92
|
return { top: stickyTop }
|
|
83
|
-
}, [enabled, headerFixed, insetsTop])
|
|
93
|
+
}, [enabled, headerFixed, insetsTop, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared])
|
|
84
94
|
|
|
85
95
|
// overlayStyle positions the header at top:0 relative to the clip container.
|
|
86
96
|
// The push animation uses 1:1 speed with pushThreshold = stickyTop + currentHeight
|
|
@@ -118,7 +128,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
return { opacity: 1, top: 0, transform: [{ translateY }] }
|
|
121
|
-
}, [enabled, headerFixed, insetsTop])
|
|
131
|
+
}, [enabled, headerFixed, insetsTop, contentIndexShared, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared, sectionHeights])
|
|
122
132
|
|
|
123
133
|
// pageY is the view's window-coordinate Y from view.measure().
|
|
124
134
|
// contentY = pageY + contentOffset.y, since screenY = contentY - contentOffset.y.
|
package/src/useScrollInit.ts
CHANGED
|
@@ -160,6 +160,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
160
160
|
return () => clearTimeout(fallback)
|
|
161
161
|
// Intentionally runs once per mount only, using whatever remountTarget was captured at
|
|
162
162
|
// construction — not a dep that should retrigger this effect.
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
164
|
}, [])
|
|
164
165
|
|
|
165
166
|
// Transition to 'ready' once both measurements are available
|