Detect ScrollView has reached the end - react-native

I have a Text with long text inside a ScrollView and I want to detect when the user has scrolled to the end of the text so I can enable a button.
I've been debugging the event object from the onScroll event but there doesn't seem any value I can use.

I did it like this:
import React from 'react';
import {ScrollView, Text} from 'react-native';
const isCloseToBottom = ({layoutMeasurement, contentOffset, contentSize}) => {
const paddingToBottom = 20;
return layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
};
const MyCoolScrollViewComponent = ({enableSomeButton}) => (
<ScrollView
onScroll={({nativeEvent}) => {
if (isCloseToBottom(nativeEvent)) {
enableSomeButton();
}
}}
scrollEventThrottle={400}
>
<Text>Here is very long lorem ipsum or something...</Text>
</ScrollView>
);
export default MyCoolScrollViewComponent;
I wanted to add paddingToBottom because usually it is not needed that ScrollView is scrolled to the bottom till last pixel. But if you want that set paddingToBottom to zero.

As people helped here I will add the simple code they write to make reached to top and reached to bottom event and I did a little illustration to make things simpler
<ScrollView
onScroll={({nativeEvent})=>{
if(isCloseToTop(nativeEvent)){
//do something
}
if(isCloseToBottom(nativeEvent)){
//do something
}
}}
>
...contents
</ScrollView>
isCloseToBottom({layoutMeasurement, contentOffset, contentSize}){
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
ifCloseToTop({layoutMeasurement, contentOffset, contentSize}){
return contentOffset.y == 0;
}

<... onScroll={(e) => {
let paddingToBottom = 10;
paddingToBottom += e.nativeEvent.layoutMeasurement.height;
if(e.nativeEvent.contentOffset.y >= e.nativeEvent.contentSize.height - paddingToBottom) {
// make something...
}
}}>...
like this react-native 0.44

For Horizontal ScrollView (e.g. Carousels) replace isCloseToBottom function with isCloseToRight
isCloseToRight = ({ layoutMeasurement, contentOffset, contentSize }) => {
const paddingToRight = 20;
return layoutMeasurement.width + contentOffset.x >= contentSize.width - paddingToRight;
};

Another solution could be to use a ListView with a single row (your text) which has onEndReached method. See the documentation here

#Henrik R's right.
But you should use Math.ceil() too.
function handleInfinityScroll(event) {
let mHeight = event.nativeEvent.layoutMeasurement.height;
let cSize = event.nativeEvent.contentSize.height;
let Y = event.nativeEvent.contentOffset.y;
if(Math.ceil(mHeight + Y) >= cSize) return true;
return false;
}

As an addition to the answer of Henrik R:
If you need to know wether the user has reached the end of the content at mount time (if the content may or may not be too long, depending on device size) - here is my solution:
<ScrollView
onLayout={this.onLayoutScrollView}
onScroll={this.onScroll}>
<View onLayout={this.onLayoutScrollContent}>
{/*...*/}
</View>
</ScrollView>
in combination with
onLayout(wrapper, { nativeEvent }) {
if (wrapper) {
this.setState({
wrapperHeight: nativeEvent.layout.height,
});
} else {
this.setState({
contentHeight: nativeEvent.layout.height,
isCloseToBottom:
this.state.wrapperHeight - nativeEvent.layout.height >= 0,
});
}
}

const isCloseToBottom = async ({ layoutMeasurement, contentOffset, contentSize }) => {
const paddingToBottom = 120
return layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom}
<ScrollView
onMomentumScrollEnd={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
loadMoreData()
}
}}
scrollEventThrottle={1}
>
Above answer is correct but to callback on reaching the end in scrollView use onMomentumScrollEnd not onScroll

I use ScrollView and this worked for me
Here is my solution:
I passed onMomentumScrollEnd prop to scrollView and on the basis event.nativeEvent I achieved onEndReached functionality in ScrollView
onMomentumScrollEnd={(event) => {
if (isCloseToBottom(event.nativeEvent)) {
LoadMoreRandomData()
}
}
}}
const isCloseToBottom = ({layoutMeasurement, contentOffset, contentSize}) => {
const paddingToBottom = 20;
return layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
};

