diff --git a/example/assets/albums/american-psycho.jpg b/example/assets/albums/american-psycho.jpg new file mode 100644 index 0000000..fbaf766 Binary files /dev/null and b/example/assets/albums/american-psycho.jpg differ diff --git a/example/assets/albums/doggstyle.jpg b/example/assets/albums/doggstyle.jpg new file mode 100644 index 0000000..f464379 Binary files /dev/null and b/example/assets/albums/doggstyle.jpg differ diff --git a/example/assets/albums/dude-ranch.jpg b/example/assets/albums/dude-ranch.jpg new file mode 100644 index 0000000..be1d69d Binary files /dev/null and b/example/assets/albums/dude-ranch.jpg differ diff --git a/example/assets/albums/in_utero.jpg b/example/assets/albums/in_utero.jpg new file mode 100644 index 0000000..50ade17 Binary files /dev/null and b/example/assets/albums/in_utero.jpg differ diff --git a/example/assets/albums/is-this-it.jpg b/example/assets/albums/is-this-it.jpg new file mode 100644 index 0000000..d491c10 Binary files /dev/null and b/example/assets/albums/is-this-it.jpg differ diff --git a/example/assets/albums/let-it-be.jpg b/example/assets/albums/let-it-be.jpg new file mode 100644 index 0000000..2df226f Binary files /dev/null and b/example/assets/albums/let-it-be.jpg differ diff --git a/example/assets/albums/rip-this.jpeg b/example/assets/albums/rip-this.jpeg new file mode 100644 index 0000000..b2a3bbf Binary files /dev/null and b/example/assets/albums/rip-this.jpeg differ diff --git a/example/assets/albums/robbin-the-hood.jpg b/example/assets/albums/robbin-the-hood.jpg new file mode 100644 index 0000000..ba77c1a Binary files /dev/null and b/example/assets/albums/robbin-the-hood.jpg differ diff --git a/example/assets/albums/spilt-milk.jpg b/example/assets/albums/spilt-milk.jpg new file mode 100644 index 0000000..20d642e Binary files /dev/null and b/example/assets/albums/spilt-milk.jpg differ diff --git a/example/assets/albums/suffer.jpg b/example/assets/albums/suffer.jpg new file mode 100644 index 0000000..f64f6d9 Binary files /dev/null and b/example/assets/albums/suffer.jpg differ diff --git a/example/assets/albums/t-hives.jpg b/example/assets/albums/t-hives.jpg new file mode 100644 index 0000000..f3f0364 Binary files /dev/null and b/example/assets/albums/t-hives.jpg differ diff --git a/example/assets/albums/trendkill.jpg b/example/assets/albums/trendkill.jpg new file mode 100644 index 0000000..f03e7a8 Binary files /dev/null and b/example/assets/albums/trendkill.jpg differ diff --git a/example/assets/albums/wysiatwin.jpg b/example/assets/albums/wysiatwin.jpg new file mode 100644 index 0000000..707694b Binary files /dev/null and b/example/assets/albums/wysiatwin.jpg differ diff --git a/example/assets/albums/youre-welcome.jpeg b/example/assets/albums/youre-welcome.jpeg new file mode 100644 index 0000000..d91daa5 Binary files /dev/null and b/example/assets/albums/youre-welcome.jpeg differ diff --git a/example/src/App.tsx b/example/src/App.tsx index cd3185e..64e7c52 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,23 +1,26 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { random } from 'lodash'; import shuffle from 'lodash/shuffle'; -import { StyleSheet, Text, View } from 'react-native'; +import { ImageSourcePropType, StyleSheet, Text, View } from 'react-native'; import { LazyScrollView } from 'react-native-lazy-scrollview'; import { ColorBlock } from './components/ColorBlock'; -const ALBUMS = [ - 'https://audioxide.com/api/images/album-artwork/in-utero-nirvana-medium-square.jpg', - 'https://audioxide.com/api/images/album-artwork/pinkerton-weezer-medium-square.jpg', - 'https://static1.squarespace.com/static/55b7b1bde4b0828f7d1438de/t/6091766fa1c5b50917511c4b/1620145812302/WAVVES+-+Hideaway+-+Album+Cover+3000x3000.jpg?format=1500w', - 'https://i.scdn.co/image/ab67616d0000b27313f2466b83507515291acce4', - 'https://m.media-amazon.com/images/I/515NY3NS1EL._UF1000,1000_QL80_.jpg', - 'https://images.squarespace-cdn.com/content/v1/5e270e203c421657bd3e3208/1584536470858-1OSWN7WUQ3DY749LGHOU/R-1843384-1282324049.jpeg.jpg', +const ALBUMS: ImageSourcePropType = [ + require('../assets/albums/american-psycho.jpg'), + require('../assets/albums/doggstyle.jpg'), + require('../assets/albums/dude-ranch.jpg'), + require('../assets/albums/in_utero.jpg'), + require('../assets/albums/is-this-it.jpg'), + require('../assets/albums/let-it-be.jpg'), + require('../assets/albums/rip-this.jpeg'), + require('../assets/albums/spilt-milk.jpg'), + require('../assets/albums/suffer.jpg'), + require('../assets/albums/t-hives.jpg'), + require('../assets/albums/trendkill.jpg'), + require('../assets/albums/wysiatwin.jpg'), + require('../assets/albums/youre-welcome.jpeg'), null, - 'https://m.media-amazon.com/images/I/61vMlYT58HL._UF1000,1000_QL80_.jpg', - 'https://e.snmc.io/i/1200/s/9a0f51b8b776171aaee65c1a352f6d11/2396429', - 'https://global-uploads.webflow.com/5fda4244c42e015b06d2fd8d/5fdbca8f15a8ee0f0f369767_cover.jpg', - 'https://i.discogs.com/GXyIsX5gBpgw3XMtEZ-FvyVJBDZyk7Aoq-bPzV_yIXk/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTIyNDY1/MTUtMTI5OTE5MjI1/NS5qcGVn.jpeg', null, ]; @@ -25,8 +28,11 @@ const OFFSET = -100; const SHUFFLED_ALBUMS = shuffle(ALBUMS); export default function App() { - const renderBlock = (uri: string | null, i: number) => ( - + const renderBlock = useCallback( + (source: ImageSourcePropType | null, i: number) => ( + + ), + [] ); return ( @@ -66,7 +72,6 @@ const styles = StyleSheet.create({ opacity: 0.7, height: 50, justifyContent: 'flex-end', - alignItems: 'center', }, offsetText: { color: 'white', @@ -74,5 +79,6 @@ const styles = StyleSheet.create({ fontWeight: '600', backgroundColor: '#000', padding: 8, + alignSelf: 'flex-start', }, }); diff --git a/example/src/components/ColorBlock.tsx b/example/src/components/ColorBlock.tsx index af9a9a6..fd0b21e 100644 --- a/example/src/components/ColorBlock.tsx +++ b/example/src/components/ColorBlock.tsx @@ -1,6 +1,13 @@ import sample from 'lodash/sample'; import React, { useMemo, useState } from 'react'; -import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native'; +import { + ActivityIndicator, + Image, + ImageSourcePropType, + StyleSheet, + Text, + View, +} from 'react-native'; import { LazyChild } from 'react-native-lazy-scrollview'; const NO_LAZY_CHILD_BACKGROUNDS = [ @@ -11,33 +18,38 @@ const NO_LAZY_CHILD_BACKGROUNDS = [ '#1e90ff', ]; -const PERCENT = 0.75; +const PERCENT = 0.9; const PERCENT_STRING = `${PERCENT * 100}%`; -const PERCENT_TEXT = `${PERCENT_STRING} threshold passed`; +const PERCENT_TEXT = `${PERCENT_STRING} not visible`; export function ColorBlock({ - uri, + source, nested, }: { - uri: string | null; + source: ImageSourcePropType | null; nested?: boolean; }) { const [triggered, setTriggered] = useState(false); - const [percentTriggered, setPercentTriggered] = useState(false); + const [isVisible, setIsVisible] = useState(false); const onThresholdPass = () => { // Make api call setTriggered(true); }; - const onPercentVisibleThresholdPass = () => { - // Make analytic call - setPercentTriggered(true); + const onVisibilityEnter = () => { + console.log('ENTER', source?.toString()); + setIsVisible(true); + }; + + const onVisibilityExit = () => { + console.log('EXIT', source?.toString()); + setIsVisible(false); }; const backgroundColor = useMemo(() => sample(NO_LAZY_CHILD_BACKGROUNDS), []); - if (!uri) { + if (!source) { return ( @@ -55,19 +67,21 @@ export function ColorBlock({ {triggered ? ( - + ) : ( )} - {percentTriggered && ( - {PERCENT_TEXT} + {!isVisible && ( + + {PERCENT_TEXT} + )} - @@ -77,19 +91,21 @@ export function ColorBlock({ return ( {triggered ? ( - + ) : ( )} - {percentTriggered && ( - {PERCENT_TEXT} + {!isVisible && ( + + {PERCENT_TEXT} + )} - ); @@ -125,22 +141,20 @@ const styles = StyleSheet.create({ backgroundColor: 'white', margin: 16, }, - percentText: { + percentTextWrapper: { position: 'absolute', top: 0, right: 0, - backgroundColor: 'rgba(255, 255, 255, 0.8)', + bottom: 0, + left: 0, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + percentText: { color: 'red', fontSize: 16, padding: 8, - width: '100%', - }, - percentLine: { - position: 'absolute', - top: PERCENT_STRING, - left: 0, - right: 0, - height: 2, - backgroundColor: 'red', + textAlign: 'center', }, }); diff --git a/package.json b/package.json index 6179e94..57f9495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-lazy-scrollview", - "version": "0.9.0", + "version": "0.9.1", "description": "Lazy ScrollView for React Native.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/components/LazyChild.tsx b/src/components/LazyChild.tsx index 0ef6551..94badf2 100644 --- a/src/components/LazyChild.tsx +++ b/src/components/LazyChild.tsx @@ -8,7 +8,6 @@ import Animated, { useSharedValue, } from 'react-native-reanimated'; import { useAnimatedContext } from '../context/AnimatedContext'; -import { Platform } from 'react-native'; interface Props { children: React.ReactNode; @@ -23,10 +22,15 @@ interface Props { /** * Callback to fire when the LazyChild's viewable area exceeds the percentVisibleThreshold. */ - onPercentVisibleThresholdPass?: () => void; + onVisibilityEnter?: () => void; + /** + * Callback to fire when the LazyChild's viewable area goes under the percentVisibleThreshold after being above it. + */ + onVisibilityExit?: () => void; /** * Protects against firing callback on measurement with zero value. Default is true. Good to set to false if you know the LazyChild is the first item in the LazyScrollview. */ + // TODO Is there a way to use height here? I know this is re: 0 as a Y measurement and the issue is with the views all starting at 0. Need a more reliable way to check if the view is at the top of the scrollview or hasn't rendered properly yet. ignoreZeroMeasurement?: boolean; } @@ -37,21 +41,16 @@ export function LazyChild({ children, onThresholdPass, percentVisibleThreshold = 1, - onPercentVisibleThresholdPass, ignoreZeroMeasurement = true, + onVisibilityEnter, + onVisibilityExit, }: Props) { - const { triggerValue, hasReachedEnd, scrollValue, bottomYValue } = + const { triggerValue, hasReachedEnd, scrollValue, topYValue, bottomYValue } = useAnimatedContext(); const _viewRef = useAnimatedRef(); const _hasFiredScrollViewThresholdTrigger = useSharedValue(false); const _ignoreZeroMeasurement = useSharedValue(ignoreZeroMeasurement); - const _isAndroid = useSharedValue(Platform.OS === 'android'); - const _canMeasure = useDerivedValue( - // https://github.com/software-mansion/react-native-reanimated/issues/5006#issuecomment-1826495797 - // Running same check on iOS sometimes causes the view to not be measured - () => !_isAndroid.value || (_viewRef.current && _isAndroid.value) - ); const handleScrollViewThresholdPass = useCallback(() => { if (!_hasFiredScrollViewThresholdTrigger.value) { @@ -70,10 +69,6 @@ export function LazyChild({ return true; } - if (!_canMeasure) { - return false; - } - const measurement = measure(_viewRef); // Track scollValue to make reaction fire @@ -98,68 +93,93 @@ export function LazyChild({ ); const _shouldMeasurePercentVisible = useSharedValue( - typeof onPercentVisibleThresholdPass === 'function' + typeof onVisibilityEnter === 'function' + ); + const _shouldFireVisibilityExit = useSharedValue( + typeof onVisibilityExit === 'function' ); const _percentVisibleTrigger = useSharedValue(percentVisibleThreshold); - const _hasFiredPercentVisibleTrigger = useSharedValue(false); + const _hasFiredOnVisibilityEntered = useSharedValue(false); + const _hasFiredOnVisibilityExited = useSharedValue(false); + + const handleOnVisibilityEntered = useCallback(() => { + if (onVisibilityEnter && !_hasFiredOnVisibilityEntered.value) { + _hasFiredOnVisibilityEntered.value = true; + _hasFiredOnVisibilityExited.value = false; + onVisibilityEnter(); + } + }, [ + _hasFiredOnVisibilityEntered, + _hasFiredOnVisibilityExited, + onVisibilityEnter, + ]); - const handlePercentTrigger = useCallback(() => { + const handleOnVisibilityExited = useCallback(() => { if ( - !_hasFiredPercentVisibleTrigger.value && - onPercentVisibleThresholdPass + onVisibilityExit && + _hasFiredOnVisibilityEntered.value && + !_hasFiredOnVisibilityExited.value ) { - _hasFiredPercentVisibleTrigger.value = true; - onPercentVisibleThresholdPass(); + _hasFiredOnVisibilityEntered.value = false; + _hasFiredOnVisibilityExited.value = true; + onVisibilityExit(); } - }, [_hasFiredPercentVisibleTrigger, onPercentVisibleThresholdPass]); - - useAnimatedReaction( - () => { - if (!_shouldMeasurePercentVisible) { - return false; - } - - if (_hasFiredPercentVisibleTrigger.value) { - return false; - } - - if (hasReachedEnd.value) { - return true; - } - - if (!_canMeasure) { - return false; - } - + }, [ + _hasFiredOnVisibilityEntered, + _hasFiredOnVisibilityExited, + onVisibilityExit, + ]); + + const isVisible = useDerivedValue(() => { + if (_WORKLET) { const measurement = measure(_viewRef); // Track scollValue to make reaction fire if (measurement !== null && scrollValue.value > -1) { - if (_ignoreZeroMeasurement.value && measurement.pageY === 0) { + const topOfView = measurement.pageY; + const bottomOfView = measurement.pageY + measurement.height; + + if (_ignoreZeroMeasurement.value && topOfView === 0) { return false; } - const percentOffset = measurement.height * _percentVisibleTrigger.value; - const percentTrigger = bottomYValue.value - percentOffset; + const visibilityHeight = + measurement.height * _percentVisibleTrigger.value; + const visibleEnterTrigger = bottomYValue.value - visibilityHeight; + const visibleExitTrigger = topYValue.value + visibilityHeight; - if (percentTrigger <= 0) { + if (visibleEnterTrigger <= 0) { return false; } return ( - measurement.pageY < percentTrigger && - !_hasFiredPercentVisibleTrigger.value + topOfView < visibleEnterTrigger && bottomOfView > visibleExitTrigger ); } + } - return false; - }, - (shouldFirePercentTrigger) => { - if (shouldFirePercentTrigger) { - runOnJS(handlePercentTrigger)(); + return false; + }); + + useAnimatedReaction( + () => isVisible.value, + (isLazyChildVisible) => { + if (isLazyChildVisible) { + if (_shouldMeasurePercentVisible.value) { + runOnJS(handleOnVisibilityEntered)(); + } + } else { + if (_shouldFireVisibilityExit.value) { + runOnJS(handleOnVisibilityExited)(); + } } } ); - return {children}; + return ( + // https://github.com/software-mansion/react-native-reanimated/blob/d8ef9c27c31dd2c32d4c3a2111326a448bf19ec9/packages/react-native-reanimated/src/platformFunctions/measure.ts#L56 + + {children} + + ); } diff --git a/src/components/LazyScrollView.tsx b/src/components/LazyScrollView.tsx index c97f146..ddffe28 100644 --- a/src/components/LazyScrollView.tsx +++ b/src/components/LazyScrollView.tsx @@ -33,20 +33,21 @@ export function LazyScrollView({ const _offset = useSharedValue(injectedOffset || 0); const _containerHeight = useSharedValue(0); const _contentHeight = useSharedValue(0); - const _scrollViewTopY = useSharedValue(0); /** * Starts at 0 and increases as the user scrolls down */ const scrollValue = useScrollViewOffset(_scrollRef); const hasReachedEnd = useDerivedValue(() => { if (!_contentHeight.value || !_containerHeight.value) { + // Container and contend measurements have not completed return false; } return scrollValue.value >= _contentHeight.value - _containerHeight.value; }); + const topYValue = useSharedValue(0); const bottomYValue = useDerivedValue( - () => _containerHeight.value + _scrollViewTopY.value + () => _containerHeight.value + topYValue.value ); const triggerValue = useDerivedValue( () => bottomYValue.value + _offset.value @@ -57,12 +58,12 @@ export function LazyScrollView({ _containerHeight.value = e.nativeEvent.layout.height; _wrapperRef.current?.measureInWindow( (_: number, y: number, _2: number, height: number) => { - _scrollViewTopY.value = y; + topYValue.value = y; _contentHeight.value = height; } ); }, - [_containerHeight, _contentHeight, _scrollViewTopY] + [_containerHeight, _contentHeight, topYValue] ); const onContentContainerLayout = useCallback( @@ -78,7 +79,6 @@ export function LazyScrollView({ ref={_scrollRef} scrollEventThrottle={16} onLayout={onLayout} - // TODO handle x & width values if horizontal is true horizontal={false} > diff --git a/src/context/AnimatedContext.ts b/src/context/AnimatedContext.ts index 963c6a9..b2c5142 100644 --- a/src/context/AnimatedContext.ts +++ b/src/context/AnimatedContext.ts @@ -5,6 +5,7 @@ const initialContext = { triggerValue: { value: 0 }, scrollValue: { value: 0 }, bottomYValue: { value: 0 }, + topYValue: { value: 0 }, }; export const AnimatedContext = createContext(initialContext);