Closed hardcodet closed 6 years ago
Hi @hardcodet,
You're right: rendering more than one gesture-aware component at a time can be a straight path to madness :-)
What about using react-native-image-gallery
, another component of ours, on top of the carousel? For example, you could render a carousel of pictures and then, when the user clicks on one of them, open the gallery and display a high-res zoomable version of the picture.
Would it answer you need or did I overlook something?
Thanks @bd-arc
You got the madness part right :)
I looked into the gallery - it a bit simplified (I wouldn't want to just set a few image URLs, for example), but it might be a good starting point I I have to go with a custom fork.
As far as the combination goes: I wouldn't want to use both carousel and the gallery, since they are very similar, so the distinction might be a bit weird and I would just switch from a non-full-screen image to a full screen image, which doesn't make too much sense in my case.
Thanks for the quick reply, I appreciate it!
Do you have a visual example of what you're trying to achieve?
I'm curious and always up for a challenge ;-)
Haha, awesome!
Basically, your carousel can do everything your gallery can (and more!). But I basically "just" need a control that is capable of flexibly handling some content (in the end, it's an image) that can be zoomed, with a pager and preferably some nice animations when flipping between pages.
I need it to be a bit more flexible than just supplying a list of image URIs. My current control displays a spinner while processing the image, and then shows the image once it's available. So I have just a wrapper control that I'm currently using along with react-native-pager. That worked beautifully, but once I introduced zooming capabilities in the wrapper, it messed everything up :)
Was that somehow understandable?
Hey @hardcodet,
I think I understand what you're after. A carousel with custom animations between items, placeholders, loaders, fade-in apparition of images once loaded... This I can provide ;-)
But I wouldn't even know where to start in order to add a zooming feature without loading another view and while preserving the swiping ability.
Wix had a pretty good project going on, react-native-interactable
, in which the team has been exploring advanced interactions with React Native. Maybe this can prove useful to you.
@hardcodet @bd-arc
Hi folks, found this issue while also trying to implement a zoom style feature with the gallery. My use case is a digital magazine where you can "flip" through pages, but where you should also be able to zoom in to read content, see images, etc.
I ended up utilizing react-native-image-zoom
with this library and utilizing the scrollEnabled
prop to switch between zoom of a single image and flipping freely through all images.
A code snippet is worth 10,000 words, so here's a quick overview of my first stab at it:
import React, { Component } from 'react';
import { Dimensions, Image } from 'react-native';
import ImageZoom from 'react-native-image-pan-zoom';
import Carousel from 'react-native-snap-carousel';
import cover from './../../home/issueGallery/cover.png';
const data = [{page: 1}, {page: 2}, {page: 3}, {page: 4}, {page: 5}, {page: 6}, {page: 7}, {page: 8}, {page: 9}];
class IssueViewer extends Component {
constructor(props) {
super(props);
this.state = {
scrollable: true
};
this._handlePageZoom = this._handlePageZoom.bind(this);
this._renderItem = this._renderItem.bind(this);
this._handleDoubleClick = this._handleDoubleClick.bind(this);
}
render() {
return (
<Carousel
data={ data }
renderItem={ this._renderItem }
sliderWidth={ Dimensions.get('window').width }
itemWidth={ (Dimensions.get('window').width) }
layout='default'
scrollEnabled={ this.state.scrollable }
/>
);
}
_handlePageZoom({ type, scale }) {
if (scale !== 1) {
this.setState({ scrollable: false });
} else if (scale === 1) {
this.setState({ scrollable: true });
}
}
_handleDoubleClick() {
this.setState({ scrollable: !this.state.scrollable });
}
_renderItem({ item, index }) {
return (
<ImageZoom cropWidth={Dimensions.get('window').width}
cropHeight={Dimensions.get('window').height}
imageWidth={400}
imageHeight={400}
onMove={ this._handlePageZoom }
onDoubleClick={ this._handleDoubleClick }>
<Image source={ cover } style={{ width: Dimensions.get('window').width }} />
</ImageZoom>
);
}
};
export default IssueViewer;
Hopefully that helps solve an issue for someone else down the road! If this approach is deemed "okay", I'd be more than happy to write up some documentation for the README or wiki to help future devs.
Thanks for your hard work on this project, it's super easy to use and so useful! Much thanks!
@bd-arc @jacobsmith You guys are awesome!
I'll be in the mountains for two days but really looking forward to trying this out! I went a similar route wtih react-native pages, but the results where a bit underwhelming - I'll definitely try this out and will be posting back. Thanks!
Hey @jacobsmith,
Thank you very much for sharing your idea! I wasn't aware of the react-native-image-zoom
plugin, but taking advantage of its double-click feature in conjunction with the scrollEnabled
prop looks like a very elegant solution 👍
If this isn't too much to ask, would you mind putting together a simple Snack example so I can check on the usability and the performance of your solution? My only concern has to do with the potentially big number of re-renders; have you experienced any performance issue so far?
By the way, since your app is a digital magazine you might be interested in knowing that I've recently implemented a feature that allows you to customize the transition between items (or pages in your use case) ;-)
@jacobsmith
I tried to apply your pattern, but the problem is that the nested ImageZoom
appears to steal all the move events from carousel (I guess its PanResponder
grabs all the gesture events). Accordingly, as soon as I replace a regular image with the ImageZoom
, I can zoom and pan the image, but not scroll anymore (a swipe just moves my image a few pixels, then snaps it back into place).
I did try the same with the snippet you posted, with the same result. This is even true if I hardcode scrollEnabled={true}
Did you customize ImageZoom
in order to achieve the desired result, or am I missing something else? Thanks!
@bd-arc and @hardcodet
I've attached a quick snack here. I haven't noticed any issues with performance (tested up to 100 pages, scrolling and zoom still seem to work fine). Of note, you are either in "Scrolling" mode or in "Zoom" mode.
And @bd-arc yes, I've looked at the possibility of adding custom swipe animations. If I end up implementing a "page turn" animation, I'll be sure to let you know! Thanks for your work on this, it's a great project!!
@hardcodet let me know if this doesn't fix your problem and I'll try and take a look at your implementation!
@jacobsmith
Thanks for putting in the work! Unfortunately, I can confirm that I'm seeing the same issue with your snack - I can easily zoom the bear, but wouldn't know if there are other pictures since swiping doesn't work. Question: Are you testing on a real device?
Test done on Android (Samsung Galaxy S6)
Edit: I just noticed that the Expo online emulator also doesn't swipe if you select an Android device.
@hardcodet I've been testing on a real iPhone 5c, but I haven't done it on Android. I'll pull out an old android phone I have later and give it a test (because I need to support Android for the project I'm working on, so if this doesn't work, I'll need to re-evaluate the approach and find something that works on both platforms). Thanks for giving it a shot; hopefully I'll find the issue on the Android side within the next few days and have a fix!
The problem is the competing gesture listeners. In theory, it would be simple:
PanResponder
of the ImageZoom
should handle the gesture ImageZoom
shouldn't grab the gesture and let the carousel do the swipingThe problem now is that the number of touches is always 1 in the PanResponder's Start/Capture callbacks. I didn't see a simple way to delegate the gestures between controls after that. I'm really surprised about that shortcoming in RN - but still hoping I overlooked something.
Yes, I've come to roughly the same conclusion myself. I very rarely do see swiping on android (maybe 5% of the time), so I wonder if it's a race condition between gestureResponders? (I haven't dealt with eventing in React Native much so that could be way off, just learning as I go).
From RN docs:
(numberActiveTouches) may not be totally accurate unless you are the responder.
I'm not quite sure what the impact of that statement is yet...
The problem is that in the onStartShouldSetPanResponder
where you determine whether you want to handle the gesture or not, numberActiveTouches
just as nativeEvent.touches.length
is always 1
. My guess is because even multiple fingers consist of multiple touches, and RN just reports the first touch. If this was reported differently (correctly?), you could easily determine whether to handle the gesture or not.
I have a somewhat working solution with a PanResponder
that I declare on a parent view of the carousel, and then forward the callback data to the currently active ImageZoom
of the carousel, but it's a dirty hack, and it also doesn't work in all cases (e.g. when you zoom too quickly, or swipe with two fingers). Dunno whether we need to go native here - but then, this is such a common scenario - pretty much every app that handles media has some sort of gallery with zoom features. *sigh* ;)
@hardcodet would you mind sharing that "dirty hack" of a solution? 😄
Sure. Keep in mind that it's really hackery and not in a good state (also, I have only been a RN developer for a few weeks ;)
Basically, I have a model that declares a PanResponder
. That model maintains state to make a distinction between scroll mode and zoom mode, and it maintains GestureHandler
instances for every image I have. If the user makes a gesture, it just invokes the GestureHandler
of the currently visible image:
export class GestureModel {
@observable currentFileId: string;
@observable isNFingerGesture: boolean = false;
panResponder: PanResponderInstance;
get currentGestureHandler(): GestureHandler {
return this.gestureHandlers.find(gh => gh.fileId === this.currentFileId);
}
@computed get isScrollMode() {
const gh = this.currentGestureHandler;
return !this.isNFingerGesture && (!gh || gh.scale === 1);
}
gestureHandlers: Array<GestureHandler> = [];
constructor() {
this.panResponder = PanResponder.create({
onStartShouldSetPanResponderCapture: (evt, gestureState) => {
return !this.isScrollMode;
},
onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if (nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
return !this.isScrollMode;
},
onStartShouldSetPanResponder: (evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if (nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
return true;
},
onMoveShouldSetPanResponder: (evt, gestureState) => {
return !this.isScrollMode;
},
onPanResponderTerminationRequest: (evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if (nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
//only allow termination if we're not in scroll mode
return this.isScrollMode;
},
onPanResponderGrant: (evt, gestureState) => {
//we don't get the touches reliably in grant - only switch to
//n-finger
const nFinger = evt.nativeEvent.touches.length > 1;
if (nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
const gestureHandler = this.currentGestureHandler;
if (gestureHandler && gestureHandler.grantCallback)
gestureHandler.grantCallback(evt, gestureState);
},
onPanResponderMove: (evt, gestureState) => {
if (evt.nativeEvent.touches.length > 1 && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
//if we don't have n-finger or scaling, there's nothing to do here
if (this.isScrollMode) return;
const gestureHandler = this.currentGestureHandler;
if (!gestureHandler || !gestureHandler.moveCallback) return;
if (this.isNFingerGesture || gestureHandler.scale > 1)
gestureHandler.moveCallback(evt, gestureState);
},
onPanResponderRelease: (evt, gestureState) => {
//reset gesture flag - will revert to scroll mode if we're not zooming
this.isNFingerGesture = false;
const gestureHandler = this.currentGestureHandler;
if (gestureHandler && gestureHandler.releaseCallback)
gestureHandler.releaseCallback(evt, gestureState);
},
onPanResponderTerminate: (evt, gestureState) => {
}
});
}
}
Here's the GestureHandler
I have for every image. Note that it takes 3 delegates for the pan responder events.
export class GestureHandler {
fileId: string;
@observable scale: number = 1;
constructor(fileId: string) {
this.fileId = fileId;
}
grantCallback: (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => void;
moveCallback: (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => void;
releaseCallback: (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => void;
}
Now what I did is just declare my model's panResponder.panHandlers
on top of the Carousel
. Also, the carousel's onSnapToItem
updates the GestureModel
so that it can direct gestures to the right GestureHandler
:
<View {...this.myGestureModel.panResponder.panHandlers}>
<Carousel data={images}
renderItem={this.renderItem}
onSnapToItem={(i) => this.methodThatUpdatesCurrentIdInGestureModel()}
scrollEnabled={this.myGestureModel.isScrollMode}
... />
</View>
And in the carousel's renderItem
method, I'm declaring gesture handlers for every item along with an image control:
renderItem({item, index}) {
//register gesture handler for the current image
const gh = new GestureHandler(item.model.fileId);
this.myGestureModel.gestureHandlers.push(gh);
return (
<ImageZoom gestureHandler={gh} ... />
);
}
Last but not least, I adjusted ImageZoom
in order not to declare its own PanResponder
. Instead, it receives the GestureHandler
and registers the callbacks. This means I just slightly changed the declarations in the componentWillMount
method. Just compare it with the original implementation - it's a simple change:
public componentWillMount() {
const setResponder = isMobile();
const gh: GestureHandler = this.props.gestureHandler;
this.gestureHandler = gh;
//register callback from gesture handler instead of setting a PanResponder callback
gh.grantCallback = (evt, gestureState) => {
// 开始手势操作
this.lastPositionX = null;
this.lastPositionY = null;
this.zoomLastDistance = null;
this.horizontalWholeCounter = 0;
...
So the workflow is this now:
PanResponder
captures gesture and invokes the handlers in GestureModel
GestureModel
invokes the GestureHandler
of the currently active imageGestureHandler
invokes the callback in my adjusted ImageZoom
ImageZoom
does the same stuff as beforeThis works, but the problem is that if you zoom very fast the first time, the gesture is terminated, probably due to some race condition. Also, if you "swipe" fast with two fingers, the carousel starts to swipe and the freezes after a few pixels because the GestureModel
disables scrolling. It's better in a release build btw.
Also, keep in mind that if you put on some debugging controls that cause re-rendering of your component while you are performing gestures, the whole gesture handling can change. I learned that the hard way :)
@jacobsmith
I just realized an additional touch responder in the hierarchy also makes things much harder :)
When I removed that one, I got very nice result using just an adjusted ImageZoom. You can use this one as a drop-in replacement - no more gesture handlers or additional PanResponder
. It basically just opts out of zooming in case of regular swipes. This works very nicely for me on Android without any jerking:
export class ImageZoom2 extends React.Component<Props, State> {
public static defaultProps = new Props()
public state = new State()
// 上次/当前/动画 x 位移
private lastPositionX: number | null = null
private positionX = 0
private animatedPositionX = new Animated.Value(0)
// 上次/当前/动画 y 位移
private lastPositionY: number | null = null
private positionY = 0
private animatedPositionY = new Animated.Value(0)
// 缩放大小
private scale = 1
private animatedScale = new Animated.Value(1)
private zoomLastDistance: number | null = null
private zoomCurrentDistance = 0
// 图片手势处理
private imagePanResponder: PanResponderInstance
// 图片视图当前中心的位置
// private centerX: number
// private centerY: number
// 上次手按下去的时间
private lastTouchStartTime: number
// 滑动过程中,整体横向过界偏移量
private horizontalWholeOuterCounter = 0
// 滑动过程中,x y的总位移
private horizontalWholeCounter = 0
private verticalWholeCounter = 0
// 两手距离中心点位置
private centerDiffX = 0
private centerDiffY = 0
// 触发单击的 timeout
private singleClickTimeout: any
// 计算长按的 timeout
private longPressTimeout: any
// 上一次点击的时间
private lastClickTime = 0
// 双击时的位置
private doubleClickX = 0
private doubleClickY = 0
// 是否双击了
private isDoubleClick = false
// 是否是长按
private isLongPress = false
isNFingerGesture: boolean = false;
get isScrollMode() {
return !this.isNFingerGesture && this.scale < 1.4;
}
public componentWillMount() {
this.imagePanResponder = PanResponder.create({
// 要求成为响应者:
onMoveShouldSetPanResponderCapture:(evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if(nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
return !this.isScrollMode;
},
onStartShouldSetPanResponder: (evt, gestureState) => !this.isScrollMode,
onPanResponderTerminationRequest: (evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if(nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
return this.isScrollMode;
},
onPanResponderGrant: (evt, gestureState) => {
const nFinger = evt.nativeEvent.touches.length > 1;
if(nFinger && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
// 开始手势操作
this.lastPositionX = null
this.lastPositionY = null
this.zoomLastDistance = null
this.horizontalWholeCounter = 0
this.verticalWholeCounter = 0
this.lastTouchStartTime = new Date().getTime()
this.isDoubleClick = false
this.isLongPress = false
// 任何手势开始,都清空单击计时器
if (this.singleClickTimeout) {
clearTimeout(this.singleClickTimeout)
}
if (evt.nativeEvent.changedTouches.length > 1) {
const centerX =
(evt.nativeEvent.changedTouches[0].pageX +
evt.nativeEvent.changedTouches[1].pageX) /
2
this.centerDiffX = centerX - this.props.cropWidth / 2
const centerY =
(evt.nativeEvent.changedTouches[0].pageY +
evt.nativeEvent.changedTouches[1].pageY) /
2
this.centerDiffY = centerY - this.props.cropHeight / 2
}
// 计算长按
if (this.longPressTimeout) {
clearTimeout(this.longPressTimeout)
}
this.longPressTimeout = setTimeout(() => {
this.isLongPress = true
if (this.props.onLongPress) {
this.props.onLongPress()
}
}, this.props.longPressTime)
if (evt.nativeEvent.changedTouches.length <= 1) {
// 一个手指的情况
if (
new Date().getTime() - this.lastClickTime <
(this.props.doubleClickInterval || 0)
) {
// 认为触发了双击
this.lastClickTime = 0
if (this.props.onDoubleClick) {
this.props.onDoubleClick()
}
// 取消长按
clearTimeout(this.longPressTimeout)
// 因为可能触发放大,因此记录双击时的坐标位置
this.doubleClickX = evt.nativeEvent.changedTouches[0].pageX
this.doubleClickY = evt.nativeEvent.changedTouches[0].pageY
// 缩放
this.isDoubleClick = true
if (this.scale > 1 || this.scale < 1) {
// 回归原位
this.scale = 1
this.positionX = 0
this.positionY = 0
} else {
// 开始在位移地点缩放
// 记录之前缩放比例
// 此时 this.scale 一定为 1
const beforeScale = this.scale
// 开始缩放
this.scale = 2
// 缩放 diff
const diffScale = this.scale - beforeScale
// 找到两手中心点距离页面中心的位移
// 移动位置
this.positionX =
(this.props.cropWidth / 2 - this.doubleClickX) *
diffScale /
this.scale
this.positionY =
(this.props.cropHeight / 2 - this.doubleClickY) *
diffScale /
this.scale
}
Animated.parallel([
Animated.timing(this.animatedScale, {
toValue: this.scale,
duration: 100
}),
Animated.timing(this.animatedPositionX, {
toValue: this.positionX,
duration: 100
}),
Animated.timing(this.animatedPositionY, {
toValue: this.positionY,
duration: 100
})
]).start()
} else {
this.lastClickTime = new Date().getTime()
}
}
},
onPanResponderMove: (evt, gestureState) => {
if(evt.nativeEvent.touches.length > 1 && !this.isNFingerGesture) {
this.isNFingerGesture = true;
}
//if we don't have n-finger or scaling, there's nothing to do here
if(this.isScrollMode) return;
if (this.isDoubleClick) {
// 有时双击会被当做位移,这里屏蔽掉
return
}
if (evt.nativeEvent.changedTouches.length <= 1) {
// x 位移
let diffX = gestureState.dx - (this.lastPositionX || 0)
if (this.lastPositionX === null) {
diffX = 0
}
// y 位移
let diffY = gestureState.dy - (this.lastPositionY || 0)
if (this.lastPositionY === null) {
diffY = 0
}
// 保留这一次位移作为下次的上一次位移
this.lastPositionX = gestureState.dx
this.lastPositionY = gestureState.dy
this.horizontalWholeCounter += diffX
this.verticalWholeCounter += diffY
if (
Math.abs(this.horizontalWholeCounter) > 5 ||
Math.abs(this.verticalWholeCounter) > 5
) {
// 如果位移超出手指范围,取消长按监听
clearTimeout(this.longPressTimeout)
}
if (this.props.panToMove) {
// diffX > 0 表示手往右滑,图往左移动,反之同理
// horizontalWholeOuterCounter > 0 表示溢出在左侧,反之在右侧,绝对值越大溢出越多
if (this.props.imageWidth * this.scale > this.props.cropWidth) {
// 如果图片宽度大图盒子宽度, 可以横向拖拽
// 没有溢出偏移量或者这次位移完全收回了偏移量才能拖拽
if (this.horizontalWholeOuterCounter > 0) {
// 溢出在右侧
if (diffX < 0) {
// 从右侧收紧
if (this.horizontalWholeOuterCounter > Math.abs(diffX)) {
// 偏移量还没有用完
this.horizontalWholeOuterCounter += diffX
diffX = 0
} else {
// 溢出量置为0,偏移量减去剩余溢出量,并且可以被拖动
diffX += this.horizontalWholeOuterCounter
this.horizontalWholeOuterCounter = 0
if (this.props.horizontalOuterRangeOffset) {
this.props.horizontalOuterRangeOffset(0)
}
}
} else {
// 向右侧扩增
this.horizontalWholeOuterCounter += diffX
}
} else if (this.horizontalWholeOuterCounter < 0) {
// 溢出在左侧
if (diffX > 0) {
// 从左侧收紧
if (Math.abs(this.horizontalWholeOuterCounter) > diffX) {
// 偏移量还没有用完
this.horizontalWholeOuterCounter += diffX
diffX = 0
} else {
// 溢出量置为0,偏移量减去剩余溢出量,并且可以被拖动
diffX += this.horizontalWholeOuterCounter
this.horizontalWholeOuterCounter = 0
if (this.props.horizontalOuterRangeOffset) {
this.props.horizontalOuterRangeOffset(0)
}
}
} else {
// 向左侧扩增
this.horizontalWholeOuterCounter += diffX
}
} else {
// 溢出偏移量为0,正常移动
}
// 产生位移
this.positionX += diffX / this.scale
// 但是横向不能出现黑边
// 横向能容忍的绝对值
const horizontalMax =
(this.props.imageWidth * this.scale - this.props.cropWidth) /
2 /
this.scale
if (this.positionX < -horizontalMax) {
// 超越了左边临界点,还在继续向左移动
this.positionX = -horizontalMax
// 让其产生细微位移,偏离轨道
this.horizontalWholeOuterCounter += -1 / 1e10
} else if (this.positionX > horizontalMax) {
// 超越了右侧临界点,还在继续向右移动
this.positionX = horizontalMax
// 让其产生细微位移,偏离轨道
this.horizontalWholeOuterCounter += 1 / 1e10
}
this.animatedPositionX.setValue(this.positionX)
} else {
// 不能横向拖拽,全部算做溢出偏移量
this.horizontalWholeOuterCounter += diffX
}
// 溢出量不会超过设定界限
if (
this.horizontalWholeOuterCounter > (this.props.maxOverflow || 0)
) {
this.horizontalWholeOuterCounter = this.props.maxOverflow || 0
} else if (
this.horizontalWholeOuterCounter < -(this.props.maxOverflow || 0)
) {
this.horizontalWholeOuterCounter = -(this.props.maxOverflow || 0)
}
if (this.horizontalWholeOuterCounter !== 0) {
// 如果溢出偏移量不是0,执行溢出回调
if (this.props.horizontalOuterRangeOffset) {
this.props.horizontalOuterRangeOffset(
this.horizontalWholeOuterCounter
)
}
}
if (this.props.imageHeight * this.scale > this.props.cropHeight) {
// 如果图片高度大图盒子高度, 可以纵向拖拽
this.positionY += diffY / this.scale
this.animatedPositionY.setValue(this.positionY)
}
}
} else {
// 多个手指的情况
// 取消长按状态
if (this.longPressTimeout) {
clearTimeout(this.longPressTimeout)
}
if (this.props.pinchToZoom) {
// 找最小的 x 和最大的 x
let minX: number
let maxX: number
if (
evt.nativeEvent.changedTouches[0].locationX >
evt.nativeEvent.changedTouches[1].locationX
) {
minX = evt.nativeEvent.changedTouches[1].pageX
maxX = evt.nativeEvent.changedTouches[0].pageX
} else {
minX = evt.nativeEvent.changedTouches[0].pageX
maxX = evt.nativeEvent.changedTouches[1].pageX
}
let minY: number
let maxY: number
if (
evt.nativeEvent.changedTouches[0].locationY >
evt.nativeEvent.changedTouches[1].locationY
) {
minY = evt.nativeEvent.changedTouches[1].pageY
maxY = evt.nativeEvent.changedTouches[0].pageY
} else {
minY = evt.nativeEvent.changedTouches[0].pageY
maxY = evt.nativeEvent.changedTouches[1].pageY
}
const widthDistance = maxX - minX
const heightDistance = maxY - minY
const diagonalDistance = Math.sqrt(
widthDistance * widthDistance + heightDistance * heightDistance
)
this.zoomCurrentDistance = Number(diagonalDistance.toFixed(1))
if (this.zoomLastDistance !== null) {
const distanceDiff =
(this.zoomCurrentDistance - this.zoomLastDistance) / 200
let zoom = this.scale + distanceDiff
if (zoom < 0.6) {
zoom = 0.6
}
if (zoom > 10) {
zoom = 10
}
// 记录之前缩放比例
const beforeScale = this.scale
// 开始缩放
this.scale = zoom
this.animatedScale.setValue(this.scale)
// 图片要慢慢往两个手指的中心点移动
// 缩放 diff
const diffScale = this.scale - beforeScale
// 找到两手中心点距离页面中心的位移
// 移动位置
this.positionX -= this.centerDiffX * diffScale / this.scale
this.positionY -= this.centerDiffY * diffScale / this.scale
this.animatedPositionX.setValue(this.positionX)
this.animatedPositionY.setValue(this.positionY)
}
this.zoomLastDistance = this.zoomCurrentDistance
}
}
this.imageDidMove("onPanResponderMove")
},
onPanResponderRelease: (evt, gestureState) => {
this.isNFingerGesture = false;
// 取消长按
if (this.longPressTimeout) {
clearTimeout(this.longPressTimeout)
}
// 双击结束,结束尾判断
if (this.isDoubleClick) {
return
}
// 长按结束,结束尾判断
if (this.isLongPress) {
return
}
// 如果是单个手指、距离上次按住大于预设秒、滑动距离小于预设值, 则可能是单击(如果后续双击间隔内没有开始手势)
const stayTime = new Date().getTime() - this.lastTouchStartTime
const moveDistance = Math.sqrt(
gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy
)
if (
evt.nativeEvent.changedTouches.length === 1 &&
moveDistance < (this.props.clickDistance || 0)
) {
this.singleClickTimeout = setTimeout(() => {
if (this.props.onClick) {
this.props.onClick()
}
}, this.props.doubleClickInterval)
} else {
// 多手势结束,或者滑动结束
if (this.props.responderRelease) {
this.props.responderRelease(gestureState.vx, this.scale)
}
this.panResponderReleaseResolve()
}
},
onPanResponderTerminate: (evt, gestureState) => {
this.isNFingerGesture = false;
//
}
})
}
public panResponderReleaseResolve = () => {
if (this.scale < 1) {
// 如果缩放小于1,强制重置为 1
this.scale = 1
Animated.timing(this.animatedScale, {
toValue: this.scale,
duration: 100
}).start()
}
if (this.props.imageWidth * this.scale <= this.props.cropWidth) {
// 如果图片宽度小于盒子宽度,横向位置重置
this.positionX = 0
Animated.timing(this.animatedPositionX, {
toValue: this.positionX,
duration: 100
}).start()
}
if (this.props.imageHeight * this.scale <= this.props.cropHeight) {
// 如果图片高度小于盒子高度,纵向位置重置
this.positionY = 0
Animated.timing(this.animatedPositionY, {
toValue: this.positionY,
duration: 100
}).start()
}
// 横向肯定不会超出范围,由拖拽时控制
// 如果图片高度大于盒子高度,纵向不能出现黑边
if (this.props.imageHeight * this.scale > this.props.cropHeight) {
// 纵向能容忍的绝对值
const verticalMax =
(this.props.imageHeight * this.scale - this.props.cropHeight) /
2 /
this.scale
if (this.positionY < -verticalMax) {
this.positionY = -verticalMax
} else if (this.positionY > verticalMax) {
this.positionY = verticalMax
}
Animated.timing(this.animatedPositionY, {
toValue: this.positionY,
duration: 100
}).start()
}
// 拖拽正常结束后,如果没有缩放,直接回到0,0点
if (this.scale === 1) {
this.positionX = 0
this.positionY = 0
Animated.timing(this.animatedPositionX, {
toValue: this.positionX,
duration: 100
}).start()
Animated.timing(this.animatedPositionY, {
toValue: this.positionY,
duration: 100
}).start()
}
// 水平溢出量置空
this.horizontalWholeOuterCounter = 0
this.imageDidMove("onPanResponderRelease")
}
public componentDidMount() {
if (this.props.centerOn) {
this.centerOn(this.props.centerOn)
}
}
public componentWillReceiveProps(nextProps: Props) {
// Either centerOn has never been called, or it is a repeat and we should ignore it
if (
(nextProps.centerOn && !this.props.centerOn) ||
(nextProps.centerOn &&
this.props.centerOn &&
this.didCenterOnChange(this.props.centerOn, nextProps.centerOn))
) {
this.centerOn(nextProps.centerOn)
}
}
public imageDidMove(type: string) {
if (this.props.onMove) {
this.props.onMove({
type,
positionX: this.positionX,
positionY: this.positionY,
scale: this.scale,
zoomCurrentDistance: this.zoomCurrentDistance
})
}
}
public didCenterOnChange(
params: { x: number; y: number; scale: number; duration: number },
paramsNext: { x: number; y: number; scale: number; duration: number }
) {
return (
params.x !== paramsNext.x ||
params.y !== paramsNext.y ||
params.scale !== paramsNext.scale
)
}
public centerOn(params: ICenterOn) {
this.positionX = params!.x
this.positionY = params!.y
this.scale = params!.scale
const duration = params!.duration || 300
Animated.parallel([
Animated.timing(this.animatedScale, {
toValue: this.scale,
duration
}),
Animated.timing(this.animatedPositionX, {
toValue: this.positionX,
duration
}),
Animated.timing(this.animatedPositionY, {
toValue: this.positionY,
duration
})
]).start(() => {
this.imageDidMove("centerOn")
})
}
/**
* 图片区域视图渲染完毕
*/
public handleLayout(event: LayoutChangeEvent) {
// this.centerX = event.nativeEvent.layout.x + event.nativeEvent.layout.width / 2
// this.centerY = event.nativeEvent.layout.y + event.nativeEvent.layout.height / 2
if (this.props.layoutChange) {
this.props.layoutChange(event)
}
}
/**
* 重置大小和位置
*/
public reset() {
this.scale = 1
this.animatedScale.setValue(this.scale)
this.positionX = 0
this.animatedPositionX.setValue(this.positionX)
this.positionY = 0
this.animatedPositionY.setValue(this.positionY)
}
public render() {
const animateConf = {
transform: [
{
scale: this.animatedScale
},
{
translateX: this.animatedPositionX
},
{
translateY: this.animatedPositionY
}
]
}
return (
<View
style={{
...styles.container,
...this.props.style,
width: this.props.cropWidth,
height: this.props.cropHeight
}}
{...this.imagePanResponder.panHandlers}
>
<Animated.View style={animateConf}>
<View
onLayout={this.handleLayout.bind(this)}
style={{
width: this.props.imageWidth,
height: this.props.imageHeight
}}
>
{this.props.children}
</View>
</Animated.View>
</View>
)
}
}
First, I'd like to thank you both for the top-quality discussion :-)
@jacobsmith Thanks for putting the example together! I've tried it on miscellaneous devices; while it works pretty well on iOS (not without a few glitches, but that was expected since there was a bit of hack involved), it unfortunately isn't usable on Android: the swipe gesture works once and then it becomes permanently superseded by the zoom one.
I regularly have issues with gesture events and callbacks being handled very differently between iOS and Android; incidentally, it's one of the few things that hold this plugin back...
@hardcodet Thank you very much for sharing your component! I can't wait to try it out because, in my experience, if it works properly on Android it will be incredible on iOS ;-)
@jacobsmith @bd-arc
One thing to note:
With the adjusted ImageZoom
, taps or double clicks on the ImageZoom
are not reported if the image isn't being zoomed in, as it doesn't grab any gestures anymore (if it did, swiping wouldn't work anymore).
Also, if I wrap the ImageZoom
in a gesture responder such as TouchableWithoutFeedback
, it all falls apart and zooming doesn't work anymore since the touchable eats all the gestures. Also wrapping the whole carousel in a TouchableWithoutFeedback
doesn't work, since that even disables the whole carousel for the same reason.
I hope there's ways around this, but the whole gesture system is rather tedious (if I felt more competent at RN, I would write broken ;). Still, I think we're on a good track here.
@bd-arc @hardcodet
Thank you both for that info! I agree, as I've been working with that hacky solution, I've found more edge cases that don't play nicely together.
@hardcodet I'll have to give that updated code a shot; thanks!
Started a new job three weeks ago, so I didn't really have time, but will be hopefully back to working on my project as of next week. @jacobsmith - did you by any chance make some progress?
Did you guys had a chance to make some progress? I did not find any decent carousel + zoom RN libraries.
I have been pulled away on another project for a while, but I'm coming back to this now. I will try to update if I find anything!
@hardcodet your solution is great, especially the latter one. At least with 10 min testing, it runs smoothly on Android. This whole issue of image lightbox with zoom has been a surprising one, just like when we tried to find a working @mentioning -package.. just could not find any that worked. In any case, thanks again for the solution.
For those who want to use my fork of @hardcodet's solution: https://github.com/ankero/react-native-image-zoom
Update
After testing with iOS I decided to go with a hybrid solution, as this solution works for Android, but causes some scrolling issues on iOS. So my current solution that we are moving to deeper testing is roughly the following:
<Carousel>
{ Platform.OS === "ios" ?
( <PhotoView /> ) :
( <ImageZoom2>
<FastImage />
</ImageZoom2> ) }
</Carousel>
This solution seems to work well, the only downside is that the PhotoView does not utilise FastImage. However it is much more performant on iOS
Ps. I don't know anything about typescript and saw 2 errors with compiling imagePanResponder and lastTouchStartTime, so added the (!) to the declarations :)
Fork from @ankero is really useful, but it doesn't seem to work on Android for me - only carousel works. While it does work extremely good on ios (IOS run on the simulator, Android on a real device).
@TeslaAdis Currently the solution we use is the following. It works ok on Android and iOS.
"react-native-snap-carousel": 3.7.5, "react-native-image-pan-zoom": "git+https://github.com/ankero/react-native-image-zoom.git#master", "react-native-photo-view": "git+https://github.com/ankero/react-native-photo-view.git#master"
The structure which enables carousel with zoom for iOS and Android:
<react-native-snap-carousel>
if ( iOS )
<react-native-photo-view />
else
<react-native-image-pan-zoom />
</react-native-snap-carousel>
Ok thank you for your feedback, I'll give it a try :)
I'm currently trying to implement image zoom using react-native-image-pan-zoom
. But, the scale will not reset after scrolling.
I have tried the reset()
method of react-native-image-pan-zoom
using the ref. No effect
Same for me, and with react-native-image-pan-zoom carousel stop working
I used pinchgesture handler->
import { PinchGestureHandler, State, GestureHandlerRootView, PanGestureHandler, } from "react-native-gesture-handler";
const scale = new Animated.Value(1);
let onPinchEvent = Animated.event(
[
{
nativeEvent: { scale: scale },
},
],
{
useNativeDriver: true,
}
);
let onPinchStateEvent = (event) => {
// console.log(event.nativeEvent.oldState);
if (event.nativeEvent.oldState === State.ACTIVE) {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
bounciness: 1,
}).start();
}
};
<GestureHandlerRootView style={{ flex: 1 }}> <PinchGestureHandler onGestureEvent={onPinchEvent} onHandlerStateChange={onPinchStateEvent}
<Animated.Image
source={{ uri: item.ImageUrl }}
style={[
styles.modalImage,
{
transform: [{ scale: scale }],
},
]}
/>
</PinchGestureHandler>
</GestureHandlerRootView>
Is this a bug report or a feature request?
Feature request.
I've been struggling (and failing!) quite a bit getting a decent image zoom within a page/swiper. Don't ask me how I missed this control, which looks way more polished than others I've seen so far. I basically just need a gallery, and as such, my users will want to be able to zoom into an image.
The problem I have probably exists here too though: If I have an ImageZoom control (that allows zooming, panning an image), that will probably collide with the "outer carousel's" own gesture handling. Is there a working sample or guidance I could use as a starting point?
Thanks!