React Native Flatlist custom refresh control with Lottie is glitchy - react-native

I'm trying to implement a custom refresher animation to my app and im almost there. It just seems like i can't fix this glitch but I know it's possible because the native RefreshControl is so smooth. Here is a video of what I currently have: https://youtu.be/4lMn2sVXBAM
In this video, you can see how it scrolls past where it's supposed to stop once you release the flatlist and then it jumps back to where it's meant to stop. I just want it to be smooth and not go past the necessary stop so it doesn't look glitchy. Here is my code:
#inject('store')
#observer
class VolesFeedScreen extends Component<HomeFeed> {
#observable voleData:any = [];
#observable newVoleData:any = [1]; // needs to have length in the beginning
#observable error:any = null;
#observable lastVoleTimestamp: any = null;
#observable loadingMoreRefreshing: boolean = false;
#observable refreshing:boolean = true;
#observable lottieViewRef: any = React.createRef();
#observable flatListRef: any = React.createRef();
#observable offsetY: number = 0;
#observable animationSpeed: any = 0;
#observable extraPaddingTop: any = 0;
async componentDidMount(){
this.animationSpeed = 1;
this.lottieViewRef.current.play();
this.voleData = await getVoles();
if(this.voleData.length > 0){
this.lastVoleTimestamp = this.voleData[this.voleData.length - 1].createdAt;
}
this.animationSpeed = 0;
this.lottieViewRef.current.reset();
this.refreshing = false;
}
_renderItem = ({item}:any) => (
<VoleCard
voleId={item.voleId}
profileImageURL={item.userImageUrl}
userHandle={item.userHandle}
userId={item.userId}
voteCountDictionary={item.votingOptionsDictionary}
userVoteOption={item.userVoteOption}
shareStat={item.shareCount}
description={item.voleDescription}
imageUrl={item.imageUrl.length > 0 ? item.imageUrl[0] : null} //only one image for now
videoUrl={item.videoUrl.length > 0 ? item.videoUrl[0] : null} //only one video for now
time={convertTime(item.createdAt)}
key={JSON.stringify(item)}
/>
);
onScroll(event:any) {
const { nativeEvent } = event;
const { contentOffset } = nativeEvent;
const { y } = contentOffset;
this.offsetY = y;
if(y < -45){
this.animationSpeed = 1;
this.lottieViewRef.current.play();
}
}
onRelease = async () => {
if (this.offsetY <= -45 && !this.refreshing) {
this.flatListRef.current.scrollToOffset({ animated: false, offset: -40 });
hapticFeedback();
this.extraPaddingTop = 40;
this.newVoleData = [1];
this.refreshing = true;
this.animationSpeed = 1;
this.lottieViewRef.current.play();
this.voleData = await getVoles();
this.extraPaddingTop = 0;
this.animationSpeed = 0;
this.lottieViewRef.current.reset();
if(this.voleData.length > 0){
this.lastVoleTimestamp = this.voleData[this.voleData.length - 1].createdAt;
}
this.refreshing = false;
}
}
render() {
return (
<View>
<LottieView
style={styles.lottieView}
ref={this.lottieViewRef}
source={require('../../../assets/loadingDots.json')}
speed={this.animationSpeed}
/>
<FlatList
contentContainerStyle={styles.mainContainer}
data={this.voleData}
renderItem={this._renderItem}
onScroll={(evt) => this.onScroll(evt)}
scrollEnabled={!this.refreshing}
ref={this.flatListRef}
onResponderRelease={this.onRelease}
ListHeaderComponent={(
<View style={{ paddingTop: this.extraPaddingTop}}/>
)}
ListFooterComponent={this.loadingMoreRefreshing ? <ActivityIndicator style={styles.footer}/> : this.refreshing ? null : <Text style={styles.noMoreVolesText}>No more voles</Text>}
onEndReached={this.newVoleData.length > 0 ? this.loadMoreVoles : null}
onEndReachedThreshold={2}
keyExtractor={item => item.voleId}
/>
<StatusBar barStyle={'dark-content'}/>
</View>
)
}
}
const styles = StyleSheet.create({
mainContainer:{
marginTop: 6,
marginLeft: 6,
marginRight: 6,
},
footer:{
marginBottom: 10,
alignSelf:'center'
},
noMoreVolesText:{
fontSize: 10,
marginBottom: 10,
alignSelf:'center',
color:'#000000',
opacity: .5,
},
lottieView: {
height: 50,
position: 'absolute',
top:0,
left: 0,
right: 0,
},
});
export default VolesFeedScreen;
What i think it's doing is that once on let go of the flatlist, it bounces up past the stop point, which is at an offset of -40 above, before the function onRelease starts. Any help is appreciated!

Related

React native multi-column FlatList insert banner