you can use this function onMomentumScrollEnd to know scroll information (event)
<ScrollView onMomentumScrollEnd={({ nativeEvent }) => {
handleScroll(nativeEvent)
}}>
and with these measure (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom)
you can know if the scroll is at the end
const handleScroll = ({ layoutMeasurement, contentOffset, contentSize }) => {
const paddingToBottom = 20;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
...
}
};

disregard all convoluted answers above. this works.
import React, { useState } from 'react';
import { ScrollView } from 'react-native';
function Component() {
const [momentum, setMomentum] = useState(false)
return (
<ScrollView
onMomentumScrollBegin={() => setMomentum(true)}
onEndReached={momentum === true ? console.log('end reached.') : null}
onEndReachedThreshold={0}
>
<Text>Filler Text 01</Text>
<Text>Filler Text 02</Text>
<Text>Filler Text 03</Text>
<Text>Filler Text 04</Text>
<Text>Filler Text 05</Text>
</ScrollView>
)
}

Related

How to update theme when device orientation changes

I am trying to implement orientation changing with hooks. I called the orientation hook from app.tsx and I want to update everything(theme,style in component) that uses widthPercentageToDP() function. How can I achieve this. I can't figured out.
useOrientation.tsx
export let { width, height } = Dimensions.get("window");
const heightPercentageToDP = (heightPercent: string | number): number => {
// Parse string percentage input and convert it to number.
const elemHeight =
typeof heightPercent === "number"
? heightPercent
: parseFloat(heightPercent);
// Use PixelRatio.roundToNearestPixel method in order to round the layout
// size (dp) to the nearest one that correspons to an integer number of pixels.
return PixelRatio.roundToNearestPixel((height * elemHeight) / 100);
};
export const useScreenDimensions = () => {
const [screenData, setScreenData] = useState({});
useEffect(() => {
setScreenData({orientation:currentOrientation()});
Dimensions.addEventListener("change", (newDimensions) => {
width = newDimensions.screen.width;
height = newDimensions.screen.height;
setScreenData({orientation:currentOrientation()}); // can be used with this height and width
//console.log(newDimensions.window);
});
return () => Dimensions.removeEventListener("change", () => {});
});
return {
width,height,
screenData
};
};
Theme file
const theme = {
spacing: {
m:widthPercentageToDP("2%") // it must be updated when orientation changes.
},
borderRadii: {
s:widthPercentageToDP("5%") // it must be updated when orientation changes.
},
textVariants: {
body:{
fontSize:widthPercentageToDP("%3"),
}
},
};
App.tsx
const {screenData} = useScreenDimensions();
console.log(screenData)
return (
<ThemeProvider>
<LoadAssets {...{ fonts, assets }}>
<Example/>
</LoadAssets>
</ThemeProvider>
);
}
Example.tsx
export const Example = ({}) => {
return (
<Box>
<Text variant="body">hey</Text>
{/* // it must be updated when orientation changes. */}
<View style={{width:widthPercentageToDP("40%")}}/>
</Box>
);
}
Box and theme come from theme.tsx file. Text component accepts variant prop that defined in theme.tsx
Using react-native-orientation you can do what you want, then the device orientation changes.
Example:
import Orientation from 'react-native-orientation';
export default class AppScreen extends Component {
componentWillMount() {
const initial = Orientation.getInitialOrientation();
if (initial === 'PORTRAIT') {
// do something
} else {
// do something else
}
},
componentDidMount() {
// this will listen for changes
Orientation.addOrientationListener(this._orientationDidChange);
},
_orientationDidChange = (orientation) => {
if (orientation === 'LANDSCAPE') {
// do something with landscape layout
} else {
// do something with portrait layout
}
},
componentWillUnmount() {
// Remember to remove listener to prevent memory leaks
Orientation.removeOrientationListener(this._orientationDidChange);
}

How to detect the swipe back action in react-native(react-navigation)? [duplicate]

How can I detect a left swipe on the entire screen in React Native?
Would it be necessary to use PanResponder or can it be done a little more easy?
I've found that react-native-swipe-gestures isn't stable (swipes works randomly on android) and react-native-gesture-handler is overcomplicated (too much efforts to just add to project).
Simplified solution based on Kuza Grave's answer, who's solution works perfect and very simple:
<View
onTouchStart={e=> this.touchY = e.nativeEvent.pageY}
onTouchEnd={e => {
if (this.touchY - e.nativeEvent.pageY > 20)
console.log('Swiped up')
}}
style={{height: 300, backgroundColor: '#ccc'}}
/>
I made this simple solution using scrollviews and touch position.
It has a very clean implementation with no heavy components or external modules.
You can also use this with <View> components instead of scrollviews.
So first, we will be creating a hook: useSwipe.tsx
import { Dimensions } from 'react-native';
const windowWidth = Dimensions.get('window').width;
export function useSwipe(onSwipeLeft?: any, onSwipeRight?: any, rangeOffset = 4) {
let firstTouch = 0
// set user touch start position
function onTouchStart(e: any) {
firstTouch = e.nativeEvent.pageX
}
// when touch ends check for swipe directions
function onTouchEnd(e: any){
// get touch position and screen size
const positionX = e.nativeEvent.pageX
const range = windowWidth / rangeOffset
// check if position is growing positively and has reached specified range
if(positionX - firstTouch > range){
onSwipeRight && onSwipeRight()
}
// check if position is growing negatively and has reached specified range
else if(firstTouch - positionX > range){
onSwipeLeft && onSwipeLeft()
}
}
return {onTouchStart, onTouchEnd};
}
then, in your component... in my case im going to use: exampleComponent.tsx
Import the previous useSwipe hook.
Add onTouchStart and onTouchEnd events to your scrollView.
ExampleComponent
import * as React from 'react';
import { ScrollView } from 'react-native';
import { useSwipe } from '../hooks/useSwipe'
export function ExampleComponent(props: any) {
const { onTouchStart, onTouchEnd } = useSwipe(onSwipeLeft, onSwipeRight, 6)
function onSwipeLeft(){
console.log('SWIPE_LEFT')
}
function onSwipeRight(){
console.log('SWIPE_RIGHT')
}
return (
<ScrollView onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{props.children}
</ScrollView>
);
}
You can mess around with the offsetRange property to handle precision.
And adapt the original code to be used with normal class components instead of hooks.
There is an existing component react-native-swipe-gestures for handling swipe gestures in up, down, left and right direction, see https://github.com/glepur/react-native-swipe-gestures
You can use react-native-swipe-gesture. You don't need to install any third party module using npm. Download the file into your project and follow the given steps
If you are using a managed Expo project there is a documented gesture handler here: https://docs.expo.io/versions/latest/sdk/gesture-handler/
The source code docs can be found here: https://docs.swmansion.com/react-native-gesture-handler/docs/
For swiping I think you would need to use FlingGestureHandler:
https://docs.swmansion.com/react-native-gesture-handler/docs/handler-fling
You can just user FlingGestureHandler from react-native-gesture-handler link to the docs. Wrap your view with it. Here's you do it.
import { Directions, Gesture, GestureDetector } from 'react-native-gesture-handler'
const MyComponentWithLeftSwipe = () => {
const flingGestureLeft = Gesture
.Fling()
.direction(Directions.LEFT)
.onEnd(() => console.log("I was swiped!")
return <GestureDetector gesture={flingGestureLeft}>
<View>
...
</View>
</GestureDetector>
}
Credits to #Nikhil Gogineni!
I modified his code to a functional component without a componentWillMount.
SwipeGesture.tsx
import React, { useEffect } from 'react';
import {
View,
Animated,
PanResponder
} from 'react-native';
/* Credits to: https://github.com/nikhil-gogineni/react-native-swipe-gesture */
const SwipeGesture = (props: any) => {
const panResponder = React.useRef(
PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onPanResponderRelease: (evt, gestureState) => {
const x = gestureState.dx;
const y = gestureState.dy;
if (Math.abs(x) > Math.abs(y)) {
if (x >= 0) {
props.onSwipePerformed('right')
}
else {
props.onSwipePerformed('left')
}
}
else {
if (y >= 0) {
props.onSwipePerformed('down')
}
else {
props.onSwipePerformed('up')
}
}
}
})).current;
return (
<Animated.View {...panResponder.panHandlers} style={props.gestureStyle}>
<View>{props.children}</View>
</Animated.View>
)
}
export default SwipeGesture;
And the usage is the "same" ...
Thanks Nikhil!
I was using the solution provided by Kuza Grave but encountered a bug on a Samsung Galaxy phone where onTouchEnd was not being fired as expected. I ended up creating another implementation using the PanResponder.
SwipeContainer.tsx
import React, { FC, ReactNode } from "react";
import { View, Animated, PanResponder } from "react-native";
type Props = {
children: ReactNode;
onSwipeRight: () => void;
onSwipeLeft: () => void;
};
const SWIPE_THRESHOLD = 200;
export const SwipeContainer: FC<Props> = ({
children,
onSwipeLeft,
onSwipeRight,
}) => {
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: (_evt, _gestureState) => true,
onPanResponderRelease: (_evt, gestureState) => {
const { dx } = gestureState;
if (dx > SWIPE_THRESHOLD) {
onSwipeRight();
}
if (dx < -SWIPE_THRESHOLD) {
onSwipeLeft();
}
// If needed, could add up and down swipes here with `gestureState.dy`
},
});
return (
<Animated.View {...panResponder.panHandlers}>
<View>{children}</View>
</Animated.View>
);
Example.tsx:
import React, { FC } from "react";
import { ChildComponent1, ChildComponent2, SwipeContainer } from "./components";
export const Example: FC = () => {
const left = () => console.log("left");
const right = () => console.log("right");
return (
<SwipeContainer onSwipeLeft={left} onSwipeRight={right}>
<ChildComponent1 />
<ChildComponent2 />
</SwipeContainer>
);
};

