I have a FlatList that has 2 potential heights on a screen depending on what is being shown. There are top and bottom bars that show by default and when the FlatList is scrolled down, the bars are hidden and the FlatList expands to take up the full screen.
The problem is when the FlatList shifts between the 2 different heights, the content inside will shift slightly. I have already figured out that I need to apply a scroll offset of the height of the top bar (54), however i'm having issues doing that.
const [scrollOffset, setScrollOffset] = useState(0);
const [scrollDir, setScrollDir] = useState("neutral");
<FlatList ...
onScroll={(event) => {
const screenHeight = Dimensions.get('window').height - SAFE_AREA_INSETS.top - SAFE_AREA_INSETS.bottom;
const currentOffset = event.nativeEvent.contentOffset.y;
const diff = currentOffset - (scrollOffset || 0);
if (Math.abs(diff) < 3 || currentOffset < 0 || currentOffset > (event.nativeEvent.contentSize.height - screenHeight)) {
setScrollDir("neutral");
}
else if (diff < 0 && currentOffset <= (event.nativeEvent.contentSize.height - screenHeight)) {
// Top and bottom bars are shown and flatlist height is adjusted ...
if(scrollDir !== 'up') {
flatlistRef.current.scrollToOffset({ animated:false, offset:currentOffset + 54 }); // Not working as intended
}
setScrollDir('up');
}
else {
// Top and bottom bars are hidden and flatlist height is adjusted ...
if(scrollDir !== 'down') {
flatlistRef.current.scrollToOffset({ animated:false, offset:currentOffset - 54 }); // Not working as intended
}
setScrollDir('down');
}
setScrollOffset(currentOffset);
}}
The scrollToOffset method seems to trigger onScroll to think its going in the opposite direction which causes oscillations, which from the code makes sense. Any ideas to make this work correctly?
Edit:
Here is a link to a video of this happening
When I scroll up/down to unhide/hide the top and bottom bar, you'll see the content in the center jump up/down. It makes sense why this is happening, the height of the center content changes which causes the jump. I want the content to stay scrolled in the same position when changing the height.
Related
Is there an easy way to know when the user scrolls back up to top in a FlatList?
Specifically
FlatList renders normally
User scrolls down
And then user scrolls back to the top
I am looking to get an easy way to detect event #3.
I have looked at using onScroll and using the nativeEvent y offsets but is there an easier or more elegant solution?
The easiest way I found involves the e.nativeEvent.contentOffset.y
I noticed that the e.nativeEvent.contentOffset.y becomes 0 on Android when on top
while can become negative on iOS (over scroll)
To be able to act when user just starts to scroll from the top and when the user scrolls back right to the top, the following works fine for me.
(sample using a component)
constructor(props) {
super(props)
this.yOffset = 0;
}
onScroll = (e) => {
if (this.yOffset <= 0 && e.nativeEvent.contentOffset.y > 0) {
// case when user just started scrolling down from the top
} else if (this.yOffset > 0 && e.nativeEvent.contentOffset.y <= 0){
// case when the user scrolls back to the top
}
this.yOffset = e.nativeEvent.contentOffset.y;
}
I'm trying to render an curved vertical list like this iOS component: https://github.com/makotokw/CocoaWZYCircularTableView
That component (written in Obj-c) iterates the visible cells when laying them out, and sets the frame (i.e. indent) using asin.
I know in React Native I can set the leftMargin style in the renderItem callback, but I can't figure out how to get the on-screen index of the item - all I have is the index into the source data. And also, at that point, I don't think I have access to the absolute position.
Any ideas?
The function you are looking for is
onViewableItemsChanged.
You can use it with viewabilityConfig which provides us with
minimumViewTime,viewAreaCoveragePercentThreshold,waitForInteraction
which can be set accordingly
const VIEWABILITY_CONFIG = {
minimumViewTime: 3000,
viewAreaCoveragePercentThreshold: 100,
waitForInteraction: true,
};
_onViewableItemsChanged = (info: {
changed: Array<{
key: string,
isViewable: boolean,
item: any,
index: ?number,
section?: any,
}>
}
){
//here you can have the index which is visible to you
}
<FlatList
renderItem={this.renderItem}
data={this.state.data}
onViewableItemsChanged={this._onViewableItemsChanged}
viewabilityConfig={VIEWABILITY_CONFIG}
/>
Thanks for both answers.
What I have ended up doing is deriving the visible items using the scroll offset of the list. This is simple because the list items all have the same height.
I do this in the onScroll handler, and at that point I calculate the horizontal offset for each item (and I use leftMargin / rightMargin to render this). It's not perfect, but it does give me an elliptical list.
_handleScroll = (event) => {
const topItemIndex = Math.floor(event.nativeEvent.contentOffset.y / LIST_ITEM_HEIGHT);
const topItemSpare = LIST_ITEM_HEIGHT-(event.nativeEvent.contentOffset.y % LIST_ITEM_HEIGHT);
const positionFromEllipseTop = (forIndex-topItemIndex)*LIST_ITEM_HEIGHT+topItemSpare;
const positionFromOrigin = Math.floor(Math.abs(yRadius - positionFromEllipseTop));
const angle = Math.asin(positionFromOrigin / yRadius);
if (orientation === 'Left') {
marginLeft = 0;
marginRight = ((xRadius * Math.cos(angle)))-LIST_ITEM_HEIGHT;
alignSelf = 'flex-end';
}
else if (orientation === 'Right') {
marginLeft = (xRadius * Math.cos(angle))-LIST_ITEM_HEIGHT;
marginRight = 0;
alignSelf = 'flex-start';
}
}
React-native's FlatList component has a prop called onLayout. You can get the position of the component on screen with this prop.
onLayout
Invoked on mount and layout changes with:
{nativeEvent: { layout: {x, y, width, height}}}
This event is fired immediately once the layout has been calculated,
but the new layout may not yet be reflected on the screen at the time
the event is received, especially if a layout animation is in
progress.
My complete source code for this issue is posted as an Expo app: https://exp.host/#kevinoldlifeway/swipe-scrollview-of-webviews as well as on Github https://github.com/kevinold/swipe-scrollview-of-webviews
I am building a book view in React Native. Using a ScrollView, I would like to swipe left and right to navigate through the pages of a title that could have several hundred to several thousand.
Since that is the case, my goal is to only the minimal amount of data so that the user is able to swipe between pages, seeing the immediately previous and next pages.
I am loading a 3 element array like so:
[Previous, Current, Next]
That would be updated in the state by Redux (not used here to keep simple) and would re-render and refocus the list.
My goal is that my ScrollView is always "centered" on the "Current" page.
Page scrolls to the previous and next page are handled by a handleScroll method which loads the appropriate precomputed array so that the current page stays in focus, but the previous and next pages (offscreen) are updated appropriately.
handleScroll (event) {
//const x = event.nativeEvent.contentOffset.x;
const { activeIndex, scrollTimes } = this.state;
const windowWidth = Dimensions.get('window').width;
const eventWidth = event.nativeEvent.contentSize.width;
const offset = event.nativeEvent.contentOffset.x;
console.log('event width: ', eventWidth);
console.log('event offset: ', offset);
console.log('scrollTimes: ', scrollTimes);
//if (scrollTimes <= 1 ) return;
if (windowWidth + offset >= eventWidth) {
//ScrollEnd, do sth...
console.log('scrollEnd right (nextPage)', offset);
const nextIndex = activeIndex + 1;
console.log('nextIndex: ', nextIndex);
// Load next page
this.loadMore()
} else if (windowWidth - offset <= eventWidth) {
//ScrollEnd, do sth...
console.log('scrollEnd left (prevPage)', offset);
// Load prev page
this.loadPrev()
}
this.setState({ scrollTimes: scrollTimes + 1 });
}
I have tried to balance the "current" page using a combination of:
contentOffset={{ x: width, y: 0 }} on ScrollView
And
componentDidMount() {
// Attempt to keep "center" element in array as focused "screen" in the horizontal list view
this.scrollView.scrollTo({ x: width, y: 0, animated: false });
}
I've also tried to scrollTo in the callback after this.setState, but have not had any luck.
I'm wondering if this "centering" could be accomplished by using Animated.
I gave this a shot but I'm not entirely sure I understood the problem, and I'm not sure how well this would hold up.
Basically I just simplified the handleScroll function significantly. First checking if we were on a scroll completion and if so determining if when we landed on that screen it was the "previous" screen or "next" - do nothing if it's already the middle screen.
I think in your code the issue was that it would fire and load data if it was the middle screen, not just the first or last. Therefore it would fire twice for each transition.
Here's the handleScroll that I think will work for you.
handleScroll (event) {
const offset = event.nativeEvent.contentOffset.x;
const mod = offset % width;
if (mod === 0) { // only transition on scroll complete
if (offset === width * 2) { // last screen
console.log('load more')
this.loadMore();
this.scrollView.scrollTo({ x: width, y: 0, animated: false });
} else if (offset !== width) { // first screen
console.log('load prev')
this.loadPrev();
this.scrollView.scrollTo({ x: width, y: 0, animated: false });
}
}
}
And a Snack demoing it.
I want to to a horizontal ScrollView with pagination enabled with one special requirement: each page (or card) is 90% of the container wide. The remaining 10% should be a preview of the next page.
It is possible to do this with ScrollView? Can I somehow specify the width of the pagination instead of taking the width of the container?
(image taken from this similar question: React Native Card Carousel view?)
I spend a lot of time fighting with this until I figured it out so here is my solution if it helps someone.
https://snack.expo.io/H1CnjIeDb
Problem was all these were required and pagination should be turned off
horizontal={true}
decelerationRate={0}
snapToInterval={width - 60}
snapToAlignment={"center"}
You can absolutely do that with ScrollView or, even better, FlatList. However, the really tricky part is the snapping effect. You can use props snapToInterval and snapToAlignment to achieve it (see Vasil Enchev's answer); unfortunately, these are iOS-only.
A co-worker and I created a plugin that answers this particular need. We ended up open-sourcing it, so it's all yours to try: react-native-snap-carousel.
The plugin is now built on top of FlatList (versions >= 3.0.0), which is great to handle huge numbers of items. It provides previews (the effect you're after), snapping effect for iOS and Android, parallax images, RTL support, and more.
You can take a look at the showcase to get a grasp of what can be achieved with it. Do not hesitate to share your experience with the plugin since we're always trying to improve it.
Edit : two new layouts have been introduced in version 3.6.0 (one with a stack of cards effect and the other with a tinder-like effect). Enjoy!
Use disableIntervalMomentum={ true } in your ScrollView. This will only allow the user to scroll one page at a time horizontally. Check official documents
https://reactnative.dev/docs/scrollview#disableintervalmomentum
<ScrollView
horizontal
disableIntervalMomentum={ true }
snapToInterval={ width }
>
<Child 1 />
<Child 2 />
</ScrollView>
You can pass a horizontal props to your scroll view:
https://facebook.github.io/react-native/docs/scrollview.html#horizontal
And then you can create a view inside to specify your width requirements.
<ScrollView
ref={(snapScroll) => { this.snapScroll = snapScroll; }}
horizontal={true}
decelerationRate={0}
onResponderRelease={()=>{
var interval = 300; // WIDTH OF 1 CHILD COMPONENT
var snapTo = (this.scrollingRight)? Math.ceil(this.lastx / interval) :
Math.floor(this.lastx / interval);
var scrollTo = snapTo * interval;
this.snapScroll.scrollTo(0,scrollTo);
}}
scrollEventThrottle={32}
onScroll={(event)=>{
var nextx = event.nativeEvent.contentOffset.x;
this.scrollingRight = (nextx > this.lastx);
this.lastx = nextx;
}}
showsHorizontalScrollIndicator={false}
style={styles.listViewHorizontal}
>
{/* scroll-children here */}
</ScrollView>
Here is example of simple scrollview pagination for bottom:
<ScrollView
......
onMomentumScrollEnd={event => {
if (isScrollviewCloseToBottom(event.nativeEvent)) {
this.loadMoreData();
}
}}
</ScrollView>
.....
....
function isScrollviewCloseToBottom({
layoutMeasurement,
contentOffset,
contentSize,
}) {
const paddingToBottom = 20;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
}
......
....
same as we can use this for right pagination:
function isScrollviewCloseToRight({
layoutMeasurement,
contentOffset,
contentSize,
}) {
const paddingToRight = 10;
return (
layoutMeasurement.width + contentOffset.x >=
contentSize.width - paddingToRight
);
}
Hope it will helpful..!!
You can look at contentOffset and scrollTo property of ScrollView . Logically what you can do is whenever the page changes(mostly when moved to next page) you can provide a extra offset of 10% or so as per your need so that the next item in the scrollview becomes visible .
Hope this helps, let me know if you need any extra details .
Here's a fun one i've been poking at for while:
I have a FlatList (same issue with ListView) and I want to render an element INSIDE the internal scrolling container with the following characteristics:
Absolutely Positioned (thus having no effect on position of list elements)
Position XX distance from top (translateY or top)
zIndex (above list elements)
The use case is i'm rendering a day view calendar grid with a horizontal bar at the current time position fixed at X distance from the beginning of the internal scrollview so it appears as the user scrolls pass that position.
So far i've tried wrapping wrapping FlatList/ListView with another ScrollView... also tried rendering this element as the header element which only works while the header/footer are visible (trashed when out of view).
Any and all ideas welcomed. :)
Thanks
Screenshot Below (red bar is what i'm trying to render):
Here's a working demo of what it sounds like you're trying to achieve: https://sketch.expo.io/BkreW1che. You can click "preview" to see it in your browser.
And here's the main code you need to measure the height of the ListView and place the indicator on top of it (visit the link above to see the full source):
handleLayout(event) {
const { y, height } = event.nativeEvent.layout;
// Now we know how tall the ListView is; let's put the indicator in the middle.
this.setState({ indicatorOffset: y + (height / 2) });
}
renderIndicator() {
const { indicatorOffset } = this.state;
// Once we know how tall the ListView is, put the indicator on top.
return indicatorOffset ? (
<View style={[{ position: 'absolute', left: 0, right: 0, top: indicatorOffset }]} />
) : null;
}
render() {
return (
<View style={styles.container}>
<ListView
onLayout={(event) => this.handleLayout(event)}
dataSource={this.state.dataSource}
renderRow={this.renderRow}
/>
{this.renderIndicator()}
</View>
);
}
Edit: I now understand that you want the indicator to scroll along with the list. That's a simple change from above, just add an onScroll listener to the ListView: https://sketch.expo.io/HkEjDy92e
handleScroll(event) {
const { y } = event.nativeEvent.contentOffset;
// Keep the indicator at the same position in the list using this offset.
this.setState({ scrollOffset: y });
},
With this change, the indicator actually seems to lag behind a bit because of the delay in the onScroll callback.
If you want better performance, you might consider rendering the indicator as part of your renderRow method instead. For example, if you know the indicator should appear at 10:30 am, then you would render it right in the middle of your 10am row.