I'm using multi-column FlatList in my React Native application to display items like below (left image). I'm trying to integrate AdMob banner into the application like many other apps did, and insert the ads banner in the middle of the list, like below (right image).
As far as I can tell, FlatList doesn't support this type of layout out-of-the-box. I'm wondering what would be a good practice to implement this feature and doesn't impact app performance.
(Side note, the list supports pull-to-refresh and infinite loading when approaching end of the list.).
Thank you in advance for any suggestions.
In such a case, I always recommend to drop the numColumns property and replace it by a custom render function, which handles the columns by its own.
Let's say we have the following data structure:
const DATA =
[{ id: 1, title: "Item One"}, { id: 2, title: "Item Two"}, { id: 3, title: "Item Three"},
{ id: 4, title: "Item Four"}, { id: 5, title: "Item Five"}, { id: 6, title: "Item Six"},
{ id: 7, title: "Item Seven"}, { id:8, title: "Item Eight"}, { id: 9, title: "Item Nine"},
{ id: 10, title: "Item Ten"}, { id: 11, title: "Item eleven"},
{ id: 12, title: "Item Twelve"}, { id: 13, title: "Item Thirteen"}];
As I said we don't use the numColumns property instead we are restructuring our data so we can render our list how we want. In this case we want to have 3 columns and after six items we want to show an ad banner.
Data Modification:
modifyData(data) {
const numColumns = 3;
const addBannerAfterIndex = 6;
const arr = [];
var tmp = [];
data.forEach((val, index) => {
if (index % numColumns == 0 && index != 0){
arr.push(tmp);
tmp = [];
}
if (index % addBannerAfterIndex == 0 && index != 0){
arr.push([{type: 'banner'}]);
tmp = [];
}
tmp.push(val);
});
arr.push(tmp);
return arr;
}
Now we can render our transformed data:
Main render function:
render() {
const newData = this.modifyData(DATA); // here we can modify the data, this is probably not the spot where you want to trigger the modification
return (
<View style={styles.container}>
<FlatList
data={newData}
renderItem={({item, index})=> this.renderItem(item, index)}
/>
</View>
);
}
RenderItem Function:
I removed some inline styling to make it more clearer.
renderItem(item, index) {
// if we have a banner item we can render it here
if (item[0].type == "banner"){
return (
<View key={index} style={{width: WIDTH-20, flexDirection: 'row'}}>
<Text style={{textAlign: 'center', color: 'white'}}> YOUR AD BANNER COMPONENT CAN BE PLACED HERE HERE </Text>
</View>
)
}
//otherwise we map over our items and render them side by side
const columns = item.map((val, idx) => {
return (
<View style={{width: WIDTH/3-20, justifyContent: 'center', backgroundColor: 'gray', height: 60, marginLeft: 10, marginRight: 10}} key={idx}>
<Text style={{textAlign: 'center'}}> {val.title} </Text>
</View>
)
});
return (
<View key={index} style={{width: WIDTH, flexDirection: 'row', marginBottom: 10}}>
{columns}
</View>
)
}
Output:
Working Example:
https://snack.expo.io/SkmTqWrJS
I'd recommend this beautiful package
https://github.com/Flipkart/recyclerlistview
Actually, we were handling thousands of data list in our app, flatlist was able to handle it quite good, but still we were looking for a high performance listview component to produce a smooth render and memory efficient as well. We stumbled upon this package. Trust me it's great.
Coming to your question, this package has the feature of rendering multiple views out-of-the-box. It has got a good documentation too.
So basically, the package has three important step to setup the listview.
DataProvider - Constructor function the defines the data for each
element
LayoutProvider - Constructor function that defines the layout (height
/ width) of each element
RowRenderer - Just like the renderItem prop in flatlist.
Basic code looks like this:
import React, { Component } from "react";
import { View, Text, Dimensions } from "react-native";
import { RecyclerListView, DataProvider, LayoutProvider } from "recyclerlistview";
const ViewTypes = {
FULL: 0,
HALF_LEFT: 1,
HALF_RIGHT: 2
};
let containerCount = 0;
class CellContainer extends React.Component {
constructor(args) {
super(args);
this._containerId = containerCount++;
}
render() {
return <View {...this.props}>{this.props.children}<Text>Cell Id: {this._containerId}</Text></View>;
}
}
export default class RecycleTestComponent extends React.Component {
constructor(args) {
super(args);
let { width } = Dimensions.get("window");
//Create the data provider and provide method which takes in two rows of data and return if those two are different or not.
let dataProvider = new DataProvider((r1, r2) => {
return r1 !== r2;
});
//Create the layout provider
//First method: Given an index return the type of item e.g ListItemType1, ListItemType2 in case you have variety of items in your list/grid
this._layoutProvider = new LayoutProvider(
index => {
if (index % 3 === 0) {
return ViewTypes.FULL;
} else if (index % 3 === 1) {
return ViewTypes.HALF_LEFT;
} else {
return ViewTypes.HALF_RIGHT;
}
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.HALF_RIGHT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.FULL:
dim.width = width;
dim.height = 140;
break;
default:
dim.width = 0;
dim.height = 0;
}
}
);
this._rowRenderer = this._rowRenderer.bind(this);
//Since component should always render once data has changed, make data provider part of the state
this.state = {
dataProvider: dataProvider.cloneWithRows(this._generateArray(300))
};
}
_generateArray(n) {
let arr = new Array(n);
for (let i = 0; i < n; i++) {
arr[i] = i;
}
return arr;
}
//Given type and data return the view component
_rowRenderer(type, data) {
//You can return any view here, CellContainer has no special significance
switch (type) {
case ViewTypes.HALF_LEFT:
return (
<CellContainer style={styles.containerGridLeft}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.HALF_RIGHT:
return (
<CellContainer style={styles.containerGridRight}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.FULL:
return (
<CellContainer style={styles.container}>
<Text>Data: {data}</Text>
</CellContainer>
);
default:
return null;
}
}
render() {
return <RecyclerListView layoutProvider={this._layoutProvider} dataProvider={this.state.dataProvider} rowRenderer={this._rowRenderer} />;
}
}
const styles = {
container: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#00a1f1"
},
containerGridLeft: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#ffbb00"
},
containerGridRight: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#7cbb00"
}
};
In the LayoutProvider, you can return multiple type of view based on the index or you can add a viewType object in your data array, render views based on that.
this._layoutProvider = new LayoutProvider(
index => {
if (index % 3 === 0) {
return ViewTypes.FULL;
} else if (index % 3 === 1) {
return ViewTypes.HALF_LEFT;
} else {
return ViewTypes.HALF_RIGHT;
}
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.HALF_RIGHT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.FULL:
dim.width = width;
dim.height = 140;
break;
default:
dim.width = 0;
dim.height = 0;
}
}
);
tl;dr: Check the https://github.com/Flipkart/recyclerlistview and use the layoutProvider to render different view.
Run the snack: https://snack.expo.io/B1GYad52b