How-to make React Native lists bounce only from the bottom?

I'm using a FlatList with a consequent ListHeaderComponent as the root component of my screen. I don't want the top of it to bounce on scroll, but I want to keep the bottom bouncing for UX'n'feel purpose as the FlatList supports infinite scroll.
Any ideas how-to?
A solution would be to listen to scroll events and check whether the bounces prop inherited from ScrollView should be enabled or not. Note that I personally find this solution to be a bit of an overkill, but it works as expected.
You can find a fully working example at the following URL: https://snack.expo.io/SkL-L0knZ. You can preview it right in the browser and you can also try it on your mobile device with the Expo app.
Here is the result (forget about the lag, as this was captured in the browser):
And here is the relevant source code:
export default class App extends Component {
constructor (props) {
super(props);
this.state = { bounces: false };
this.renderHeader = this.renderHeader.bind(this);
this._onScroll = this._onScroll.bind(this);
}
_onScroll (event) {
const scrollPosition = event && event.nativeEvent && event.nativeEvent.contentOffset && event.nativeEvent.contentOffset.y;
let newBouncesValue;
if (scrollPosition < viewportHeight / 3) {
newBouncesValue = false;
} else {
newBouncesValue = true;
}
if (newBouncesValue === this.state.bounces) {
return;
}
this.setState({ bounces: newBouncesValue });
}
renderHeader () {
const { bounces } = this.state;
const style = [
styles.header,
{ backgroundColor : bounces ? 'darkseagreen' : 'firebrick'}
];
return (
<Text style={style}>{ bounces ? 'CAN BOUNCE' : "WON'T BOUNCE"}</Text>
);
}
renderItem ({item}) {
return (
<Text style={styles.row}>{item.key}</Text>
);
}
render() {
return (
<View style={styles.container}>
{ this.renderHeader() }
<FlatList
data={DUMMY_DATA}
renderItem={this.renderItem}
onScroll={this._onScroll}
bounces={this.state.bounces}
scrollEventThrottle={16}
/>
</View>
);
}
}

