diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3adbbfa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +*.js +*.md \ No newline at end of file diff --git a/Demo/package.json b/Demo/package.json index d98a358..631b42e 100644 --- a/Demo/package.json +++ b/Demo/package.json @@ -7,15 +7,15 @@ "test": "jest" }, "dependencies": { - "react": "16.0.0-alpha.12", - "react-native": "0.48.2", - "react-native-image-gallery": "2.1.3" + "react": "16.2.0", + "react-native": "0.52.0", + "react-native-image-gallery": "2.1.5" }, "devDependencies": { - "babel-jest": "21.0.2", - "babel-preset-react-native": "3.0.2", - "jest": "21.0.2", - "react-test-renderer": "16.0.0-alpha.12" + "babel-jest": "22.0.6", + "babel-preset-react-native": "4.0.0", + "jest": "22.0.6", + "react-test-renderer": "16.2.0" }, "jest": { "preset": "react-native" diff --git a/package.json b/package.json index ac1aa3e..01b232b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-image-gallery", - "version": "2.1.4", + "version": "2.1.5", "description": "Pure JavaScript image gallery component for iOS and Android", "main": "src/Gallery.js", "scripts": { @@ -18,8 +18,6 @@ "homepage": "https://github.com/archriss/react-native-image-gallery#readme", "dependencies": { "prop-types": "^15.6.0", - "react-native-gesture-responder": "0.1.1", - "react-native-scroller": "0.0.6", "react-mixin": "^3.0.5", "react-timer-mixin": "^0.13.3" }, diff --git a/src/Gallery.js b/src/Gallery.js index 4b899d7..5cc4835 100644 --- a/src/Gallery.js +++ b/src/Gallery.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { View, ViewPropTypes } from 'react-native'; import PropTypes from 'prop-types'; -import { createResponder } from 'react-native-gesture-responder'; +import { createResponder } from './libraries/GestureResponder'; import TransformableImage from './libraries/TransformableImage'; import ViewPager from './libraries/ViewPager'; @@ -232,7 +232,8 @@ export default class Gallery extends PureComponent { onViewTransformed && onViewTransformed(transform, pageId); })} onTransformGestureReleased={((transform) => { - onTransformGestureReleased && onTransformGestureReleased(transform, pageId); + // need the 'return' here because the return value is checked in ViewTransformer + return onTransformGestureReleased && onTransformGestureReleased(transform, pageId); })} ref={((ref) => { this.imageRefs.set(pageId, ref); })} key={'innerImage#' + pageId} diff --git a/src/libraries/GestureResponder/TouchDistanceMath.js b/src/libraries/GestureResponder/TouchDistanceMath.js new file mode 100644 index 0000000..89b74c2 --- /dev/null +++ b/src/libraries/GestureResponder/TouchDistanceMath.js @@ -0,0 +1,40 @@ +'use strict'; + +export function distance (touchTrackA, touchTrackB, ofCurrent) { + let xa, ya, xb, yb; + if (ofCurrent) { + xa = touchTrackA.currentPageX; + ya = touchTrackA.currentPageY; + xb = touchTrackB.currentPageX; + yb = touchTrackB.currentPageY; + } else { + xa = touchTrackA.previousPageX; + ya = touchTrackA.previousPageY; + xb = touchTrackB.previousPageX; + yb = touchTrackB.previousPageY; + } + return Math.sqrt(Math.pow(xa - xb, 2) + Math.pow(ya - yb, 2)); +} + +export function maxDistance (touchBank, ofCurrent) { + let max = 0; + for (let i = 0; i < touchBank.length - 1; i++) { + for (let j = i + 1; j < touchBank.length; j++) { + let d = distance(touchBank[i], touchBank[j], ofCurrent); + if (d > max) { + max = d; + } + } + } + return max; +} + +export function pinchDistance (touchHistory, touchesChangedAfter, ofCurrent) { + let touchBank = touchHistory.touchBank; + if (touchHistory.numberActiveTouches > 1) { + let filteredTouchBank = touchBank.filter((touchTrack) => { + return touchTrack && touchTrack.currentTimeStamp >= touchesChangedAfter; + }); + return maxDistance(filteredTouchBank, ofCurrent); + } +} diff --git a/src/libraries/GestureResponder/TouchHistoryMath.js b/src/libraries/GestureResponder/TouchHistoryMath.js new file mode 100644 index 0000000..8c7e317 --- /dev/null +++ b/src/libraries/GestureResponder/TouchHistoryMath.js @@ -0,0 +1,99 @@ +/** + * @providesModule TouchHistoryMath + */ + +'use strict'; + +var TouchHistoryMath = { + /** + * This code is optimized and not intended to look beautiful. This allows + * computing of touch centroids that have moved after `touchesChangedAfter` + * timeStamp. You can compute the current centroid involving all touches + * moves after `touchesChangedAfter`, or you can compute the previous + * centroid of all touches that were moved after `touchesChangedAfter`. + * + * @param {TouchHistoryMath} touchHistory Standard Responder touch track + * data. + * @param {number} touchesChangedAfter timeStamp after which moved touches + * are considered "actively moving" - not just "active". + * @param {boolean} isXAxis Consider `x` dimension vs. `y` dimension. + * @param {boolean} ofCurrent Compute current centroid for actively moving + * touches vs. previous centroid of now actively moving touches. + * @return {number} value of centroid in specified dimension. + */ + centroidDimension: function (touchHistory, touchesChangedAfter, isXAxis, ofCurrent) { + var touchBank = touchHistory.touchBank; + var total = 0; + var count = 0; + + var oneTouchData = touchHistory.numberActiveTouches === 1 ? touchHistory.touchBank[touchHistory.indexOfSingleActiveTouch] : null; + + if (oneTouchData !== null) { + if (oneTouchData.touchActive && oneTouchData.currentTimeStamp > touchesChangedAfter) { + total += ofCurrent && isXAxis ? oneTouchData.currentPageX : ofCurrent && !isXAxis ? oneTouchData.currentPageY : !ofCurrent && isXAxis ? oneTouchData.previousPageX : oneTouchData.previousPageY; + count = 1; + } + } else { + for (var i = 0; i < touchBank.length; i++) { + var touchTrack = touchBank[i]; + if (touchTrack !== null && touchTrack !== undefined && touchTrack.touchActive && touchTrack.currentTimeStamp >= touchesChangedAfter) { + var toAdd; // Yuck, program temporarily in invalid state. + if (ofCurrent && isXAxis) { + toAdd = touchTrack.currentPageX; + } else if (ofCurrent && !isXAxis) { + toAdd = touchTrack.currentPageY; + } else if (!ofCurrent && isXAxis) { + toAdd = touchTrack.previousPageX; + } else { + toAdd = touchTrack.previousPageY; + } + total += toAdd; + count++; + } + } + } + return count > 0 ? total / count : TouchHistoryMath.noCentroid; + }, + + currentCentroidXOfTouchesChangedAfter: function (touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension(touchHistory, touchesChangedAfter, true, // isXAxis + true // ofCurrent + ); + }, + + currentCentroidYOfTouchesChangedAfter: function (touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension(touchHistory, touchesChangedAfter, false, // isXAxis + true // ofCurrent + ); + }, + + previousCentroidXOfTouchesChangedAfter: function (touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension(touchHistory, touchesChangedAfter, true, // isXAxis + false // ofCurrent + ); + }, + + previousCentroidYOfTouchesChangedAfter: function (touchHistory, touchesChangedAfter) { + return TouchHistoryMath.centroidDimension(touchHistory, touchesChangedAfter, false, // isXAxis + false // ofCurrent + ); + }, + + currentCentroidX: function (touchHistory) { + return TouchHistoryMath.centroidDimension(touchHistory, 0, // touchesChangedAfter + true, // isXAxis + true // ofCurrent + ); + }, + + currentCentroidY: function (touchHistory) { + return TouchHistoryMath.centroidDimension(touchHistory, 0, // touchesChangedAfter + false, // isXAxis + true // ofCurrent + ); + }, + + noCentroid: -1 +}; + +module.exports = TouchHistoryMath; diff --git a/src/libraries/GestureResponder/createResponder.js b/src/libraries/GestureResponder/createResponder.js new file mode 100644 index 0000000..9096690 --- /dev/null +++ b/src/libraries/GestureResponder/createResponder.js @@ -0,0 +1,313 @@ +/** + * Inspired by 'PanResponder' from Facebook. + */ + +'use strict'; + +import {InteractionManager} from 'react-native'; +import TouchHistoryMath from './TouchHistoryMath'; // copied from react/lib/TouchHistoryMath.js +import {pinchDistance} from './TouchDistanceMath'; +import TimerMixin from 'react-timer-mixin'; + +const currentCentroidXOfTouchesChangedAfter = TouchHistoryMath.currentCentroidXOfTouchesChangedAfter; +const currentCentroidYOfTouchesChangedAfter = TouchHistoryMath.currentCentroidYOfTouchesChangedAfter; +const previousCentroidXOfTouchesChangedAfter = TouchHistoryMath.previousCentroidXOfTouchesChangedAfter; +const previousCentroidYOfTouchesChangedAfter = TouchHistoryMath.previousCentroidYOfTouchesChangedAfter; +const currentCentroidX = TouchHistoryMath.currentCentroidX; +const currentCentroidY = TouchHistoryMath.currentCentroidY; + +const TAP_UP_TIME_THRESHOLD = 400; +const TAP_MOVE_THRESHOLD = 10; +const MOVE_THRESHOLD = 2; + +let DEV = false; + +function initializeGestureState (gestureState) { + gestureState.moveX = 0; + gestureState.moveY = 0; + gestureState.x0 = 0; + gestureState.y0 = 0; + gestureState.dx = 0; + gestureState.dy = 0; + gestureState.vx = 0; + gestureState.vy = 0; + gestureState.numberActiveTouches = 0; + // All `gestureState` accounts for timeStamps up until: + gestureState._accountsForMovesUpTo = 0; + + gestureState.previousMoveX = 0; + gestureState.previousMoveY = 0; + gestureState.pinch = undefined; + gestureState.previousPinch = undefined; + gestureState.singleTapUp = false; + gestureState.doubleTapUp = false; + gestureState._singleTabFailed = false; +} + +function updateGestureStateOnMove (gestureState, touchHistory, e) { + const movedAfter = gestureState._accountsForMovesUpTo; + const prevX = previousCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + const x = currentCentroidXOfTouchesChangedAfter(touchHistory, movedAfter); + const prevY = previousCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + const y = currentCentroidYOfTouchesChangedAfter(touchHistory, movedAfter); + const dx = x - prevX; + const dy = y - prevY; + + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + gestureState.moveX = x; + gestureState.moveY = y; + + // TODO: This must be filtered intelligently. + // const dt = touchHistory.mostRecentTimeStamp - movedAfter; + const dt = convertToMillisecIfNeeded(touchHistory.mostRecentTimeStamp - movedAfter); + gestureState.vx = dx / dt; + gestureState.vy = dy / dt; + gestureState.dx += dx; + gestureState.dy += dy; + gestureState._accountsForMovesUpTo = touchHistory.mostRecentTimeStamp; + + gestureState.previousMoveX = prevX; + gestureState.previousMoveY = prevY; + gestureState.pinch = pinchDistance(touchHistory, movedAfter, true); + gestureState.previousPinch = pinchDistance(touchHistory, movedAfter, false); +} + +function clearInteractionHandle (interactionState) { + if (interactionState.handle) { + InteractionManager.clearInteractionHandle(interactionState.handle); + interactionState.handle = null; + } +} + +/** + * Due to commit https://github.com/facebook/react-native/commit/f2c1868b56bdfc8b0d6f448733848eafed2cd440, + * Android is using nanoseconds while iOS is using milliseconds. + * @param interval + * @returns {*} + */ +function convertToMillisecIfNeeded (interval) { + if (interval > 1000000) { + return interval / 1000000; + } + return interval; +} + +function cancelSingleTapConfirm (gestureState) { + if (typeof gestureState._singleTapConfirmId !== 'undefined') { + TimerMixin.clearTimeout(gestureState._singleTapConfirmId); + gestureState._singleTapConfirmId = undefined; + } +} + +/** + * The config object contains same callbacks as the default gesture responder(https://facebook.github.io/react-native/docs/gesture-responder-system.html). + * And every callback are called with an additional argument 'gestureState', like PanResponder. + * @param config + * @returns {{}} + */ + +/** + * The config object contains same callbacks as the default gesture responder(https://facebook.github.io/react-native/docs/gesture-responder-system.html). + * And every callback are called with an additional argument 'gestureState', like PanResponder. + * @param config + * @param debug true to enable debug logs + * @returns {{}} + */ +export default function create (config) { + if (config.debug) { + DEV = true; + } + + const interactionState = { + handle: null + }; + const gestureState = { + // Useful for debugging + stateID: Math.random() + }; + initializeGestureState(gestureState); + + const handlers = { + onStartShouldSetResponder: function (e) { + DEV && console.log('onStartShouldSetResponder...'); + cancelSingleTapConfirm(gestureState); + return config.onStartShouldSetResponder ? + config.onStartShouldSetResponder(e, gestureState) : + false; + }, + onMoveShouldSetResponder: function (e) { + DEV && console.log('onMoveShouldSetResponder...'); + + return config.onMoveShouldSetResponder && effectiveMove(config, gestureState) ? + config.onMoveShouldSetResponder(e, gestureState) : + false; + }, + onStartShouldSetResponderCapture: function (e) { + DEV && console.log('onStartShouldSetResponderCapture...'); + cancelSingleTapConfirm(gestureState); + // TODO: Actually, we should reinitialize the state any time + // touches.length increases from 0 active to > 0 active. + if (e.nativeEvent.touches.length === 1) { + initializeGestureState(gestureState); + } + gestureState.numberActiveTouches = e.touchHistory.numberActiveTouches; + return config.onStartShouldSetResponderCapture ? + config.onStartShouldSetResponderCapture(e, gestureState) : + false; + }, + + onMoveShouldSetResponderCapture: function (e) { + DEV && console.log('onMoveShouldSetResponderCapture...'); + const touchHistory = e.touchHistory; + // Responder system incorrectly dispatches should* to current responder + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + return false; + } + updateGestureStateOnMove(gestureState, touchHistory, e); + return config.onMoveShouldSetResponderCapture && effectiveMove(config, gestureState) ? + config.onMoveShouldSetResponderCapture(e, gestureState) : + false; + }, + + onResponderGrant: function (e) { + DEV && console.log('onResponderGrant...'); + cancelSingleTapConfirm(gestureState); + if (!interactionState.handle) { + interactionState.handle = InteractionManager.createInteractionHandle(); + } + gestureState._grantTimestamp = e.touchHistory.mostRecentTimeStamp; + gestureState.x0 = currentCentroidX(e.touchHistory); + gestureState.y0 = currentCentroidY(e.touchHistory); + gestureState.dx = 0; + gestureState.dy = 0; + if (config.onResponderGrant) { + config.onResponderGrant(e, gestureState); + } + // TODO: t7467124 investigate if this can be removed + return config.onShouldBlockNativeResponder === undefined ? + true : + config.onShouldBlockNativeResponder(); + }, + + onResponderReject: function (e) { + DEV && console.log('onResponderReject...'); + clearInteractionHandle(interactionState); + config.onResponderReject && config.onResponderReject(e, gestureState); + }, + + onResponderRelease: function (e) { + if (gestureState.singleTapUp) { + if (gestureState._lastSingleTapUp) { + if (convertToMillisecIfNeeded(e.touchHistory.mostRecentTimeStamp - gestureState._lastReleaseTimestamp) < TAP_UP_TIME_THRESHOLD) { + gestureState.doubleTapUp = true; + } + } + gestureState._lastSingleTapUp = true; + + // schedule to confirm single tap + if (!gestureState.doubleTapUp) { + const snapshot = Object.assign({}, gestureState); + const timeoutId = TimerMixin.setTimeout(() => { + if (gestureState._singleTapConfirmId === timeoutId) { + DEV && console.log('onResponderSingleTapConfirmed...'); + config.onResponderSingleTapConfirmed && config.onResponderSingleTapConfirmed(e, snapshot); + } + }, TAP_UP_TIME_THRESHOLD); + gestureState._singleTapConfirmId = timeoutId; + } + } + gestureState._lastReleaseTimestamp = e.touchHistory.mostRecentTimeStamp; + + DEV && console.log('onResponderRelease...' + JSON.stringify(gestureState)); + clearInteractionHandle(interactionState); + config.onResponderRelease && config.onResponderRelease(e, gestureState); + initializeGestureState(gestureState); + }, + + onResponderStart: function (e) { + DEV && console.log('onResponderStart...'); + const touchHistory = e.touchHistory; + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + if (config.onResponderStart) { + config.onResponderStart(e, gestureState); + } + }, + + onResponderMove: function (e) { + const touchHistory = e.touchHistory; + // Guard against the dispatch of two touch moves when there are two + // simultaneously changed touches. + if (gestureState._accountsForMovesUpTo === touchHistory.mostRecentTimeStamp) { + return; + } + // Filter out any touch moves past the first one - we would have + // already processed multi-touch geometry during the first event. + updateGestureStateOnMove(gestureState, touchHistory, e); + + DEV && console.log('onResponderMove...' + JSON.stringify(gestureState)); + if (config.onResponderMove && effectiveMove(config, gestureState)) { + config.onResponderMove(e, gestureState); + } + }, + + onResponderEnd: function (e) { + const touchHistory = e.touchHistory; + gestureState.numberActiveTouches = touchHistory.numberActiveTouches; + + if (touchHistory.numberActiveTouches > 0 || + convertToMillisecIfNeeded(touchHistory.mostRecentTimeStamp - gestureState._grantTimestamp) > TAP_UP_TIME_THRESHOLD || + Math.abs(gestureState.dx) >= TAP_MOVE_THRESHOLD || + Math.abs(gestureState.dy) >= TAP_MOVE_THRESHOLD + ) { + gestureState._singleTabFailed = true; + } + if (!gestureState._singleTabFailed) { + gestureState.singleTapUp = true; + } + + DEV && console.log('onResponderEnd...' + JSON.stringify(gestureState)); + clearInteractionHandle(interactionState); + config.onResponderEnd && config.onResponderEnd(e, gestureState); + }, + + onResponderTerminate: function (e) { + DEV && console.log('onResponderTerminate...'); + clearInteractionHandle(interactionState); + config.onResponderTerminate && config.onResponderTerminate(e, gestureState); + initializeGestureState(gestureState); + }, + + onResponderTerminationRequest: function (e) { + DEV && console.log('onResponderTerminationRequest...'); + return config.onResponderTerminationRequest ? + config.onResponderTerminationRequest(e.gestureState) : + true; + } + }; + return {...handlers}; +} + +/** + * On Android devices, the default gesture responder is too sensitive that a single tap(no move intended) may trigger a move event. + * We can use a moveThreshold config to avoid those unwanted move events. + * @param config + * @param gestureState + * @returns {boolean} + */ +function effectiveMove (config, gestureState) { + if (gestureState.numberActiveTouches > 1) { + // on iOS simulator, a pinch gesture(move with alt pressed) will not change gestureState.dx(always 0) + return true; + } + + let moveThreshold = MOVE_THRESHOLD; + if (typeof config.moveThreshold === 'number') { + moveThreshold = config.minMoveDistance; + } + if (Math.abs(gestureState.dx) >= moveThreshold || Math.abs(gestureState.dy) >= moveThreshold) { + return true; + } + return false; +} diff --git a/src/libraries/GestureResponder/index.js b/src/libraries/GestureResponder/index.js new file mode 100644 index 0000000..675464a --- /dev/null +++ b/src/libraries/GestureResponder/index.js @@ -0,0 +1,5 @@ +'use strict'; + +import createResponder from './createResponder'; + +export { createResponder }; diff --git a/src/libraries/Scroller/AnimationUtils.js b/src/libraries/Scroller/AnimationUtils.js new file mode 100644 index 0000000..8dfd258 --- /dev/null +++ b/src/libraries/Scroller/AnimationUtils.js @@ -0,0 +1,3 @@ +export function currentAnimationTimeMillis() { + return Date.now(); +} diff --git a/src/libraries/Scroller/ViscousFluidInterpolator.js b/src/libraries/Scroller/ViscousFluidInterpolator.js new file mode 100644 index 0000000..263f32b --- /dev/null +++ b/src/libraries/Scroller/ViscousFluidInterpolator.js @@ -0,0 +1,29 @@ +'use strict'; + +const VISCOUS_FLUID_SCALE = 8; +const VISCOUS_FLUID_NORMALIZE = 1 / viscousFluid(1); +const VISCOUS_FLUID_OFFSET = 1 - VISCOUS_FLUID_NORMALIZE * viscousFluid(1); + +function viscousFluid (x) { + x *= VISCOUS_FLUID_SCALE; + if (x < 1) { + x -= (1 - Math.exp(-x)); + } else { + var start = 0.36787944117; // 1/e == exp(-1) + x = 1 - Math.exp(1 - x); + x = start + x * (1 - start); + } + return x; +} + +const ViscousFluidInterpolator = { + getInterpolation: function (input) { + var interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input); + if (interpolated > 0) { + return interpolated + VISCOUS_FLUID_OFFSET; + } + return interpolated; + } +}; + +export default ViscousFluidInterpolator; diff --git a/src/libraries/Scroller/index.js b/src/libraries/Scroller/index.js new file mode 100644 index 0000000..fde05f0 --- /dev/null +++ b/src/libraries/Scroller/index.js @@ -0,0 +1,355 @@ +'use strict'; + +/** + * Inspired by Android Scroller + */ + +import ViscousFluidInterpolator from './ViscousFluidInterpolator'; +import {currentAnimationTimeMillis} from './AnimationUtils'; + +/** + * The coefficient of friction applied to flings/scrolls. + * @type {number} + */ +// const SCROLL_FRICTION = 0.015; +const SCROLL_FRICTION = 0.03; + +const DEFAULT_DURATION = 250; +const SCROLL_MODE = 0; +const FLING_MODE = 1; + +const DECELERATION_RATE = Math.log(0.78) / Math.log(0.9); +const INFLEXION = 0.35; // Tension lines cross at (INFLEXION, 1) +const START_TENSION = 0.5; +const END_TENSION = 1.0; +const P1 = START_TENSION * INFLEXION; +const P2 = 1.0 - END_TENSION * (1.0 - INFLEXION); + +const NB_SAMPLES = 100; +const SPLINE_POSITION = []; +const SPLINE_TIME = []; + +const GRAVITY_EARTH = 9.80665; + +(function () { + var x_min = 0; + var y_min = 0; + for (let i = 0; i < NB_SAMPLES; i++) { + let alpha = i / NB_SAMPLES; + + let x_max = 1; + let x, tx, coef; + while (true) { + x = x_min + (x_max - x_min) / 2.0; + coef = 3.0 * x * (1.0 - x); + tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; + if (Math.abs(tx - alpha) < 1E-5) break; + if (tx > alpha) x_max = x; + else x_min = x; + } + SPLINE_POSITION[i] = coef * ((1.0 - x) * START_TENSION + x) + x * x * x; + + let y_max = 1.0; + let y, dy; + while (true) { + y = y_min + (y_max - y_min) / 2.0; + coef = 3.0 * y * (1.0 - y); + dy = coef * ((1.0 - y) * START_TENSION + y) + y * y * y; + if (Math.abs(dy - alpha) < 1E-5) break; + if (dy > alpha) y_max = y; + else y_min = y; + } + SPLINE_TIME[i] = coef * ((1.0 - y) * P1 + y * P2) + y * y * y; + } + SPLINE_POSITION[NB_SAMPLES] = SPLINE_TIME[NB_SAMPLES] = 1.0; +})(); + +function signum (number) { + if (isNaN(number)) { + return NaN; + } + var sig = number; + if (number > 0) { + sig = 1; + } else if (number < 0) { + sig = -1; + } + return sig; +} + +export default class Scroller { + /** + * + * @param flywheel specify whether or not to support progressive "flywheel" behavior in flinging. + */ + constructor (flywheel, onScrollCallback) { + this.mCurrX = 0; + this.mCurrY = 0; + this.mFinished = true; + this.mInterpolator = ViscousFluidInterpolator; + // this.mPpi = PixelRatio.get() * 160; + this.mPpi = 160; + this.mDeceleration = this.computeDeceleration(SCROLL_FRICTION); + this.mFlywheel = flywheel; + + this.mPhysicalCoeff = this.computeDeceleration(0.84); // look and feel tuning + + this.mFlingFriction = SCROLL_FRICTION; + this.onScrollCallback = onScrollCallback; + } + + computeDeceleration (friction) { + return GRAVITY_EARTH * 39.37 * this.mPpi * friction; + } + + /** + * Returns whether the scroller has finished scrolling. + * @returns {Boolean} True if the scroller has finished scrolling, false otherwise. + */ + isFinished () { + return this.mFinished; + } + + /** + * Force the finished field to a particular value. + * @param finished The new finished value. + */ + forceFinished (finished) { + this.mFinished = finished; + } + + /** + * Returns the current X offset in the scroll. + * @returns {*} The new X offset as an absolute distance from the origin. + */ + getCurrX () { + return this.mCurrX; + } + + /** + * Returns the current Y offset in the scroll. + * @returns {*} The new Y offset as an absolute distance from the origin. + */ + getCurrY () { + return this.mCurrY; + } + + getCurrVelocity () { + return this.mMode === FLING_MODE ? + this.mCurrVelocity : this.mVelocity - this.mDeceleration * this.timePassed() / 2000.0; + } + + computeScrollOffset () { + if (this.mFinished) { + this.onScrollCallback && this.onScrollCallback(0, 0, this); + return false; + } + + var timePassed = currentAnimationTimeMillis() - this.mStartTime; + + if (timePassed < this.mDuration) { + switch (this.mMode) { + case SCROLL_MODE: + let x = this.mInterpolator.getInterpolation(timePassed * this.mDurationReciprocal); + this.mCurrX = this.mStartX + Math.round(x * this.mDeltaX); + this.mCurrY = this.mStartY + Math.round(x * this.mDeltaY); + break; + case FLING_MODE: + let t = timePassed / this.mDuration; + let index = parseInt(NB_SAMPLES * t); + let distanceCoef = 1; + let velocityCoef = 0; + if (index < NB_SAMPLES) { + let t_inf = index / NB_SAMPLES; + let t_sup = (index + 1) / NB_SAMPLES; + let d_inf = SPLINE_POSITION[index]; + let d_sup = SPLINE_POSITION[index + 1]; + velocityCoef = (d_sup - d_inf) / (t_sup - t_inf); + distanceCoef = d_inf + (t - t_inf) * velocityCoef; + } + + this.mCurrVelocity = velocityCoef * this.mDistance / this.mDuration * 1000; + + this.mCurrX = this.mStartX + Math.round(distanceCoef * (this.mFinalX - this.mStartX)); + // Pin to mMinX <= mCurrX <= mMaxX + // this.mCurrX = Math.min(this.mCurrX, this.mMaxX); + // this.mCurrX = Math.max(this.mCurrX, this.mMinX); + + this.mCurrY = this.mStartY + Math.round(distanceCoef * (this.mFinalY - this.mStartY)); + // Pin to mMinY <= mCurrY <= mMaxY + this.mCurrY = Math.min(this.mCurrY, this.mMaxY); + this.mCurrY = Math.max(this.mCurrY, this.mMinY); + + if (this.mCurrX == this.mFinalX && this.mCurrY == this.mFinalY) { + this.mFinished = true; + } + + break; + } + } else { + this.mCurrX = this.mFinalX; + this.mCurrY = this.mFinalY; + this.mFinished = true; + } + + var dx = this.mCurrX - this.mLastX; + var dy = this.mCurrY - this.mLastY; + + this.mLastX = this.mCurrX; + this.mLastY = this.mCurrY; + + this.onScrollCallback && this.onScrollCallback(dx, dy, this); + + if (dx === 0 && dy === 0 && this.mFinished) { + return false; + } + return true; + } + + startScroll (startX, startY, dx, dy, duration = DEFAULT_DURATION) { + this.mMode = SCROLL_MODE; + this.mFinished = false; + this.mDuration = duration; + this.mStartTime = currentAnimationTimeMillis(); + this.mStartX = startX; + this.mStartY = startY; + this.mFinalX = startX + dx; + this.mFinalY = startY + dy; + this.mDeltaX = dx; + this.mDeltaY = dy; + this.mDurationReciprocal = 1.0 / this.mDuration; + + this.mLastX = this.mStartX; + this.mLastY = this.mStartY; + + this.performAnimation(); + } + + /** + * Start scrolling based on a fling gesture. The distance travelled will + * depend on the initial velocity of the fling. + * @param startX + * @param startY + * @param velocityX Initial velocity of the fling (X) measured in dp or pt per second + * @param velocityY Initial velocity of the fling (Y) measured in dp or pt per second + * @param minX + * @param maxX + * @param minY + * @param maxY + */ + fling (startX, startY, velocityX, velocityY, + minX, maxX, minY, maxY) { + // Continue a scroll or fling in progress + if (this.mFlywheel && !this.mFinished) { + let oldVel = this.getCurrVelocity(); + + let dx = this.mFinalX - this.mStartX; + let dy = this.mFinalY - this.mStartY; + let hyp = Math.hypot(dx, dy); + + let ndx = dx / hyp; + let ndy = dy / hyp; + + let oldVelocityX = ndx * oldVel; + let oldVelocityY = ndy * oldVel; + if (signum(velocityX) === signum(oldVelocityX) && + signum(velocityY) === signum(oldVelocityY)) { + velocityX += oldVelocityX; + velocityY += oldVelocityY; + } + } + + this.mMode = FLING_MODE; + this.mFinished = false; + + let velocity = Math.hypot(velocityX, velocityY); + + this.mVelocity = velocity; + this.mDuration = this.getSplineFlingDuration(velocity); + this.mStartTime = currentAnimationTimeMillis(); + this.mStartX = startX; + this.mStartY = startY; + + let coeffX = velocity == 0 ? 1.0 : velocityX / velocity; + let coeffY = velocity == 0 ? 1.0 : velocityY / velocity; + + let totalDistance = this.getSplineFlingDistance(velocity); + this.mDistance = totalDistance * signum(velocity); + + this.mMinX = minX; + this.mMaxX = maxX; + this.mMinY = minY; + this.mMaxY = maxY; + + this.mFinalX = startX + Math.round(totalDistance * coeffX); + // Pin to mMinX <= mFinalX <= mMaxX + this.mFinalX = Math.min(this.mFinalX, this.mMaxX); + this.mFinalX = Math.max(this.mFinalX, this.mMinX); + + this.mFinalY = startY + Math.round(totalDistance * coeffY); + // Pin to mMinY <= mFinalY <= mMaxY + this.mFinalY = Math.min(this.mFinalY, this.mMaxY); + this.mFinalY = Math.max(this.mFinalY, this.mMinY); + + this.mLastX = this.mStartX; + this.mLastY = this.mStartY; + + this.performAnimation(); + } + + getSplineDeceleration (velocity) { + return Math.log(INFLEXION * Math.abs(velocity) / (this.mFlingFriction * this.mPhysicalCoeff)); + } + + getSplineFlingDuration (velocity) { + var l = this.getSplineDeceleration(velocity); + var decelMinusOne = DECELERATION_RATE - 1.0; + return 1000.0 * Math.exp(l / decelMinusOne); + } + + getSplineFlingDistance (velocity) { + var l = this.getSplineDeceleration(velocity); + var decelMinusOne = DECELERATION_RATE - 1.0; + return this.mFlingFriction * this.mPhysicalCoeff * Math.exp(DECELERATION_RATE / decelMinusOne * l); + } + + performAnimation () { + if (this.computeScrollOffset()) { + requestAnimationFrame(this.performAnimation.bind(this)); + } else { + } + } + + abortAnimation () { + this.mCurrX = this.mFinalX; + this.mCurrY = this.mFinalY; + this.mFinished = true; + } + + extendDuration (extend) { + var passed = timePassed(); + this.mDuration = passed + extend; + this.mDurationReciprocal = 1.0 / this.mDuration; + this.mFinished = false; + } + + timePassed () { + return currentAnimationTimeMillis() - this.mStartTime; + } + + setFinalX (newX) { + this.mFinalX = newX; + this.mDeltaX = this.mFinalX - this.mStartX; + this.mFinished = false; + } + + setFinalY (newY) { + this.mFinalY = newY; + this.mDeltaY = this.mFinalY - this.mStartY; + this.mFinished = false; + } + + debugInfo () { + return 'cur=' + this.mCurrX + ' ' + this.mCurrY + ', final=' + this.mFinalX + ' ' + this.mFinalY; + } +} diff --git a/src/libraries/ViewPager/index.js b/src/libraries/ViewPager/index.js index e44f515..b786ad1 100644 --- a/src/libraries/ViewPager/index.js +++ b/src/libraries/ViewPager/index.js @@ -7,8 +7,8 @@ import { Dimensions } from 'react-native'; import PropTypes from 'prop-types'; -import Scroller from 'react-native-scroller'; -import { createResponder } from 'react-native-gesture-responder'; +import Scroller from '../Scroller'; +import { createResponder } from '../GestureResponder'; const MIN_FLING_VELOCITY = 0.5; @@ -108,6 +108,14 @@ export default class ViewPager extends PureComponent { const finalX = this.getScrollOffsetOfPage(page); this.scroller.startScroll(this.scroller.getCurrX(), 0, finalX - this.scroller.getCurrX(), 0, 0); + + requestAnimationFrame(() => { + // this is here to work around a bug in FlatList, as discussed here + // https://github.com/facebook/react-native/issues/1831 + // (and solved here https://github.com/facebook/react-native/commit/03ae65bc ?) + this.scrollByOffset(1); + this.scrollByOffset(-1); + }); } componentDidUpdate (prevProps) { @@ -237,6 +245,13 @@ export default class ViewPager extends PureComponent { } getItemLayout (data, index) { + // this method is called 'getItemLayout', but it is not actually used + // as the 'getItemLayout' function for the FlatList. We use it within + // the code on this page though. The reason for this is that working + // with 'getItemLayout' for FlatList is buggy. You might end up with + // unrendered / missing content. Therefore we work around it, as + // described here + // https://github.com/facebook/react-native/issues/15734#issuecomment-330616697 return { length: this.state.width + this.props.pageMargin, offset: (this.state.width + this.props.pageMargin) * index, @@ -310,8 +325,12 @@ export default class ViewPager extends PureComponent { data={pageDataArray} renderItem={this.renderRow} onLayout={this.onLayout} - getItemLayout={this.getItemLayout} - initialScrollIndex={(this.props.initialPage || undefined)} + + // use contentOffset instead of initialScrollIndex so that we don't have + // to use the buggy 'getItemLayout' prop. See + // https://github.com/facebook/react-native/issues/15734#issuecomment-330616697 and + // https://github.com/facebook/react-native/issues/14945#issuecomment-354651271 + contentOffset = {{x: this.getScrollOffsetOfPage(parseInt(this.props.initialPage)), y:0}} /> ); diff --git a/src/libraries/ViewTransformer/Rect.js b/src/libraries/ViewTransformer/Rect.js index be3abe2..41b6d46 100644 --- a/src/libraries/ViewTransformer/Rect.js +++ b/src/libraries/ViewTransformer/Rect.js @@ -41,8 +41,22 @@ export default class Rect { return new Rect(this.left, this.top, this.right, this.bottom); } - equals (rect) { - return this.left === rect.left && this.top === rect.top && this.right === rect.right && this.bottom && rect.bottom; + equals (rect, epsilon) { + if (!epsilon) { + return ( + this.left === rect.left && + this.top === rect.top && + this.right === rect.right && + this.bottom === rect.bottom + ); + } else { + return ( + Math.abs(this.left - rect.left) < epsilon && + Math.abs(this.top - rect.top) < epsilon && + Math.abs(this.right - rect.right) < epsilon && + Math.abs(this.bottom - rect.bottom) < epsilon + ); + } } isValid () { diff --git a/src/libraries/ViewTransformer/index.js b/src/libraries/ViewTransformer/index.js index d09f6ed..f919e99 100644 --- a/src/libraries/ViewTransformer/index.js +++ b/src/libraries/ViewTransformer/index.js @@ -1,8 +1,8 @@ import React from 'react'; import ReactNative, { View, Animated, Easing, NativeModules } from 'react-native'; -import Scroller from 'react-native-scroller'; +import Scroller from '../Scroller'; import PropTypes from 'prop-types'; -import { createResponder } from 'react-native-gesture-responder'; +import { createResponder } from '../GestureResponder'; import { Rect, Transform, transformedRect, availableTranslateSpace, fitCenterRect, alignedRect, getTransform } from './TransformUtils'; export default class ViewTransformer extends React.Component { @@ -50,7 +50,7 @@ export default class ViewTransformer extends React.Component { pageY: 0 }; this._viewPortRect = new Rect(); // A holder to avoid new too much - + this.onLayout = this.onLayout.bind(this); this.cancelAnimation = this.cancelAnimation.bind(this); this.contentRect = this.contentRect.bind(this); @@ -352,7 +352,7 @@ export default class ViewTransformer extends React.Component { } let fromRect = this.transformedContentRect(); - if (fromRect.equals(targetRect)) { + if (fromRect.equals(targetRect, 0.01)) { return; }