Record button snapchat style with expo/react-native

I'm trying to create a record button component for an expo camera app.
I didn't eject my app and I would like to keep it like that if possible.
So I did a component that is working:
import React from 'react';
import {TouchableWithoutFeedback, Animated, StyleSheet, View} from 'react-native';
import { Svg } from 'expo';
const AnimatedPath = Animated.createAnimatedComponent(Svg.Path);
const PRESSIN_DELAY = 200
export default class RecordButton extends React.Component {
constructor(props) {
super(props);
this.state = {
progress: new Animated.Value(0),
}
this.stopping = false
}
componentWillMount(){
let R = this.props.radius;
let strokeWidth = 7
let dRange = [];
let iRange = [];
let steps = 359;
for (var i = 0; i<steps; i++){
dRange.push(this.describeArc(R+strokeWidth+2/2, R+strokeWidth+2/2, R, 0, i));
iRange.push(i/(steps-1));
}
var _d = this.state.progress.interpolate({
inputRange: iRange,
outputRange: dRange
})
this.animationData = {
R: R,
strokeWidth: strokeWidth,
_d: _d,
}
}
describeArc = (x, y, radius, startAngle, endAngle)=>{
var start = this.polarToCartesian(x, y, radius, endAngle);
var end = this.polarToCartesian(x, y, radius, startAngle);
var largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
var d = [
"M", start.x, start.y,
"A", radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(" ");
return d;
}
polarToCartesian(centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
}
startAnimation = async ()=>{
this.Animation = Animated.timing(this.state.progress,{
toValue:1,
duration:this.props.duration,
})
this.props.onPressIn().then(res => {
if(res){
this.Animation.start(()=>{this.stopAnimation(false)})
}
})
}
stopAnimation = (abort=true) => {
if(!this.stopping){
this.stopping = true
abort && this.Animation.stop()
Animated.timing(this.state.progress,{
toValue: 0,
duration:0,
}).start()
this.props.onPressOut()
this.stopping = false
}
}
_HandlePressIn = async ()=>{
this.timerTimeStamp = Date.now()
this.timer = setTimeout(()=>{
this.startAnimation()
}, PRESSIN_DELAY)
}
_HandlePressOut = async ()=>{
let timeStamp = Date.now()
if(timeStamp - this.timerTimeStamp <= PRESSIN_DELAY){
clearTimeout(this.timer)
this.props.onJustPress()
}else{
this.stopAnimation()
}
}
render() {
const { R, _d, strokeWidth } = this.animationData
return (
<View style={this.props.style}>
<TouchableWithoutFeedback style={styles.touchableStyle} onPress={this.props.onPress} onPressIn={this._HandlePressIn} onPressOut={this._HandlePressOut}>
<Svg
style={styles.svgStyle}
height={R*2+13}
width={R*2+13}
>
<Svg.Circle
cx={R+strokeWidth+2/2}
cy={R+strokeWidth+2/2}
r={R}
stroke="grey"
strokeWidth={strokeWidth+1}
fill="none"
/>
<Svg.Circle
cx={R+strokeWidth+2/2}
cy={R+strokeWidth+2/2}
r={R}
stroke="white"
strokeWidth={strokeWidth}
fill="none"
/>
<AnimatedPath
d={_d}
stroke="red"
strokeWidth={strokeWidth}
fill="none"
/>
</Svg>
</TouchableWithoutFeedback>
</View>
);
}
}
const styles = StyleSheet.create({
svgStyle:{
flex:1,
},
touchableStyle: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
});
So everything is almost fine (not really performant but that's ok for now)
The problem is when the parent view trigger a re-render with just a setState for exemple. This is really time consuming, like 3-5 seconds. So I think it's because each time it need to re-do all the math ...
But I'm quite new to expo/react-native and even more with animation so I don't know how to optimise it. Or if there is a better way to do what I want.
Edit: I notice that it was slow when I tried to show the button after hiding it with a setState in the parent component.
So I found a workaround: I just create a method that is doing the same but inside the recordButton. Like that, the re-render doesn't do the math again, it's more performant. I think there's a better way to do it so I'm still aware of other solutions...

how to restart current screen in react native?

i'm doing some graphs work and when i change the graph
instead of trying to look for all variables in the screen to zero and to look at all the graphs functions to render it live
i just want to restart the current screen (meaning as if it would go to the same scene for the first time)
how do i do that without actually exiting and reentering the scene ?
to be sure here is my code:
import React, {Component} from 'react';
import {View, Text, StyleSheet, processColor, Picker} from 'react-native';
import { Button} from "native-base";
import './globals.js'
import {CombinedChart} from 'react-native-charts-wrapper';
import moment from 'moment';
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'stretch',
backgroundColor: 'transparent',
paddingTop:20
}
});
var InsulinShort = [];
var InsulinLong = [];
var glocuseTests = [];
const injectionsCount = 1000;
for (var i = 1; i < injectionsCount; i++) {
var random = Math.random();
if (random <= 0.7) {
var gloguseValue = Math.floor(Math.random() * 40) + 75;
var gloguseposition = Math.random();
glocuseTests.push({x:i+gloguseposition, y: gloguseValue, marker: "level: "+gloguseValue});
}
}
for (var i = 1; i < injectionsCount; i++) {
var random = Math.random();
if (random <= 0.15) {
var shortvalue = Math.floor(Math.random() * 16) + 1 ;
var shortPosition = Math.round(Math.random() * 100) / 100;
InsulinShort.push({x:i+shortPosition,y: shortvalue*20, marker: shortvalue+ " units of short insulin"});
}else if (random <= 0.3) {
var longePosition = Math.round(Math.random() * 10) / 10;
var longvalue = Math.floor(Math.random() * 16) + 1;
InsulinLong.push({x:i+longePosition,y: longvalue*20, marker: longvalue+ " units of long insulin"});
}else{
}
}
export default class LogGraph extends Component {
constructor() {
super();
// var valueFormatter = new Array(3515599953920);
this.state = {
selectedPeriod: "all",
loading: true,
days:365,
legend: {
enabled: true,
textSize: 18,
form: 'SQUARE',
formSize: 18,
xEntrySpace: 10,
yEntrySpace: 5,
formToTextSpace: 5,
wordWrapEnabled: true,
maxSizePercent: 0.5
},
xAxis: {
valueFormatter: [
'01/04/18',
'02/04/18',
'03/04/18',
'04/04/18',
'05/04/18',
'06/04/18',
'07/04/18',
'08/04/18',
'09/04/18',
'10/04/18',
'11/04/18',
'12/04/18',
'13/04/18',
'14/04/18',
'15/04/18',
'16/04/18',
'17/04/18',
'18/04/18',
'19/04/18',
'20/04/18',
],
// valueFormatterPattern:'ssss',
// limitLines:100,
// drawLimitLinesBehindData:false,
// avoidFirstLastClipping: false,
textColor: processColor('#000'),
gridColor: processColor('#000'),
axisLineColor: processColor('#000'),
drawGridLines:true,
drawAxisLine:false,
drawLabels:true,
granularityEnabled: true,
// granularity:1515599953920,
granularity: 1,
// granularity: 131096 ,
position:'TOP',
textSize: 10,
labelRotationAngle:45,
gridLineWidth: 1,
axisLineWidth: 2,
gridDashedLine: {
lineLength: 10,
spaceLength: 10
},
centerAxisLabels:false,
},
yAxis: {
left: {
axisMinimum: 0,
axisMaximum: 400,
labelCount: 6,
labelCountForce: true,
granularityEnabled: true,
granularity: 5,
},
right: {
axisMinimum: 0,
axisMaximum: 20,
labelCount: 6, // 0 5 10 15 20 25 30
labelCountForce: true,
granularityEnabled: true,
granularity: 5,
}
},
marker: {
enabled: true,
markerColor: processColor('#F0C0FF8C'),
textColor: processColor('white'),
markerFontSize: 18,
},
data: {
barData: {
config: {
barWidth: 1 / 24 ,
},
dataSets: [{
values:InsulinLong,
label: 'Long Insulin',
config: {
drawValues: false,
colors: [processColor('#a2a4b2')],
}
},{
values:InsulinShort,
label: 'Short Insulin',
config: {
barShadowColor: processColor('red'),
highlightAlpha: 200,
drawValues: false,
colors: [processColor('#d0d5de')],
}
}]
},
lineData: {
dataSets: [{
values: glocuseTests,
label: 'Glucose Level',
config: {
drawValues: false,
colors: [processColor('#0090ff')],
// mode: "CUBIC_BEZIER",
drawCircles: true,
lineWidth: 3,
}
}],
},
}
};
}
pickerZoomSelected (value) {
// this.setState({selectedPeriod: value})
global.graphStateChoosen = value
this.resetscreen();
}
resetscreen() {
}
pickerDaysSelected (value) {
this.setState({days: value})
}
isGraphItemLegal (itemTime) {
// console.log ("log was made at: "+itemTime)
// multiplied by 1000 so that the argument is in milliseconds, not seconds.
var itemDate = new Date(itemTime*1000);
// Hours part from the timestamp
var itemHour = itemDate.getHours();
// Minutes part from the timestamp
var itemMinutes = "0" + itemDate.getMinutes();
// Seconds part from the timestamp
var itemSeconds = "0" + itemDate.getSeconds();
// console.log ("refactured: "+itemHour + ':' + itemMinutes.substr(-2) + ':' + itemSeconds.substr(-2));
// console.log ("selected piriod: " + this.state.selectedPeriod);
if (global.graphStateChoosen == "all") {
// console.log ("dont show item+1");
return true;
}
// //blocks morning \ afternoon \ evening
if ((itemHour > 18) && (itemHour < 24)) {
console.log ("EVENING ITEM: "+itemHour + ':' + itemMinutes.substr(-2) + ':' + itemSeconds.substr(-2));
// console.log ("is it equal ?: " + this.state.selectedPeriod == "evening");
if (!(global.graphStateChoosen == "evening")|| (global.graphStateChoosen == "all")) {
// console.log ("dont show item+1");
return false;
}
} else if ((itemHour > 6) && (itemHour < 13)) {
console.log ("MORNING ITEM: "+itemHour + ':' + itemMinutes.substr(-2) + ':' + itemSeconds.substr(-2));
// console.log ("is it equal ?: " + this.state.selectedPeriod == "evening");
if (!(global.graphStateChoosen === "morning")|| (global.graphStateChoosen == "all")) {
// console.log ("dont show item+2");
return false;
}
} else if ((itemHour > 13) && (itemHour < 18)) {
console.log ("AFTERNOON ITEM: "+itemHour + ':' + itemMinutes.substr(-2) + ':' + itemSeconds.substr(-2));
if (! (global.graphStateChoosen == "afternoon") || (global.graphStateChoosen == "all")) {
// console.log ("dont show item+3");
return false;
}
}
return true;
}
clearList() {
}
creatList(logs){
// var startTime = moment().millisecond();
var list = [];
var time = false;
if (logs) {
// console.log('firstLogDay',moment(firstLogDay).format('L'));
let gloguseItems = [];
let shortItems = [];
let longItems = [];
let firstValidFlag = false;
let firstLogTime;
let lastLogTime;
let days;
let firstLogDate;
let firstLogDayTime;
lastLogTime = logs[0].time;
for (var i = logs.length-1; i >= 0; i--) {
console.log ("cheking i: "+i);
let item = logs[i];
if ( !firstValidFlag && ['glucose', 'long', 'short'].indexOf(item.type) >= 0 ) {
// debugger;
firstValidFlag = true;
firstLogTime = logs[i].time;
days = Math.round((lastLogTime-firstLogTime)/(1000*60*60*24));
firstLogDate = moment(firstLogTime).format('YYYY-MM-DD');
// console.log('firstLogDate',firstLogDate);
firstLogDayTime = new Date(firstLogDate);
firstLogDayTime = firstLogDayTime.getTime() - (2*60*60*1000);
// console.log('firstLogDayTime',firstLogDayTime);
}
console.log ("runing on i: "+i);
console.log ("with our time: "+this.state.graphStateChoosen);
var isItemLegal = this.isGraphItemLegal(item.time);
console.log ("answer is: " + isItemLegal);
if ((firstValidFlag) && (isItemLegal)) {
let logX = ( item.time - firstLogDayTime ) / 1000 / 60 / 60 /24;
// logX = Math.round(logX * 10) / 10
if (logX) {
// logX = item.time;
// console.log(logX);
let logY = item.amount;
if (item.type !== 'glucose') {
if (item.type === 'short') {
shortItems.push({
x: logX,
y: logY*20,
marker: logY+ " units of short insulin" + moment(item.time).format('LLL')
})
}
if (item.type === 'long') {
longItems.push({
x: logX,
y: logY*20,
marker: logY+ " units of long insulin" + moment(item.time).format('LLL')
})
}
}else{
if(item.type === 'glucose'){
gloguseItems.push({
x: logX,
y: parseInt(logY),
marker: "level: "+ logY + moment(item.time).format('LLL')
})
}
}
}
}
}
console.log ("Reached Here");
let oldData = this.state.data;
console.log ("Reached Here 1");
oldData.lineData.dataSets[0].values = gloguseItems;
oldData.barData.dataSets[1].values = shortItems;
oldData.barData.dataSets[0].values = longItems;
let DayFlag = firstLogDate;
let valueFormatter = [];
console.log ("Reached Here 2");
valueFormatter.push(moment(DayFlag).format('DD/MM/YYYY'));
for (let i = 0; i < days; i++) {
DayFlag = moment(DayFlag).add(1, 'days');
valueFormatter.push(DayFlag.format('DD/MM/YYYY'));
}
console.log ("Reached Here 3");
let xAxis = this.state.xAxis;
xAxis.valueFormatter = valueFormatter;
console.log ("Reached Here 4");
this.setState({
data:oldData,
days:days,
xAxis:xAxis,
loading:false
});
console.log ("Reached Here 5");
}else{
this.setState({loading:false});
}
}
componentDidMount() {
this.creatList(this.props.logs);
}
zoomTwentyPressed() {
console.log ("pressed 25");
}
zoomFiftyPressed() {
console.log ("pressed 50");
}
zoomHundredPressed() {
console.log ("pressed 100"+this.state.days);
// CHANGE ZOOM HERE
// this.combinedChart.zoom = 80
// this.setState({days: this.state.days/2})
}
static displayName = 'Combined';
handleSelect(event) {
let entry = event.nativeEvent
if (entry == null) {
this.setState({...this.state, selectedEntry: null})
} else {
this.setState({...this.state, selectedEntry: JSON.stringify(entry)})
}
// console.log(event.nativeEvent)
}
render() {
return (
<View style={styles.container}>
{
(this.state.loading) ? <Text>Loading</Text>:
<CombinedChart
data={this.state.data}
ref={component => this.combinedChart = component}
xAxis={this.state.xAxis}
yAxis={this.state.yAxis}
legend={this.state.legend}
onSelect={this.handleSelect.bind(this)}
onChange={(event) => console.log(event.nativeEvent)}
marker={this.state.marker}
animation={{durationY: 1000,durationX: 1000}}
maxVisibleValueCount={16}
autoScaleMinMaxEnabled={true}
zoom={{scaleX: Math.floor(this.state.days/12), scaleY: 1, xValue: 4, yValue: 4, axisDependency: 'LEFT'}}
style={styles.container}/>
}
<Text style={{
paddingLeft: 20,
fontSize: 20
}}>
Zoom Options
</Text>
<View style={styles.buttonWrap}>
<View style={{
flexDirection:'row',
marginLeft:20,
paddingLeft: 80,
paddingTop: 20,
justifyContent: 'space-around',
}}>
<Picker
style={{width:'80%'}}
selectedValue={this.state.PickerValueLong}
onValueChange={ (itemValue, itemIndex) => {
// console.log(this);
this.pickerDaysSelected(itemValue);
}}>
<Picker.Item label="x100 Days" value='100' />
<Picker.Item label="x50 Days" value='50' />
<Picker.Item label="x25 Days" value='25' />
</Picker>
<Picker
style={{width:'80%'}}
selectedValue= {global.graphStateChoosen}
onValueChange= {(itemValue, itemIndex) => {
// console.log(this);
this.pickerZoomSelected(itemValue);
}}>
<Picker.Item label="Morning" value='morning' />
<Picker.Item label="Afternoon" value='afternoon' />
<Picker.Item label="Evening" value='evening' />
<Picker.Item label="All Day" value='all' />
</Picker>
</View>
</View>
</View>
);
}
}
thnx
It's not something you usually do from inside that screen. But on it's container, you could render a null and the scene back again.
call this.componentWillMount()