Scrolling issues with FlatList when rows are variable height

I'm using a FlatList where each row can be of different height (and may contain a mix of both text and zero or more images from a remote server).
I cannot use getItemLayout because I don't know the height of each row (nor the previous ones) to be able to calculate.
The problem I'm facing is that I cannot scroll to the end of the list (it jumps back few rows when I try) and I'm having issues when trying to use scrollToIndex (I'm guessing due to the fact I'm missing getItemLayout).
I wrote a sample project to demonstrate the problem:
import React, { Component } from 'react';
import { AppRegistry, StyleSheet, Text, View, Image, FlatList } from 'react-native';
import autobind from 'autobind-decorator';
const items = count => [...Array(count)].map((v, i) => ({
key: i,
index: i,
image: 'https://dummyimage.com/600x' + (((i % 4) + 1) * 50) + '/000/fff',
}));
class RemoteImage extends Component {
constructor(props) {
super(props);
this.state = {
style: { flex: 1, height: 0 },
};
}
componentDidMount() {
Image.getSize(this.props.src, (width, height) => {
this.image = { width, height };
this.onLayout();
});
}
#autobind
onLayout(event) {
if (event) {
this.layout = {
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
};
}
if (!this.layout || !this.image || !this.image.width)
return;
this.setState({
style: {
flex: 1,
height: Math.min(this.image.height,
Math.floor(this.layout.width * this.image.height / this.image.width)),
},
});
}
render() {
return (
<Image
onLayout={this.onLayout}
source={{ uri: this.props.src }}
style={this.state.style}
resizeMode='contain'
/>
);
}
}
class Row extends Component {
#autobind
onLayout({ nativeEvent }) {
let { index, item, onItemLayout } = this.props;
let height = Math.max(nativeEvent.layout.height, item.height || 0);
if (height != item.height)
onItemLayout(index, { height });
}
render() {
let { index, image } = this.props.item;
return (
<View style={[styles.row, this.props.style]}>
<Text>Header {index}</Text>
<RemoteImage src = { image } />
<Text>Footer {index}</Text>
</View>
);
}
}
export default class FlatListTest extends Component {
constructor(props) {
super(props);
this.state = { items: items(50) };
}
#autobind
renderItem({ item, index }) {
return <Row
item={item}
style={index&1 && styles.row_alternate || null}
onItemLayout={this.onItemLayout}
/>;
}
#autobind
onItemLayout(index, props) {
let items = [...this.state.items];
let item = { ...items[index], ...props };
items[index] = { ...item, key: [item.height, item.index].join('_') };
this.setState({ items });
}
render() {
return (
<FlatList
ref={ref => this.list = ref}
data={this.state.items}
renderItem={this.renderItem}
/>
);
}
}
const styles = StyleSheet.create({
row: {
padding: 5,
},
row_alternate: {
backgroundColor: '#bbbbbb',
},
});
AppRegistry.registerComponent('FlatListTest', () => FlatListTest);
Use scrollToOffset() instead:
export default class List extends React.PureComponent {
// Gets the total height of the elements that come before
// element with passed index
getOffsetByIndex(index) {
let offset = 0;
for (let i = 0; i < index; i += 1) {
const elementLayout = this._layouts[i];
if (elementLayout && elementLayout.height) {
offset += this._layouts[i].height;
}
}
return offset;
}
// Gets the comment object and if it is a comment
// is in the list, then scrolls to it
scrollToComment(comment) {
const { list } = this.props;
const commentIndex = list.findIndex(({ id }) => id === comment.id);
if (commentIndex !== -1) {
const offset = this.getOffsetByIndex(commentIndex);
this._flatList.current.scrollToOffset({ offset, animated: true });
}
}
// Fill the list of objects with element sizes
addToLayoutsMap(layout, index) {
this._layouts[index] = layout;
}
render() {
const { list } = this.props;
return (
<FlatList
data={list}
keyExtractor={item => item.id}
renderItem={({ item, index }) => {
return (
<View
onLayout={({ nativeEvent: { layout } }) => {
this.addToLayoutsMap(layout, index);
}}
>
<Comment id={item.id} />
</View>
);
}}
ref={this._flatList}
/>
);
}
}
When rendering, I get the size of each element of the list and write it into an array:
onLayout={({ nativeEvent: { layout } }) => this._layouts[index] = layout}
When it is necessary to scroll the screen to the element, I summarize the heights of all the elements in front of it and get the amount to which to scroll the screen (getOffsetByIndex method).
I use the scrollToOffset method:
this._flatList.current.scrollToOffset({ offset, animated: true });
(this._flatList is ref of FlatList)
So what I think you can do and what you already have the outlets for is to store a collection by the index of the rows layouts onLayout. You'll want to store the attributes that's returned by getItemLayout: {length: number, offset: number, index: number}.
Then when you implement getItemLayout which passes an index you can return the layout that you've stored. This should resolve the issues with scrollToIndex. Haven't tested this, but this seems like the right approach.
Have you tried scrollToEnd?
http://facebook.github.io/react-native/docs/flatlist.html#scrolltoend
As the documentation states, it may be janky without getItemLayout but for me it does work without it
I did not find any way to use getItemLayout when the rows have variable heights , So you can not use initialScrollIndex .
But I have a solution that may be a bit slow:
You can use scrollToIndex , but when your item is rendered . So you need initialNumToRender .
You have to wait for the item to be rendered and after use scrollToIndex so you can not use scrollToIndex in componentDidMount .
The only solution that comes to my mind is using scrollToIndex in onViewableItemsChanged . Take note of the example below :
In this example, we want to go to item this.props.index as soon as this component is run
constructor(props){
this.goToIndex = true;
}
render() {
return (
<FlatList
ref={component => {this.myFlatList = component;}}
data={data}
renderItem={({item})=>this._renderItem(item)}
keyExtractor={(item,index)=>index.toString()}
initialNumToRender={this.props.index+1}
onViewableItemsChanged={({ viewableItems }) => {
if (this.goToIndex){
this.goToIndex = false;
setTimeout(() => { this.myFlatList.scrollToIndex({index:this.props.index}); }, 10);
}
}}
/>
);
}
You can use onScrollToIndexFailed to avoid getItemLayout
onScrollToIndexFailed={info => {
const wait = new Promise(resolve => setTimeout(resolve, 100));
wait.then(() => {
refContainer.current?.scrollToIndex({
index: pinPosition || 0,
animated: true
});
});
}}

