Finding out scroll direction in react-native listview/scrollview - react-native

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;
};

Related

Implement smooth text transition in React Native

I try to implement component that takes takes text property and depend on previous value shows smooth transition:
import React, { useEffect, useRef } from 'react'
import { Animated, StyleSheet } from 'react-native'
const usePrevious = (value) => {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export default function AnimatedText({ style, children }) {
const fadeInValue = new Animated.Value(0)
const fadeOutValue = new Animated.Value(1)
const prevChildren = usePrevious(children)
useEffect(() => {
if (children != prevChildren) {
animate()
}
}, [children])
const animate = () => {
Animated.parallel([
Animated.timing(fadeInValue, {
toValue: 1,
duration: 1000,
useNativeDriver: true
}),
Animated.timing(fadeOutValue, {
toValue: 0,
duration: 1000,
useNativeDriver: true
})
]).start()
}
return (
<>
<Animated.Text style={[ style, { opacity: fadeInValue }]}>{children}</Animated.Text>
{
prevChildren &&
<Animated.Text style={[ style, styles.animatedText, { opacity: fadeOutValue }]}>{prevChildren}</Animated.Text>
}
</>
)
}
const styles = StyleSheet.create({
animatedText: {
position: 'absolute',
top: 0,
left: 0,
}
})
As the result I got smooth transition between component rendering with different children arguments. But there is a flickering due to some reasons related to animated value updates. Is there any way to avoid this problem or better solution to implement such component?
Found the solution with react-native-reanimated. It's not elegant implementation but it seems to work correctly without flickering:
import React, { useEffect, useRef } from 'react'
import { StyleSheet } from 'react-native'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
const usePrevious = (value) => {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export default function AnimatedText({ style, children }) {
const fadeValue1 = useSharedValue(0)
const fadeValue2 = useSharedValue(1)
const toggleFlagRef = useRef(false)
const animatedTextStyle1 = useAnimatedStyle(() => {
return {
opacity: withTiming(fadeValue1.value, { duration: 1000 })
}
})
const animatedTextStyle2 = useAnimatedStyle(() => {
return {
opacity: withTiming(fadeValue2.value, { duration: 1000 })
}
})
const prevChildren = usePrevious(children)
useEffect(() => {
if (children != prevChildren) {
animate()
}
}, [children])
const animate = () => {
if (toggleFlagRef.current) {
fadeValue1.value = 0
fadeValue2.value = 1
} else {
fadeValue1.value = 1
fadeValue2.value = 0
}
toggleFlagRef.current = !toggleFlagRef.current
}
return (
<>
<Animated.Text style={[ style, animatedTextStyle1 ]}>{toggleFlagRef.current ? prevChildren : children}</Animated.Text>
{
prevChildren &&
<Animated.Text style={[ style, styles.animatedText, animatedTextStyle2 ]}>{toggleFlagRef.current ? children : prevChildren}</Animated.Text>
}
</>
)
}
const styles = StyleSheet.create({
animatedText: {
position: 'absolute',
top: 0,
left: 0,
}
})

React Native Flatlist custom refresh control with Lottie is glitchy

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!

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...

Detect ScrollView has reached the end

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>
)
}

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>
);
}