Finding out scroll direction in react-native listview/scrollview

Is there a way to find out the scroll direction of react-native's listview/scrollview components?
Native iOS components seem to be able to do it by calculating the offset from scrollViewWillBeginDragging and scrollViewDidScroll, but there seems to be no bindings for these.
You can use the onScroll event of a ScrollView and check the contentOffset in the event callback:
https://rnplay.org/apps/FltwOg
'use strict';
var React = require('react-native');
var {
AppRegistry,
StyleSheet,
ScrollView,
Text,
View,
} = React;
var SampleApp = React.createClass({
offset: 0,
onScroll: function(event) {
var currentOffset = event.nativeEvent.contentOffset.y;
var direction = currentOffset > this.offset ? 'down' : 'up';
this.offset = currentOffset;
console.log(direction);
},
render: function() {
return (
<View style={styles.container}>
<ScrollView style={styles.scroller} onScroll={this.onScroll}>
<Text>Scrollable content here</Text>
</ScrollView>
</View>
)
}
});
var styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 50,
},
scroller: {
height: 5000,
},
});
AppRegistry.registerComponent('SampleApp', () => SampleApp);
I had problems with alexp's solution to accurately specify the direction when the difference between the old and the new offset was very small. So I filtered them out.
_onScroll = event => {
const currentOffset = event.nativeEvent.contentOffset.y;
const dif = currentOffset - (this.offset || 0);
if (Math.abs(dif) < 3) {
console.log('unclear');
} else if (dif < 0) {
console.log('up');
} else {
console.log('down');
}
this.offset = currentOffset;
};

