meliorence / react-native-snap-carousel

Swiper/carousel component for React Native featuring previews, multiple layouts, parallax images, performant handling of huge numbers of items, and more. Compatible with Android & iOS.
BSD 3-Clause "New" or "Revised" License
10.37k stars 2.29k forks source link

Feature request/guidance: Zoom an image? #264

Closed hardcodet closed 6 years ago

hardcodet commented 6 years ago

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!

bd-arc commented 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?

hardcodet commented 6 years ago

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!

bd-arc commented 6 years ago

Do you have a visual example of what you're trying to achieve?

I'm curious and always up for a challenge ;-)

hardcodet commented 6 years ago

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?

bd-arc commented 6 years ago

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.

jacobsmith commented 6 years ago

@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!

hardcodet commented 6 years ago

@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!

bd-arc commented 6 years ago

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

react-native-snap-carousel stack layout

hardcodet commented 6 years ago

@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!

jacobsmith commented 6 years ago

@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!

hardcodet commented 6 years ago

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

jacobsmith commented 6 years ago

@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!

hardcodet commented 6 years ago

The problem is the competing gesture listeners. In theory, it would be simple:

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

jacobsmith commented 6 years ago

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

hardcodet commented 6 years ago

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

jacobsmith commented 6 years ago

@hardcodet would you mind sharing that "dirty hack" of a solution? 😄

hardcodet commented 6 years ago

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:

This 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 :)

hardcodet commented 6 years ago

@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>
        )
    }
}
bd-arc commented 6 years ago

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

hardcodet commented 6 years ago

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

jacobsmith commented 6 years ago

@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!

hardcodet commented 6 years ago

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?

junibrosas commented 6 years ago

Did you guys had a chance to make some progress? I did not find any decent carousel + zoom RN libraries.

jacobsmith commented 6 years ago

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!

ankero commented 6 years ago

@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 :)

TeslaAdis commented 5 years ago

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

ankero commented 5 years ago

@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>
TeslaAdis commented 5 years ago

Ok thank you for your feedback, I'll give it a try :)

kiranjd commented 5 years ago

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

braianj commented 4 years ago

Same for me, and with react-native-image-pan-zoom carousel stop working

ckswopnera commented 2 years ago

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>