I'm trying to build this sticky header navbar in my RN app. Basically, an horizontal scrollview of categories that highlight the current category based on Y scrolling.
Thanks to the video of great William Candillon (https://www.youtube.com/watch?v=xutPT1oZL2M&t=1369s) I'm pretty close, but I have a main problem.
I'm using interpolation to translate the X position of category View while scrolling. And then I have a Scrollview wrapping this Animated View. The problem is that Scrollview is not functional as is does not have the reference of the position of the Animated View. As you can see in the gif below (blue -> Animated.View / red -> Scrollview)
I like the interpolation approach as it's declarative and runs on native thread, so I tried to avoid as much as possible create listener attached to scrollTo() function.
What approach would you consider?
export default ({ y, scrollView, tabs }) => {
const index = new Value(0);
const [measurements, setMeasurements] = useState(
new Array(tabs.length).fill(0)
);
const indexTransition = withTransition(index);
const width = interpolate(indexTransition, {
inputRange: tabs.map((_, i) => i),
outputRange: measurements
});
const translateX = interpolate(indexTransition, {
inputRange: tabs.map((_tab, i) => i),
outputRange: measurements.map((_, i) => {
return (
-1 *
measurements
.filter((_measurement, j) => j < i)
.reduce((acc, m) => acc + m, 0) -
8 * i
);
})
});
const style = {
borderRadius: 24,
backgroundColor: 'black',
width,
flex: 1
};
const maskElement = <Animated.View {...{ style }} />;
useCode(
() =>
block(
tabs.map((tab, i) =>
cond(
i === tabs.length - 1
? greaterOrEq(y, tab.anchor)
: and(
greaterOrEq(y, tab.anchor),
lessOrEq(y, tabs[i + 1].anchor)
),
set(index, i)
)
)
),
[index, tabs, y]
);
return (
<Animated.View style={[styles.container, {}]}>
<Animated.ScrollView
scrollEventThrottle={16}
horizontal
style={{ backgroundColor: 'red', flex: 1 }}
>
<Animated.View
style={{
transform: [{ translateX }],
backgroundColor: 'blue'
}}
>
<Tabs
onPress={i => {
if (scrollView) {
scrollView.getNode().scrollTo({ y: tabs[i].anchor + 1 });
}
}}
onMeasurement={(i, m) => {
measurements[i] = m;
setMeasurements([...measurements]);
}}
{...{ tabs, translateX }}
/>
</Animated.View>
</Animated.ScrollView>
</Animated.View>
);
};
For anyone facing this issue, I solved it by adding the following on the animated scrollview to auto scroll the to the active tab
// Tabs.tsx
const scrollH = useRef<Animated.ScrollView>(null);
let lastScrollX = new Animated.Value<number>(0);
//Here's the magic code to scroll to active tab
//translateX is the animated node value from the position of the active tab
useCode(
() => block(
[cond(
or(greaterThan(translateX, lastScrollX), lessThan(translateX, lastScrollX)),
call([translateX], (tranX) => {
if (scrollH.current && tranX[0] !== undefined) {
scrollH.current.scrollTo({ x: tranX[0], animated: false });
}
})),
set(lastScrollX, translateX)
])
, [translateX]);
// Render the Animated.ScrollView
return (
<Animated.ScrollView
horizontal
ref={scrollH}
showsHorizontalScrollIndicator={false}
>{tabs.map((tab, index) => (
<Tab ..../> ..... </Animated.ScrollView>
Related
In this snack I am trying to have 3 cards in the center of the screen with a horizontal FlatList and enabled paging to jump to the next 3 cards on scroll.
But the layout starts getting destroyed after scrolling and some pixels of the next/previous card appears in the view.
How should I style this list to always have exactly 3 cards in the center of the screen and scroll will jump to the next page with the next 3 cards ? Or like the GooglePlay store, a fixed pixels of previous/next card be visible to the left and right of the main 3 cards. (Example screenshots below)
<View style={{flex:1,justifyContent: 'center', marginLeft: 5, marginRight: 5,}}>
<FlatList
horizontal
pagingEnabled
data={data}
keyExtractor={(item) => `ìtem-${item}`}
renderItem={({ item }) => (
<Card style={{width:Dimensions.get("window").width/3-5,marginRight:5}}>
{/* some content */}
</Card>
)}
/>
</View>
I do not need a library like snap-carousel or so ...
use Scrollview prop snapToOffsets to achieve that.
like google play example ( one by one ) try snack.
your example ( three by three ) try snack.
how to use snapToOffsets?
const snapToOffsetsLikeGooglePlay = data.map((x, i) => {
return ((i * itemWidth) + startScroll)
})
const snapToOffsetsLikeYourExample = data.map((x, i) => {
return ((i * (itemWidth) * previewCount) + startScroll)
})
//see the example below to know
//what is `startScroll` and `previewCount` mean?
//and how to calculate `itemWidth`?
here the full example
import React from 'react';
import {FlatList, Text} from 'react-native';
import { View, StyleSheet, ScrollView, Dimensions } from 'react-native';
const { width } = Dimensions.get('window');
//you need to preview n items.
const previewCount = 3;
//to center items
//the screen will show `previewCount` + 1/4 firstItemWidth + 1/4 lastItemWidth
//so for example if previewCount = 3
//itemWidth will be =>>> itemWidth = screenWidth / (3 + 1/4 + 1/4)
const itemWidth = width/(previewCount + .5);
//to center items you start from 3/4 firstItemWidth
const startScroll = (itemWidth * 3/4);
const App = () => {
const data = [...Array(24).keys()];
const flatlistRef = React.useRef();
React.useEffect(() => {
if (flatlistRef.current) flatlistRef.current.scrollToOffset({
offset:startScroll, animated: false
});
}, [flatlistRef]);
const snapToOffsetsLikeGooglePlay = data.map((x, i) => {
return ((i * itemWidth) + startScroll)
})
const snapToOffsets = data.map((x, i) => {
return ((i * (itemWidth) * previewCount) + startScroll)
})
return (
<FlatList
ref={flatlistRef}
style={styles.container}
pagingEnabled={true}
horizontal= {true}
decelerationRate={0}
snapToOffsets={snapToOffsets}
snapToAlignment={"center"}
data={data}
renderItem={({item, index}) => (
<View style={styles.view} >
<Text style={styles.text}>{index}</Text>
</View>
)}/>
);
}
export default App;
const styles = StyleSheet.create({
container: {
},
view: {
marginTop: 100,
backgroundColor: '#eee',
width: itemWidth - 20, //20 is margin left and right
margin: 10,
height: 140,
borderRadius: 10,
justifyContent : 'center',
alignItems : 'center',
},
text : {
fontSize : 60,
fontWeight : 'bold',
color : '#aaa',
},
});
update: start from zero as #Amir-Mousavi comment
one by one try snack
1-) comment useEffect.
2-) set const startScroll = (itemWidth * 3/4)
three by three try snack
1-) comment useEffect.
2-) set const startScroll = (itemWidth * 2.75)
Ok after much work and testing I finally was able to fix this.
snapToInterval have to snap to interval a full length of the screen.
if you use pWidth *3 it wont work. Now you may ask why, I really do not understand , it may have something to do with float values.
But if you use snapToInterval={Dimensions.get('window').width} it should work.
Have a look at snack example
I have components like below
<Animated.ScrollView
onScroll = {Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true, listener: (event) => handleScroll(event) }
)}
scrollEventThrottle = {16}
alwaysBounceHorizontal = {false}
alwaysBounceVertical = {false}
bounces = {false}>
<View style = {{height:100}}><Text>One</Text></View>
<View style = {{height:100}}><Text>Two</Text></View>
<View style = {{height:100}}><Text>Three</Text></View>
<Animated.ScrollView>
I am able to get get Scroll Y position using
const scrollY = new Animated.Value(0);
Now how do I get the positions of view so I can get that values to compute and add animations to it.
For example, when I scroll down and if View - Three becomes completely visible inside the viewport, I need to change some styles to it. And remove the styles if its going away from the viewport... How do I do it?
I think there is a good way.
Because your list is variable, use FlatList to render your elements, and use onViewableItemsChanged to detect wich have changed.
In this example i animate the opacity of views
Init
// fetch api => response
let animatedValues = []
response.forEach(element => {
animatedValues.push(new Animated.Value(0))
})
Handle
const handleViewsChange = (event) => {
let animations = []
event.changed.forEach(view => {
animations.push(
Animated.timing(animatedValues[view.index], {
toValue: view.isViewable ? 1 : 0,
duration: 300,
useNativeDriver: true
})
)
})
Animated.parallel(animations).start()
}
Your list
<Flatlist
data={response}
renderItem={(item, index) => {
return(
<Animated.View key={index} style={{height:100, opacity: animatedValues[index]}}><Text>{item.thing}</Text></Animated.View>
)
}}
keyExtractor={(item, index) => index.toString()}
onViewableItemsChanged={event => handleViewsChange(event)}
/>
You will surely have to adapt for your use
In my React Native 0.62.2 app, react-native-gesture-handler 1.6.1 and react-native-animated 10.10.1 are used to make image grid draggable. The problem is that all uploaded images grids are moving together instead of individually draggable. Here is the code for draggable image grid:
import { Col, Row, Grid } from 'react-native-easy-grid';
import { PanGestureHandler } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import FastImage from 'react-native-fast-image';
export default DisplayImages = ({pics, deleteImage}) => { //<<==component to display images passed in from 'pics'
const translateX = new Animated.Value(0) //<<==draggable related code
const translateY = new Animated.Value(0)
const handleGesture = Animated.event([{nativeEvent: {translationX: translateX,translationY:translateY}}], { useNativeDriver: true });
//VV== code below displays single image grid
const displayImg = (img_source, width, ht, index, modalWidth, modalHt) => {
let aniStyle = {
transform:[
{ translateY : translateY },
{ translateX : translateX }
]
};
return (
<>
<PanGestureHandler onGestureEvent={handleGesture}>
<Animated.View style={aniStyle}>
<TouchableOpacity onPress={()=>{setModalDialog(index)}} >
<FastImage
source={{uri:img_source}}
resizeMode={FastImage.resizeMode.cover}
key={index}
style={{
width:width,
height:ht,
verticalAlign:0,
paddingTop:0,
}}
/>
</TouchableOpacity>
</Animated.View>
</PanGestureHandler>
)
}
//VV==code blow to display 2 images as an example
return (
<Grid style={{position:"absolute", paddingTop:0,paddingLeft:0}}>
<Row style={styles.row}>
{pics.map((item, index) => {
return (displayImg(item, screen_width*half, screen_width*half, index, screen_width, item.height*(screen_width/item.width)))
})}
</Row>
</Grid>
);
}
Here is the 2 image grids were dragged towards the left together but not only one grid moved
1 image was dragged but 2 images were moving together
The property of the gesture needs to be defined for each of the grid. It can be done by moving the declaration of the property into the definition of method displayImg:
const displayImg = (img_source, width, ht, index, modalWidth, modalHt) => {
const translateX = new Animated.Value(0) //<<==draggable related code
const translateY = new Animated.Value(0)
const handleGesture = Animated.event([{nativeEvent: {translationX: translateX,translationY:translateY}}], { useNativeDriver: true });
let aniStyle = {
transform:[
{ translateY : translateY },
{ translateX : translateX }
]
};
return (
<>
<PanGestureHandler onGestureEvent={handleGesture}>
<Animated.View style={aniStyle}>
...
After that, each grid can be dragged on its own.
I am building a chat app, using an inverted Flatlist. I add new items to the top of the list when onEndReached is called and everything works fine.
The problem is that if add items to the bottom, it instantly scrolls to the bottom of the list. That means that the user has to scroll back up to read the messages that were just added (which is terrible).
I tried to call scrollToOffset in onContentSizeChange, but this has a one-second delay where the scroll jumps back and forth.
How can I have the list behave the same way when I add items to the top AND to the bottom, by keeping the same messages on screen instead of showing the new ones?
here is demo: https://snack.expo.io/#nomi9995/flatlisttest
Solution 1:
use maintainVisibleContentPosition props for preventing auto scroll in IOS but unfortunately, it's not working on android. but here is PR for android Pull Request. before merge this PR you can patch by own from this PR
<FlatList
ref={(ref) => { this.chatFlatList = ref; }}
style={styles.flatList}
data={this.state.items}
renderItem={this._renderItem}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
/>
Solution 2:
I found another workaround by keep latest y offset with onScroll and also save content height before and after adding new items with onContentSizeChange and calculate the difference of content height, and set new y offset to the latest y offset + content height difference!
Here I am adding a new item on top and bottom in an inverted Flatlist.
I hope you can compare your requirements with the provided sample code :)
Full Code:
import React, {Component} from 'react';
import {
SafeAreaView,
View,
FlatList,
StyleSheet,
Text,
Button,
Platform,
UIManager,
LayoutAnimation,
} from 'react-native';
if (Platform.OS === 'android') {
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
const getRandomColor = () => {
var letters = '0123456789ABCDEF';
var color = '#';
for (var i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
const DATA = [
getRandomColor(),
getRandomColor(),
getRandomColor(),
getRandomColor(),
getRandomColor(),
];
export default class App extends Component {
scrollValue = 0;
append = true;
state = {
data: DATA,
};
addItem = (top) => {
const {data} = this.state;
let newData;
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
if (top) {
newData = [...data, getRandomColor()];
this.setState({data: newData});
} else {
newData = [getRandomColor(), ...data];
this.setState({data: newData});
}
};
shouldComponentUpdate() {
return this.scrollValue === 0 || this.append;
}
onScrollBeginDrag = () => {
this.append = true;
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
this.setState({});
};
render() {
const {data} = this.state;
return (
<SafeAreaView style={styles.container}>
<Button title="ADD ON TOP" onPress={() => this.addItem(true)} />
<FlatList
data={data}
onScrollBeginDrag={this.onScrollBeginDrag}
renderItem={({item}) => <Item item={item} />}
keyExtractor={(item) => item}
inverted
onScroll={(e) => {
this.append = false;
this.scrollValue = e.nativeEvent.contentOffset.y;
}}
/>
<Button title="ADD ON BOTTOM" onPress={() => this.addItem(false)} />
</SafeAreaView>
);
}
}
function Item({item}) {
return (
<View style={[styles.item, {backgroundColor: item}]}>
<Text style={styles.title}>{item}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
item: {
backgroundColor: '#f9c2ff',
padding: 20,
height: 100,
},
title: {
fontSize: 32,
},
});
This is one year late, but this works fine:
<FlatList
inverted
initialScrollIndex={1}
{...}
/>
Since inverted renders flatlist but with inverted: 1, thus you need to pass 1 to initialScrollIndex so that it scrolls to bottom in normal list and to top in the inverted one
Have you tried using keyExtractor?
It may help react avoid re-render, so try use unique keys for each item.
you can read more about it here: https://reactnative.dev/docs/flatlist#keyextractor
I'm trying to create an SmoothPicker like that:
I'm using react-native-smooth-picker and all works fine, except when I'm Changing to Feet.
When I change to Feet, I want that the list will rerender and change the data to Feet parameters, but it only happened after a scroll. Is there a way to do that?
here is my code:
const HeightPicker = () => {
const [isFeet, setIsFeet] = useState(false)
const listRef = useRef(null)
let metersHeights = []
const feetHeights = new Set()
for (i = 140; i <= 220; i++) {
metersHeights.push(i)
feetHeights.add(centimeterToFeet(i))
}
const [selected, setSelected] = useState(40)
return <View
style={{
backgroundColor: 'rgb(92,76, 73)',
paddingBottom: 20
}} >
<Toggle
style={{
alignSelf: 'flex-end',
marginVertical: 20,
marginEnd: 20
}}
textFirst="Feet"
textSecond="Meters"
onChange={(change) => {
setIsFeet(change)
}} />
<SmoothPicker
ref={listRef}
onScrollToIndexFailed={() => { }}
initialScrollToIndex={selected}
keyExtractor={(value) => value.toString()}
horizontal
showsHorizontalScrollIndicator={false}
magnet={true}
bounces={true}
extraData={isFeet}
data={isFeet ? [...feetHeights] : metersHeights}
onSelected={({ item, index }) => setSelected(index)}
renderItem={({ item, index }) => (
<Bubble selected={index === selected}>
{item}
</Bubble>
)}
/>
</View>
}
You should separate the feet & meters (the function tha generates them).
Set data with useState & make meters as default
make use of useEffect to change the data everytime you toggle.
...
useEffect(() => {
handleData(isFeet)
},[isFeet]);
const handletData = (isFeet) => {
if(isFeet){
setData(feet)
}else{
setData(meters)
}
}
....
data={data}
...