How to get the position of the currently focussed input?

I need to know the position of the currently focussed input to make some calculations. At the end I want to call .measure() on it.
What I got:
import { TextInput } from 'react-native';
const { State: TextInputState } = TextInput;
var currentlyFocussedField = TextInputState.currentlyFocusedField();
console.log('currentlyFocussedField', currentlyFocussedField);
But this only returns the id of the text field (like 394, and the next field is 395). How can I access the real element/object or how do I get its position on the screen?
I don't know how you can access the React component from the numeric reactTag in javascript, but you can use the same UIManager.measure(reactTag, callback) function that RN uses internally to make .measure() work.
It's not documented, but you can find the source here, for iOS, and here for Android.
You can use this to get the all the size values of the native view. For example, drawing from your example above:
import { UIManager, TextInput } from 'react-native';
const { State: TextInputState } = TextInput;
const currentlyFocusedField = TextInputState.currentlyFocusedField();
UIManager.measure(currentlyFocusedField, (originX, originY, width, height, pageX, pageY) => {
console.log(originX, originY, width, height, pageX, pageY);
});
This is how I'm getting the position of a focused input to scroll to it:
import { findNodeHandle, ScrollView, TextInput, UIManager } from 'react-native'
class SomeForm extends Component {
scrollTo (element) {
const handle = findNodeHandle(this[element])
if (handle) {
UIManager.measure(handle, (x, y) => {
this._scrollView.scrollTo({
animated: true,
y
})
})
}
}
render () {
return (
<ScrollView
ref={(element) => {
this._scrollView = element
}}
>
<TextInput
onFocus={() => this.scrollTo('_inputName')}
ref={(element) => {
this._inputName = element
}}
/>
</ScrollView>
)
}
}
Instead of:
var currentlyFocussedField = TextInputState.currentlyFocusedField();
use:
var currentlyFocussedField = TextInputState.currentlyFocusedInput();
Then you can:
currentlyFocussedField.measure(...)