Scrollable image with pinch-to-zoom in react-native

I'm trying to display an image in my React Native app (Android) and I want to give users an ability to zoom that image in and out.
This also requires the image to be scrollable once zoomed in.
How would I go about it?
I tried to use ScrollView to display a bigger image inside, but on Android it can either scroll vertically or horizontally, not both ways.
Even if that worked there is a problem of making pinch-to-zoom work.
As far as I understand I need to use PanResponder on a custom view to zoom an image and position it accordingly. Is there an easier way?
I ended up rolling my own ZoomableImage component. So far it's been working out pretty well, here is the code:
import React, { Component } from "react";
import { View, PanResponder, Image } from "react-native";
import PropTypes from "prop-types";
function calcDistance(x1, y1, x2, y2) {
const dx = Math.abs(x1 - x2);
const dy = Math.abs(y1 - y2);
return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}
function calcCenter(x1, y1, x2, y2) {
function middle(p1, p2) {
return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2;
}
return {
x: middle(x1, x2),
y: middle(y1, y2)
};
}
function maxOffset(offset, windowDimension, imageDimension) {
const max = windowDimension - imageDimension;
if (max >= 0) {
return 0;
}
return offset < max ? max : offset;
}
function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) {
const xDiff = imageWidth * zoom - width;
const yDiff = imageHeight * zoom - height;
return {
left: -xDiff / 2,
top: -yDiff / 2
};
}
class ZoomableImage extends Component {
constructor(props) {
super(props);
this._onLayout = this._onLayout.bind(this);
this.state = {
zoom: null,
minZoom: null,
layoutKnown: false,
isZooming: false,
isMoving: false,
initialDistance: null,
initialX: null,
initalY: null,
offsetTop: 0,
offsetLeft: 0,
initialTop: 0,
initialLeft: 0,
initialTopWithoutZoom: 0,
initialLeftWithoutZoom: 0,
initialZoom: 1,
top: 0,
left: 0
};
}
processPinch(x1, y1, x2, y2) {
const distance = calcDistance(x1, y1, x2, y2);
const center = calcCenter(x1, y1, x2, y2);
if (!this.state.isZooming) {
const offsetByZoom = calcOffsetByZoom(
this.state.width,
this.state.height,
this.props.imageWidth,
this.props.imageHeight,
this.state.zoom
);
this.setState({
isZooming: true,
initialDistance: distance,
initialX: center.x,
initialY: center.y,
initialTop: this.state.top,
initialLeft: this.state.left,
initialZoom: this.state.zoom,
initialTopWithoutZoom: this.state.top - offsetByZoom.top,
initialLeftWithoutZoom: this.state.left - offsetByZoom.left
});
} else {
const touchZoom = distance / this.state.initialDistance;
const zoom =
touchZoom * this.state.initialZoom > this.state.minZoom
? touchZoom * this.state.initialZoom
: this.state.minZoom;
const offsetByZoom = calcOffsetByZoom(
this.state.width,
this.state.height,
this.props.imageWidth,
this.props.imageHeight,
zoom
);
const left =
this.state.initialLeftWithoutZoom * touchZoom + offsetByZoom.left;
const top =
this.state.initialTopWithoutZoom * touchZoom + offsetByZoom.top;
this.setState({
zoom,
left:
left > 0
? 0
: maxOffset(left, this.state.width, this.props.imageWidth * zoom),
top:
top > 0
? 0
: maxOffset(top, this.state.height, this.props.imageHeight * zoom)
});
}
}
processTouch(x, y) {
if (!this.state.isMoving) {
this.setState({
isMoving: true,
initialX: x,
initialY: y,
initialTop: this.state.top,
initialLeft: this.state.left
});
} else {
const left = this.state.initialLeft + x - this.state.initialX;
const top = this.state.initialTop + y - this.state.initialY;
this.setState({
left:
left > 0
? 0
: maxOffset(
left,
this.state.width,
this.props.imageWidth * this.state.zoom
),
top:
top > 0
? 0
: maxOffset(
top,
this.state.height,
this.props.imageHeight * this.state.zoom
)
});
}
}
_onLayout(event) {
const layout = event.nativeEvent.layout;
if (
layout.width === this.state.width &&
layout.height === this.state.height
) {
return;
}
const zoom = layout.width / this.props.imageWidth;
const offsetTop =
layout.height > this.props.imageHeight * zoom
? (layout.height - this.props.imageHeight * zoom) / 2
: 0;
this.setState({
layoutKnown: true,
width: layout.width,
height: layout.height,
zoom,
offsetTop,
minZoom: zoom
});
}
componentWillMount() {
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: () => {},
onPanResponderMove: evt => {
const touches = evt.nativeEvent.touches;
if (touches.length === 2) {
this.processPinch(
touches[0].pageX,
touches[0].pageY,
touches[1].pageX,
touches[1].pageY
);
} else if (touches.length === 1 && !this.state.isZooming) {
this.processTouch(touches[0].pageX, touches[0].pageY);
}
},
onPanResponderTerminationRequest: () => true,
onPanResponderRelease: () => {
this.setState({
isZooming: false,
isMoving: false
});
},
onPanResponderTerminate: () => {},
onShouldBlockNativeResponder: () => true
});
}
render() {
return (
<View
style={this.props.style}
{...this._panResponder.panHandlers}
onLayout={this._onLayout}
>
<Image
style={{
position: "absolute",
top: this.state.offsetTop + this.state.top,
left: this.state.offsetLeft + this.state.left,
width: this.props.imageWidth * this.state.zoom,
height: this.props.imageHeight * this.state.zoom
}}
source={this.props.source}
/>
</View>
);
}
}
ZoomableImage.propTypes = {
imageWidth: PropTypes.number.isRequired,
imageHeight: PropTypes.number.isRequired,
source: PropTypes.object.isRequired
};
export default ZoomableImage;
There's a much easier way now.
Just make a ScollView with minimumZoomScale and maximumZoomScale:
import React, { Component } from 'react';
import { AppRegistry, ScrollView, Text } from 'react-native';
export default class IScrolledDownAndWhatHappenedNextShockedMe extends Component {
render() {
return (
<ScrollView minimumZoomScale={1} maximumZoomScale={5} >
<Text style={{fontSize:96}}>Scroll me plz</Text>
<Text style={{fontSize:96}}>If you like</Text>
<Text style={{fontSize:96}}>Scrolling down</Text>
<Text style={{fontSize:96}}>What's the best</Text>
<Text style={{fontSize:96}}>Framework around?</Text>
<Text style={{fontSize:80}}>React Native</Text>
</ScrollView>
);
}
}
// skip these lines if using Create React Native App
AppRegistry.registerComponent(
'AwesomeProject',
() => IScrolledDownAndWhatHappenedNextShockedMe);
In my case I have to add images inside Viewpager with Zoom functionality.
So I have used these two library.
import ViewPager from '#react-native-community/viewpager'
import PhotoView from 'react-native-photo-view-ex';
which you can install from.
npm i #react-native-community/viewpager
npm i react-native-photo-view-ex
So I have used this code.
class ResumeView extends React.Component {
render() {
preivewArray = this.props.showPreview.previewArray
var pageViews = [];
for (i = 0; i < preivewArray.length; i++) {
pageViews.push(<View style={style.page}>
<PhotoView
source={{ uri: preivewArray[i].filePath }}
minimumZoomScale={1}
maximumZoomScale={3}
// resizeMode='stretch'
style={{ width: a4_width, height: a4_height, alignSelf: 'center' }} />
</View>);
}
return (
<ViewPager
onPageScroll={this.pageScroll}
style={{ width: '100%', height: a4_height }}>
{pageViews}
</ViewPager>
)
}
pageScroll = (event) => {
console.log("onPageScroll")
}
}
You can simply use the react-native-image-zoom-viewer or react-native-image-pan-zoom library for that. Using this libraries you don't have to code manually.
npm i react-native-photo-view-ex
import PhotoView from 'react-native-photo-view-ex';
<PhotoView
style={{ flex: 1, width: '100%', height: '100%' }}
source={{ uri: this.state.filePath }} // you can supply any URL as well
minimumZoomScale={1} // max value can be 1
maximumZoomScale={2} // max value can be 3
/>
Don't go deep if you are working with react-native because things will go more and more complex as deep you go.
Give it a try...
npm i react-native-image-zoom-viewer --save
or
yarn add react-native-image-zoom-viewer
copy this code and put it in app.js and hit Run button.
import React from 'react';
import {View} from 'react-native';
import ImageViewer from 'react-native-image-zoom-viewer';
const image = [
{
url:
'https://static8.depositphotos.com/1020341/896/i/950/depositphotos_8969502-stock-photo-human-face-with-cracked-texture.jpg',
},
];
const App = () => {
return (
<View style={{flex: 1}}>
<ImageViewer imageUrls={image} />
</View>
);
};
export default App;
Features like zoom, pan, tap/swipe to switch image using react-native-gesture-handler,react-native-reanimated. Perfectly and smoothly running on android/ios.
USAGE
<ImageZoomPan
uri={'https://picsum.photos/200/300'}
activityIndicatorProps={{
color: COLOR_SECONDARY,
}}
onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd}
onLongPressActiveInteration={onPressIn}
onLongPressEndInteration={onPressOut}
onSwipeTapForNext={onSwipeTapForNext}
onSwipeTapForPrev={onSwipeTapForPrev}
minScale={0.8}
onLoadEnd={start}
resizeMode={isFullScreen ? 'cover' : 'contain'} //'stretch'
/>
Image Zoom component
import React, {useRef, useState} from 'react';
import {ActivityIndicator,Dimensions, Image} from 'react-native';
import {
LongPressGestureHandler,
PanGestureHandler,
PinchGestureHandler,
State,
TapGestureHandler,
} from 'react-native-gesture-handler';
import Animated, {
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import styles from './styles';
const clamp = (value, min, max) => {
'worklet';
return Math.min(Math.max(min, value), max);
};
const noop = () => {};
const getDeviceWidth = () => {
return Dimensions.get('window').width;
};
const AnimatedImage = Animated.createAnimatedComponent(Image);
export default function ImageZoom({
uri = '',
minScale = 1,
maxScale = 5,
minPanPointers = 2,
maxPanPointers = 2,
isPanEnabled = true,
isPinchEnabled = true,
onLoadEnd = noop,
onInteractionStart = noop,
onInteractionEnd = noop,
onPinchStart = noop,
onPinchEnd = noop,
onPanStart = noop,
onPanEnd = noop,
onLongPressActiveInteration = noop,
onLongPressEndInteration = noop,
onSwipeTapForNext = noop,
onSwipeTapForPrev = noop,
style = {},
containerStyle = {},
imageContainerStyle = {},
activityIndicatorProps = {},
renderLoader,
resizeMode = 'cover',
...props
}) {
const panRef = useRef();
const pinchRef = useRef();
const isInteracting = useRef(false);
const isPanning = useRef(false);
const isPinching = useRef(false);
const doubleTapRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [state, setState] = useState({
canInteract: false,
centerX: 0,
centerY: 0,
});
const {canInteract, centerX, centerY} = state;
const scale = useSharedValue(1);
const initialFocalX = useSharedValue(0);
const initialFocalY = useSharedValue(0);
const focalX = useSharedValue(0);
const focalY = useSharedValue(0);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const onInteractionStarted = () => {
if (!isInteracting.current) {
isInteracting.current = true;
onInteractionStart();
}
};
const onInteractionEnded = () => {
if (isInteracting.current && !isPinching.current && !isPanning.current) {
isInteracting.current = false;
onInteractionEnd();
}
};
const onPinchStarted = () => {
onInteractionStarted();
isPinching.current = true;
onPinchStart();
};
const onPinchEnded = () => {
isPinching.current = false;
onPinchEnd();
onInteractionEnded();
};
const onPanStarted = () => {
onInteractionStarted();
isPanning.current = true;
onPanStart();
};
const onPanEnded = () => {
isPanning.current = false;
onPanEnd();
onInteractionEnded();
};
const panHandler = useAnimatedGestureHandler({
onActive: event => {
translateX.value = event.translationX;
translateY.value = event.translationY;
},
onFinish: () => {
translateX.value = withTiming(0);
translateY.value = withTiming(0);
},
});
const pinchHandler = useAnimatedGestureHandler({
onStart: event => {
initialFocalX.value = event.focalX;
initialFocalY.value = event.focalY;
},
onActive: event => {
// onStart: focalX & focalY result both to 0 on Android
if (initialFocalX.value === 0 && initialFocalY.value === 0) {
initialFocalX.value = event.focalX;
initialFocalY.value = event.focalY;
}
scale.value = clamp(event.scale, minScale, maxScale);
focalX.value = (centerX - initialFocalX.value) * (scale.value - 1);
focalY.value = (centerY - initialFocalY.value) * (scale.value - 1);
},
onFinish: () => {
scale.value = withTiming(1);
focalX.value = withTiming(0);
focalY.value = withTiming(0);
initialFocalX.value = 0;
initialFocalY.value = 0;
},
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: translateX.value},
{translateY: translateY.value},
{translateX: focalX.value},
{translateY: focalY.value},
{scale: scale.value},
],
}));
const onLayout = ({
nativeEvent: {
layout: {x, y, width, height},
},
}) => {
setState(current => ({
...current,
canInteract: true,
centerX: x + width / 2,
centerY: y + height / 2,
}));
};
const onImageLoadEnd = () => {
onLoadEnd();
setIsLoading(false);
};
const onLongPress = event => {
if (event.nativeEvent.state === State.ACTIVE) {
onLongPressActiveInteration();
}
if (
event.nativeEvent.state === State.END ||
event.nativeEvent.state === State.CANCELLED
) {
onLongPressEndInteration();
}
};
const onSingleTapEvent = event => {
let e = event.nativeEvent;
if (e.state === State.ACTIVE) {
if (e.x < getDeviceWidth() / 2) {
onSwipeTapForPrev();
} else {
onSwipeTapForNext();
}
}
};
return (
<PinchGestureHandler
ref={pinchRef}
simultaneousHandlers={[panRef]}
onGestureEvent={pinchHandler}
onActivated={onPinchStarted}
onCancelled={onPinchEnded}
onEnded={onPinchEnded}
onFailed={onPinchEnded}
enabled={isPinchEnabled && canInteract}>
<Animated.View style={[styles.container, containerStyle]}>
<PanGestureHandler
ref={panRef}
simultaneousHandlers={[pinchRef]}
onGestureEvent={panHandler}
onActivated={onPanStarted}
onCancelled={onPanEnded}
onEnded={onPanEnded}
onFailed={onPanEnded}
minPointers={minPanPointers}
maxPointers={maxPanPointers}
enabled={isPanEnabled && canInteract}>
<Animated.View
onLayout={onLayout}
style={[styles.content, imageContainerStyle]}>
<TapGestureHandler
waitFor={doubleTapRef}
onHandlerStateChange={onSingleTapEvent}>
<TapGestureHandler
ref={doubleTapRef}
onHandlerStateChange={() => null}
numberOfTaps={2}>
<LongPressGestureHandler
onHandlerStateChange={onLongPress}
minDurationMs={800}>
<AnimatedImage
style={[styles.container, style, animatedStyle]}
source={{uri}}
resizeMode={resizeMode}
onLoadEnd={onImageLoadEnd}
{...props}
/>
</LongPressGestureHandler>
</TapGestureHandler>
</TapGestureHandler>
{isLoading &&
(renderLoader ? (
renderLoader()
) : (
<ActivityIndicator
size="large"
style={styles.loader}
color="dimgrey"
{...activityIndicatorProps}
/>
))}
</Animated.View>
</PanGestureHandler>
</Animated.View>
</PinchGestureHandler>
);
}