Closed tumanov-alex closed 5 years ago
Feel free to do profiling and identify optimization opportunities
You might want to try https://www.npmjs.com/package/zoomable-svg https://github.com/msand/InfiniDraw/ https://infinidraw.live/ It's a bit full of stuff in the origin, so even chrome on desktop has trouble zooming until you get out far enough.
Seems quite smooth with react-native-gesture-handler:
import React, { Component, PureComponent } from 'react';
import { Animated, StyleSheet, Text, View, Dimensions } from 'react-native';
import {
PanGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
State,
} from 'react-native-gesture-handler';
import { Svg, Rect, G } from 'react-native-svg';
const { width, height } = Dimensions.get('window');
const GAnimated = Animated.createAnimatedComponent(G);
const range = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const genPoly = count => {
return Array.from({ length: count }).map((_, i) => {
const x = range(20, 320);
const y = range(170, 470);
const radius = range(10, 30);
return (
<Rect
onPress={() => alert(i)}
x={x}
y={y}
width={radius}
height={radius}
fill={`rgba(${range(0, 255)}, ${range(0, 255)}, ${range(0, 255)}, 0.2)`}
strokeWidth={1}
stroke="black"
key={i}
style={{
zIndex: 100,
}}
/>
);
});
};
const polygons = genPoly(100);
const USE_NATIVE_DRIVER = true;
export class PinchableBox extends React.Component {
panRef = React.createRef();
rotationRef = React.createRef();
pinchRef = React.createRef();
constructor(props) {
super(props);
/* Pinching */
this._baseScale = new Animated.Value(1);
this._pinchScale = new Animated.Value(1);
this._scale = Animated.multiply(this._baseScale, this._pinchScale);
this._lastScale = 1;
this._onPinchGestureEvent = Animated.event(
[{ nativeEvent: { scale: this._pinchScale } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
/* Rotation */
this._rotate = new Animated.Value(0);
this._rotateStr = this._rotate.interpolate({
inputRange: [-100, 100],
outputRange: ['-100rad', '100rad'],
});
this._lastRotate = 0;
this._onRotateGestureEvent = Animated.event(
[{ nativeEvent: { rotation: this._rotate } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
/* Tilt */
this._tilt = new Animated.Value(0);
this._tiltStr = this._tilt.interpolate({
inputRange: [-501, -500, 0, 1],
outputRange: ['1rad', '1rad', '0rad', '0rad'],
});
this._lastTilt = 0;
this._onTiltGestureEvent = Animated.event(
[{ nativeEvent: { translationY: this._tilt } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
}
_onRotateHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastRotate += event.nativeEvent.rotation;
this._rotate.setOffset(this._lastRotate);
this._rotate.setValue(0);
}
};
_onPinchHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastScale *= event.nativeEvent.scale;
this._baseScale.setValue(this._lastScale);
this._pinchScale.setValue(1);
}
};
_onTiltGestureStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastTilt += event.nativeEvent.translationY;
this._tilt.setOffset(this._lastTilt);
this._tilt.setValue(0);
}
};
render() {
return (
<PanGestureHandler
ref={this.panRef}
onGestureEvent={this._onTiltGestureEvent}
onHandlerStateChange={this._onTiltGestureStateChange}
minDist={10}
minPointers={2}
maxPointers={2}
avgTouches
>
<Animated.View style={styles.wrapper}>
<RotationGestureHandler
ref={this.rotationRef}
simultaneousHandlers={this.pinchRef}
onGestureEvent={this._onRotateGestureEvent}
onHandlerStateChange={this._onRotateHandlerStateChange}
>
<Animated.View style={styles.wrapper}>
<PinchGestureHandler
ref={this.pinchRef}
simultaneousHandlers={this.rotationRef}
onGestureEvent={this._onPinchGestureEvent}
onHandlerStateChange={this._onPinchHandlerStateChange}
>
<Animated.View style={styles.container} collapsable={false}>
<Svg
width={width}
height={height}
viewBox="40 130 330 500"
style={styles.pinchableImage}
>
<GAnimated
style={{
transform: [
{ perspective: 200 },
{ translateX: width / 2 },
{ translateY: height / 2 },
{ scale: this._scale },
{ rotate: this._rotateStr },
{ translateX: -width / 2 },
{ translateY: -height / 2 },
],
}}
>
{polygons}
</GAnimated>
</Svg>
</Animated.View>
</PinchGestureHandler>
</Animated.View>
</RotationGestureHandler>
</Animated.View>
</PanGestureHandler>
);
}
}
export default PinchableBox;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'black',
overflow: 'hidden',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
pinchableImage: {
backgroundColor: 'white',
width,
height,
},
wrapper: {
flex: 1,
},
});
Another example with proper translation instead:
import React, { Component, PureComponent } from 'react';
import { Animated, StyleSheet, Text, View, Dimensions } from 'react-native';
import {
PanGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
State,
} from 'react-native-gesture-handler';
import { Svg, Rect, G } from 'react-native-svg';
const { width, height } = Dimensions.get('window');
const GAnimated = Animated.createAnimatedComponent(G);
const range = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const genPoly = count => {
return Array.from({ length: count }).map((_, i) => {
const x = range(20, 320);
const y = range(170, 470);
const radius = range(10, 30);
return (
<Rect
onPress={() => alert(i)}
x={x}
y={y}
width={radius}
height={radius}
fill={`rgba(${range(0, 255)}, ${range(0, 255)}, ${range(0, 255)}, 0.2)`}
strokeWidth={1}
stroke="black"
key={i}
style={{
zIndex: 100,
}}
/>
);
});
};
const polygons = genPoly(100);
class Polygons extends PureComponent {
render() {
return polygons;
}
}
const USE_NATIVE_DRIVER = true;
export class PinchableBox extends React.Component {
panRef = React.createRef();
rotationRef = React.createRef();
pinchRef = React.createRef();
constructor(props) {
super(props);
/* Pinching */
this._baseScale = new Animated.Value(1);
this._pinchScale = new Animated.Value(1);
this._scale = Animated.multiply(this._baseScale, this._pinchScale);
this._lastScale = 1;
this._onPinchGestureEvent = Animated.event(
[{ nativeEvent: { scale: this._pinchScale } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
/* Rotation */
this._rotate = new Animated.Value(0);
this._rotateStr = this._rotate.interpolate({
inputRange: [-100, 100],
outputRange: ['-100rad', '100rad'],
});
this._lastRotate = 0;
this._onRotateGestureEvent = Animated.event(
[{ nativeEvent: { rotation: this._rotate } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
/* Translate */
this._translateX = new Animated.Value(0);
this._translateY = new Animated.Value(0);
this._lastOffset = { x: 0, y: 0 };
this._onGestureEvent = Animated.event(
[
{
nativeEvent: {
translationX: this._translateX,
translationY: this._translateY,
},
},
],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
}
_onRotateHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastRotate += event.nativeEvent.rotation;
this._rotate.setOffset(this._lastRotate);
this._rotate.setValue(0);
}
};
_onPinchHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastScale *= event.nativeEvent.scale;
this._baseScale.setValue(this._lastScale);
this._pinchScale.setValue(1);
}
};
_onHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
this._lastOffset.x += event.nativeEvent.translationX;
this._lastOffset.y += event.nativeEvent.translationY;
this._translateX.setOffset(this._lastOffset.x);
this._translateX.setValue(0);
this._translateY.setOffset(this._lastOffset.y);
this._translateY.setValue(0);
}
};
render() {
return (
<PanGestureHandler
ref={this.panRef}
onGestureEvent={this._onGestureEvent}
onHandlerStateChange={this._onHandlerStateChange}
>
<Animated.View style={styles.wrapper}>
<RotationGestureHandler
ref={this.rotationRef}
simultaneousHandlers={this.pinchRef}
onGestureEvent={this._onRotateGestureEvent}
onHandlerStateChange={this._onRotateHandlerStateChange}
>
<Animated.View style={styles.wrapper}>
<PinchGestureHandler
ref={this.pinchRef}
simultaneousHandlers={this.rotationRef}
onGestureEvent={this._onPinchGestureEvent}
onHandlerStateChange={this._onPinchHandlerStateChange}
>
<Animated.View style={styles.container} collapsable={false}>
<Svg
width={width}
height={height}
viewBox="40 130 330 500"
style={styles.pinchableImage}
>
<GAnimated
style={{
transform: [
{ translateX: this._translateX },
{ translateY: this._translateY },
{ perspective: 200 },
{ translateX: width / 2 },
{ translateY: height / 2 },
{ scale: this._scale },
{ rotate: this._rotateStr },
{ translateX: -width / 2 },
{ translateY: -height / 2 },
],
}}
>
<Polygons />
</GAnimated>
</Svg>
</Animated.View>
</PinchGestureHandler>
</Animated.View>
</RotationGestureHandler>
</Animated.View>
</PanGestureHandler>
);
}
}
export default PinchableBox;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'black',
overflow: 'hidden',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
pinchableImage: {
backgroundColor: 'white',
width,
height,
},
wrapper: {
flex: 1,
},
});
@tumanov-alex Can you try with the latest commit from the develop branch? https://github.com/react-native-community/react-native-svg/commit/defce98ac90f174f68dac817aa701d66f9535bc1 I've optimized the rendering performance on Android quite a bit, found two low hanging fruits by profiling my latest example.
Simplified version for android:
import React, { Component } from 'react';
import { Svg, Rect, G } from 'react-native-svg';
import { Animated, StyleSheet, Dimensions } from 'react-native';
import {
PanGestureHandler,
PinchGestureHandler,
State,
} from 'react-native-gesture-handler';
const { width, height } = Dimensions.get('window');
const AnimatedG = Animated.createAnimatedComponent(G);
const AnimatedSvg = Animated.createAnimatedComponent(Svg);
const range = (min, max) => Math.random() * (max - min) + min;
const ri = (min, max) => Math.floor(Math.random() * (max - min) + 1) + min;
const genPoly = count => {
return Array.from({ length: count }).map((_, i) => {
const x = range(0.05, 0.95) * width;
const y = range(0.05, 0.95) * width;
const side = range(0.01, 0.05) * width;
return (
<Rect
x={x}
y={y}
key={i}
width={side}
height={side}
stroke="black"
onPress={() => alert(i)}
fill={`rgba(${ri(0, 255)}, ${ri(0, 255)}, ${ri(0, 255)}, 0.2)`}
/>
);
});
};
const polygons = genPoly(100);
class Polygons extends Component {
shouldComponentUpdate() {
return false;
}
render() {
return polygons;
}
}
const USE_NATIVE_DRIVER = true;
export class PinchableBox extends Component {
/* Pan */
panRef = React.createRef();
lastOffset = { x: 0, y: 0 };
translateX = new Animated.Value(0);
translateY = new Animated.Value(0);
onPanGestureEvent = Animated.event(
[
{
nativeEvent: {
translationX: this.translateX,
translationY: this.translateY,
},
},
],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
onPanHandlerStateChange = ({ nativeEvent }) => {
if (nativeEvent.oldState === State.ACTIVE) {
const { lastOffset, translateX, translateY } = this;
const { translationX, translationY } = nativeEvent;
lastOffset.x += translationX;
lastOffset.y += translationY;
const { x, y } = lastOffset;
translateX.setOffset(x);
translateY.setOffset(y);
translateX.setValue(0);
translateY.setValue(0);
}
};
/* Pinch */
lastScale = 1;
pinchRef = React.createRef();
baseScale = new Animated.Value(1);
pinchScale = new Animated.Value(1);
scale = Animated.multiply(this.baseScale, this.pinchScale);
onPinchGestureEvent = Animated.event(
[{ nativeEvent: { scale: this.pinchScale } }],
{ useNativeDriver: USE_NATIVE_DRIVER },
);
onPinchHandlerStateChange = ({ nativeEvent }) => {
if (nativeEvent.oldState === State.ACTIVE) {
const { baseScale, pinchScale } = this;
this.lastScale *= nativeEvent.scale;
baseScale.setValue(this.lastScale);
pinchScale.setValue(1);
}
};
render() {
let y = width / 2;
return (
<PanGestureHandler
ref={this.panRef}
onGestureEvent={this.onPanGestureEvent}
onHandlerStateChange={this.onPanHandlerStateChange}
>
<Animated.View style={styles.wrapper}>
<PinchGestureHandler
ref={this.pinchRef}
simultaneousHandlers={this.panRef}
onGestureEvent={this.onPinchGestureEvent}
onHandlerStateChange={this.onPinchHandlerStateChange}
>
<AnimatedSvg
width={width}
height={height}
style={styles.container}
viewBox={`0 0 ${width} ${width}`}
>
<AnimatedG
style={{
transform: [
{ translateX: this.translateX },
{ translateY: this.translateY },
{ translateY: y },
{ scale: this.scale },
{ translateY: -y },
],
}}
>
<Polygons />
</AnimatedG>
</AnimatedSvg>
</PinchGestureHandler>
</Animated.View>
</PanGestureHandler>
);
}
}
export default PinchableBox;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'white',
width,
height,
},
wrapper: {
width,
height,
},
});
Another example using reanimated:
import React, { Component } from 'react';
import { StyleSheet, Dimensions, View, Platform } from 'react-native';
import { Svg, Rect, G } from 'react-native-svg';
import Animated from 'react-native-reanimated';
import {
State,
PanGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
} from 'react-native-gesture-handler';
const android = Platform.OS === 'android';
const { width, height } = Dimensions.get('window');
const AnimatedG = Animated.createAnimatedComponent(G);
const { set, cond, block, eq, add, Value, event, concat, multiply } = Animated;
const halfWidth = width / 2;
const [originX, originY] = android ? [0, width] : [halfWidth, halfWidth];
const max = 0.9 * width;
const side = 0.1 * width;
const pos = () => Math.random() * max;
const rbyte = () => Math.floor(Math.random() * 256);
const fill = () => `rgba(${rbyte()}, ${rbyte()}, ${rbyte()}, 0.2)`;
const polygons = Array.from({ length: 200 }).map((_, i) => (
<Rect
key={i}
x={pos()}
y={pos()}
width={side}
height={side}
fill={fill()}
stroke="black"
onPress={alert.bind(alert, i)}
/>
));
class Polygons extends Component {
shouldComponentUpdate() {
return false;
}
render() {
return polygons;
}
}
export default class Example extends Component {
constructor(props) {
super(props);
this.X = new Value(0);
this.Y = new Value(0);
this.R = new Value(0);
this.Z = new Value(1);
const offsetX = new Value(0);
const offsetY = new Value(0);
const offsetR = new Value(0);
const offsetZ = new Value(1);
this.handlePan = event([
{
nativeEvent: ({ translationX: x, translationY: y, state }) => {
console.log(state);
return block([
set(this.X, add(x, offsetX)),
set(this.Y, add(y, offsetY)),
cond(eq(state, State.END), [
set(offsetX, add(offsetX, x)),
set(offsetY, add(offsetY, y)),
]),
]);
},
},
]);
this.handlePinch = event([
{
nativeEvent: ({ scale: z, state }) => {
console.log(state);
return block([
cond(eq(state, State.ACTIVE), set(this.Z, multiply(z, offsetZ))),
cond(eq(state, State.END), [set(offsetZ, multiply(offsetZ, z))]),
]);
},
},
]);
this.handleRotate = event([
{
nativeEvent: ({ rotation: r, state }) => {
console.log(state);
return block([
set(this.R, add(r, offsetR)),
cond(eq(state, State.END), [set(offsetR, add(offsetR, r))]),
]);
},
},
]);
}
panRef = React.createRef();
pinchRef = React.createRef();
rotationRef = React.createRef();
render() {
return (
<View style={styles.container}>
<PanGestureHandler
averageTouches
ref={this.panRef}
onGestureEvent={this.handlePan}
onHandlerStateChange={this.handlePan}
simultaneousHandlers={[this.rotationRef, this.pinchRef]}
>
<Animated.View>
<PinchGestureHandler
ref={this.pinchRef}
onGestureEvent={this.handlePinch}
onHandlerStateChange={this.handlePinch}
simultaneousHandlers={[this.rotationRef, this.panRef]}
>
<Animated.View>
<RotationGestureHandler
ref={this.rotationRef}
onGestureEvent={this.handleRotate}
onHandlerStateChange={this.handleRotate}
simultaneousHandlers={[this.pinchRef, this.panRef]}
>
<Animated.View>
<Svg
width={width}
height={height}
viewBox={`0 0 ${width} ${width}`}
>
<AnimatedG
style={{
transform: [
{ translateX: this.X },
{ translateY: this.Y },
{ translateX: originX },
{ translateY: originY },
{ rotate: concat(this.R, 'rad') },
{ scale: this.Z },
{ translateX: -originX },
{ translateY: -originY },
],
}}
>
<Rect
x={0}
y={0}
width={width}
height={width}
fill={fill()}
/>
<Polygons />
</AnimatedG>
</Svg>
</Animated.View>
</RotationGestureHandler>
</Animated.View>
</PinchGestureHandler>
</Animated.View>
</PanGestureHandler>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'white',
width,
height,
},
});
Published v9.6.1 with the improvements.
If you remove the onPress handlers, it's even faster, as it doesn't need to make certain calls to enable correct tracking of touches + movements.
Hooks instead of components:
import React, { useRef, memo } from 'react';
import { StyleSheet, Dimensions, View, Platform } from 'react-native';
import { Svg, Rect, G } from 'react-native-svg';
import Animated from 'react-native-reanimated';
import {
State,
PanGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
} from 'react-native-gesture-handler';
const android = Platform.OS === 'android';
const { width, height } = Dimensions.get('window');
const AnimatedG = Animated.createAnimatedComponent(G);
const { set, cond, block, eq, add, Value, event, concat, multiply } = Animated;
const max = 0.9 * width;
const side = 0.1 * width;
const origin = -width / 2;
const ratio = width / height;
const oX = android ? origin : 0;
const pos = () => Math.random() * max;
const byte = () => Math.floor(Math.random() * 256);
const fill = () => `rgba(${byte()}, ${byte()}, ${byte()}, 0.2)`;
const polygons = Array.from({ length: 200 }).map((_, i) => (
<Rect
key={i}
x={pos()}
y={pos()}
width={side}
height={side}
fill={fill()}
stroke="black"
/>
));
const Polygons = memo(() => polygons, () => true);
export default () => {
const X = new Value(0);
const Y = new Value(0);
const R = new Value(0);
const Z = new Value(1);
const offsetX = new Value(0);
const offsetY = new Value(0);
const offsetR = new Value(0);
const offsetZ = new Value(1);
const handlePan = event([
{
nativeEvent: ({ translationX: x, translationY: y, state }) =>
block([
set(X, add(x, offsetX)),
set(Y, add(y, offsetY)),
cond(eq(state, State.END), [
set(offsetX, add(offsetX, x)),
set(offsetY, add(offsetY, y)),
]),
]),
},
]);
const handlePinch = event([
{
nativeEvent: ({ scale: z, state }) =>
block([
cond(eq(state, State.ACTIVE), set(Z, multiply(z, offsetZ))),
cond(eq(state, State.END), [set(offsetZ, multiply(offsetZ, z))]),
]),
},
]);
const handleRotation = event([
{
nativeEvent: ({ rotation: r, state }) =>
block([
set(R, add(r, offsetR)),
cond(eq(state, State.END), [set(offsetR, add(offsetR, r))]),
]),
},
]);
const panRef = useRef(null);
const pinchRef = useRef(null);
const rotationRef = useRef(null);
return (
<View style={styles.container}>
<PanGestureHandler
ref={panRef}
avgTouches
onGestureEvent={handlePan}
onHandlerStateChange={handlePan}
simultaneousHandlers={[rotationRef, pinchRef]}
>
<Animated.View>
<PinchGestureHandler
ref={pinchRef}
onGestureEvent={handlePinch}
onHandlerStateChange={handlePinch}
simultaneousHandlers={[rotationRef, panRef]}
>
<Animated.View>
<RotationGestureHandler
ref={rotationRef}
onGestureEvent={handleRotation}
onHandlerStateChange={handleRotation}
simultaneousHandlers={[pinchRef, panRef]}
>
<Animated.View>
<Svg
width={width}
height={height}
viewBox={`${origin} ${origin * ratio} ${width} ${width}`}
>
<AnimatedG
style={{
transform: [
{ translateX: X },
{ translateY: Y },
{ translateX: oX },
{ rotate: concat(R, 'rad') },
{ scale: Z },
{ translateX: -oX },
],
}}
>
<G transform={`translate(${origin},${origin})`}>
<Rect
x={0}
y={0}
width={width}
height={width}
fill={fill()}
/>
<Polygons />
</G>
</AnimatedG>
</Svg>
</Animated.View>
</RotationGestureHandler>
</Animated.View>
</PinchGestureHandler>
</Animated.View>
</PanGestureHandler>
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'white',
},
});
Slightly more advanced viewer example, adapted to svg and slightly stripped down version from: https://github.com/kmagiera/react-native-gesture-handler/issues/546#issuecomment-481341466 https://github.com/Ashoat/squadcal/blob/master/native/media/multimedia-modal.react.js
import React, { memo } from 'react';
import { StyleSheet, Dimensions, View, Platform, Text, TouchableOpacity } from 'react-native';
import { Svg, Rect, G } from 'react-native-svg';
import Animated, { Easing } from 'react-native-reanimated';
import {
State,
PanGestureHandler,
PinchGestureHandler,
TapGestureHandler,
} from 'react-native-gesture-handler';
const android = Platform.OS === 'android';
const { width, height } = Dimensions.get('window');
const AnimatedG = Animated.createAnimatedComponent(G);
const {
Value,
Clock,
event,
Extrapolate,
set,
call,
cond,
not,
and,
or,
eq,
neq,
greaterThan,
add,
sub,
multiply,
divide,
pow,
max,
min,
round,
abs,
interpolate,
startClock,
stopClock,
clockRunning,
timing,
decay,
diffClamp
} = Animated;
function clamp(value: Value, minValue: Value, maxValue: Value): Value {
return cond(
greaterThan(value, maxValue),
maxValue,
cond(
greaterThan(minValue, value),
minValue,
value,
),
);
}
function scaleDelta(value: Value, gestureActive: Value) {
const diffThisFrame = new Value(1);
const prevValue = new Value(1);
return cond(
gestureActive,
[
set(diffThisFrame, divide(value, prevValue)),
set(prevValue, value),
diffThisFrame,
],
set(prevValue, 1),
);
}
function panDelta(value: Value, gestureActive: Value) {
const diffThisFrame = new Value(0);
const prevValue = new Value(0);
return cond(
gestureActive,
[
set(diffThisFrame, sub(value, prevValue)),
set(prevValue, value),
diffThisFrame,
],
set(prevValue, 0),
);
}
function gestureJustEnded(tapState: Value) {
const prevValue = new Value(-1);
return cond(
eq(prevValue, tapState),
0,
[
set(prevValue, tapState),
eq(tapState, State.END),
],
);
}
function runTiming(
clock: Clock,
initialValue: Value | number,
finalValue: Value | number,
startStopClock: bool = true,
): Value {
const state = {
finished: new Value(0),
position: new Value(0),
frameTime: new Value(0),
time: new Value(0),
};
const config = {
toValue: new Value(0),
duration: 250,
easing: Easing.out(Easing.ease),
};
return [
cond(
not(clockRunning(clock)),
[
set(state.finished, 0),
set(state.frameTime, 0),
set(state.time, 0),
set(state.position, initialValue),
set(config.toValue, finalValue),
startStopClock && startClock(clock),
],
),
timing(clock, state, config),
cond(
state.finished,
startStopClock && stopClock(clock),
),
state.position,
];
}
function runDecay(
clock: Clock,
velocity: Value,
initialPosition: Value,
startStopClock: bool = true,
): Value {
const state = {
finished: new Value(0),
velocity: new Value(0),
position: new Value(0),
time: new Value(0),
};
const config = { deceleration: 0.99 };
return [
cond(
not(clockRunning(clock)),
[
set(state.finished, 0),
set(state.velocity, velocity),
set(state.position, initialPosition),
set(state.time, 0),
startStopClock && startClock(clock),
],
),
decay(clock, state, config),
set(velocity, state.velocity),
cond(
state.finished,
startStopClock && stopClock(clock),
),
state.position,
];
}
class MultimediaModal extends React.PureComponent {
centerX = new Value(0);
centerY = new Value(0);
screenWidth = new Value(0);
screenHeight = new Value(0);
imageWidth = new Value(0);
imageHeight = new Value(0);
pinchHandler = React.createRef();
panHandler = React.createRef();
tapHandler = React.createRef();
handlerRefs = [ this.pinchHandler, this.panHandler, this.tapHandler ];
priorityHandlerRefs = [ this.pinchHandler, this.panHandler ];
pinchEvent;
panEvent;
tapEvent;
scale: Value;
x: Value;
y: Value;
opacity: Value;
imageContainerOpacity: Value;
constructor(props: Props) {
super(props);
this.updateDimensions();
const { screenWidth, screenHeight, imageWidth, imageHeight } = this;
const left = sub(this.centerX, divide(imageWidth, 2));
const top = sub(this.centerY, divide(imageHeight, 2));
const initialCoordinates = { x: 0, y: 0, width, height };
const initialScale = divide(
initialCoordinates.width,
imageWidth,
);
const initialTranslateX = sub(
initialCoordinates.x + initialCoordinates.width / 2,
add(left, divide(imageWidth, 2)),
);
const initialTranslateY = sub(
initialCoordinates.y + initialCoordinates.height / 2,
add(top, divide(imageHeight, 2)),
);
const position = new Value(1);
const progress = interpolate(
position,
{
inputRange: [ 0, 1 ],
outputRange: [ 0, 1 ],
extrapolate: Extrapolate.CLAMP,
},
);
// The inputs we receive from PanGestureHandler
const panState = new Value(-1);
const panTranslationX = new Value(0);
const panTranslationY = new Value(0);
const panVelocityX = new Value(0);
const panVelocityY = new Value(0);
this.panEvent = event([{
nativeEvent: {
state: panState,
translationX: panTranslationX,
translationY: panTranslationY,
velocityX: panVelocityX,
velocityY: panVelocityY,
},
}]);
const panActive = eq(panState, State.ACTIVE);
// The inputs we receive from PinchGestureHandler
const pinchState = new Value(-1);
const pinchScale = new Value(1);
const pinchFocalX = new Value(0);
const pinchFocalY = new Value(0);
this.pinchEvent = event([{
nativeEvent: {
state: pinchState,
scale: pinchScale,
focalX: pinchFocalX,
focalY: pinchFocalY,
},
}]);
const pinchActive = eq(pinchState, State.ACTIVE);
// The inputs we receive from TapGestureHandler
const tapState = new Value(-1);
const tapX = new Value(0);
const tapY = new Value(0);
this.tapEvent = event([{
nativeEvent: {
state: tapState,
x: tapX,
y: tapY,
},
}]);
// The all-important outputs
const curScale = new Value(1);
const curX = new Value(0);
const curY = new Value(0);
const curOpacity = new Value(1);
// The centered variables help us know if we need to be recentered
const recenteredScale = max(curScale, 1);
const horizontalPanSpace = this.horizontalPanSpace(recenteredScale);
const verticalPanSpace = this.verticalPanSpace(recenteredScale);
const resetXClock = new Clock();
const resetYClock = new Clock();
const zoomClock = new Clock();
const dismissingFromPan = new Value(0);
const roundedCurScale = divide(round(multiply(curScale, 1000)), 1000);
const gestureActive = or(pinchActive, panActive);
const activeInteraction = or(
gestureActive,
clockRunning(zoomClock),
dismissingFromPan,
);
const updates = [
this.pinchUpdate(
pinchActive,
pinchScale,
pinchFocalX,
pinchFocalY,
curScale,
curX,
curY,
),
this.panUpdate(
panActive,
panTranslationX,
panTranslationY,
curX,
curY,
),
this.doubleTapUpdate(
tapState,
tapX,
tapY,
roundedCurScale,
zoomClock,
gestureActive,
curScale,
curX,
curY,
),
this.opacityUpdate(
panState,
pinchActive,
panVelocityX,
panVelocityY,
curX,
curY,
roundedCurScale,
curOpacity,
dismissingFromPan,
),
this.recenter(
resetXClock,
resetYClock,
activeInteraction,
recenteredScale,
horizontalPanSpace,
verticalPanSpace,
curScale,
curX,
curY,
),
this.flingUpdate(
resetXClock,
resetYClock,
activeInteraction,
panVelocityX,
panVelocityY,
horizontalPanSpace,
verticalPanSpace,
curX,
curY,
),
];
const updatedScale = [ updates, curScale ];
const updatedCurX = [ updates, curX ];
const updatedCurY = [ updates, curY ];
const updatedOpacity = [ updates, curOpacity ];
const reverseProgress = sub(1, progress);
this.scale = add(
multiply(reverseProgress, initialScale),
multiply(progress, updatedScale),
);
this.x = add(
multiply(reverseProgress, initialTranslateX),
multiply(progress, updatedCurX),
);
this.y = add(
multiply(reverseProgress, initialTranslateY),
multiply(progress, updatedCurY),
);
this.opacity = multiply(progress, updatedOpacity);
this.imageContainerOpacity = interpolate(
progress,
{
inputRange: [ 0, 0.1 ],
outputRange: [ 0, 1 ],
extrapolate: Extrapolate.CLAMP,
},
);
}
// How much space do we have to pan the image horizontally?
horizontalPanSpace(scale: Value) {
const apparentWidth = multiply(this.imageWidth, scale);
const horizPop = divide(
sub(apparentWidth, this.screenWidth),
2,
);
return max(horizPop, 0);
}
// How much space do we have to pan the image vertically?
verticalPanSpace(scale: Value) {
const apparentHeight = multiply(this.imageHeight, scale);
const vertPop = divide(
sub(apparentHeight, this.screenHeight),
2,
);
return max(vertPop, 0);
}
pinchUpdate(
// Inputs
pinchActive: Value,
pinchScale: Value,
pinchFocalX: Value,
pinchFocalY: Value,
// Outputs
curScale: Value,
curX: Value,
curY: Value,
): Value {
const deltaScale = scaleDelta(pinchScale, pinchActive);
const deltaPinchX = multiply(
sub(1, deltaScale),
sub(
pinchFocalX,
curX,
this.centerX,
),
);
const deltaPinchY = multiply(
sub(1, deltaScale),
sub(
pinchFocalY,
curY,
this.centerY,
),
);
return cond(
[ deltaScale, pinchActive ],
[
set(curX, add(curX, deltaPinchX)),
set(curY, add(curY, deltaPinchY)),
set(curScale, multiply(curScale, deltaScale)),
],
);
}
panUpdate(
// Inputs
panActive: Value,
panTranslationX: Value,
panTranslationY: Value,
// Outputs
curX: Value,
curY: Value,
): Value {
const deltaX = panDelta(panTranslationX, panActive);
const deltaY = panDelta(panTranslationY, panActive);
return cond(
[ deltaX, deltaY, panActive ],
[
set(curX, add(curX, deltaX)),
set(curY, add(curY, deltaY)),
],
);
}
doubleTapUpdate(
// Inputs
tapState: Value,
tapX: Value,
tapY: Value,
roundedCurScale: Value,
zoomClock: Clock,
gestureActive: Value,
// Outputs
curScale: Value,
curX: Value,
curY: Value,
): Value {
const zoomClockRunning = clockRunning(zoomClock);
const zoomActive = and(not(gestureActive), zoomClockRunning);
const targetScale = cond(greaterThan(roundedCurScale, 1), 1, 3);
const tapXDiff = sub(tapX, this.centerX, curX);
const tapYDiff = sub(tapY, this.centerY, curY);
const tapXPercent = divide(tapXDiff, this.imageWidth, curScale);
const tapYPercent = divide(tapYDiff, this.imageHeight, curScale);
const horizPanSpace = this.horizontalPanSpace(targetScale);
const vertPanSpace = this.verticalPanSpace(targetScale);
const horizPanPercent = divide(horizPanSpace, this.imageWidth, targetScale);
const vertPanPercent = divide(vertPanSpace, this.imageHeight, targetScale);
const tapXPercentClamped = clamp(
tapXPercent,
multiply(-1, horizPanPercent),
horizPanPercent,
);
const tapYPercentClamped = clamp(
tapYPercent,
multiply(-1, vertPanPercent),
vertPanPercent,
);
const targetX = multiply(tapXPercentClamped, this.imageWidth, targetScale);
const targetY = multiply(tapYPercentClamped, this.imageHeight, targetScale);
const targetRelativeScale = divide(targetScale, curScale);
const targetRelativeX = multiply(-1, add(targetX, curX));
const targetRelativeY = multiply(-1, add(targetY, curY));
const zoomScale = runTiming(zoomClock, 1, targetRelativeScale);
const zoomX = runTiming(zoomClock, 0, targetRelativeX, false);
const zoomY = runTiming(zoomClock, 0, targetRelativeY, false);
const deltaScale = scaleDelta(zoomScale, zoomActive);
const deltaX = panDelta(zoomX, zoomActive);
const deltaY = panDelta(zoomY, zoomActive);
const tapJustEnded = gestureJustEnded(tapState);
return cond(
[ tapJustEnded, deltaX, deltaY, deltaScale, gestureActive ],
stopClock(zoomClock),
cond(
or(zoomClockRunning, tapJustEnded),
[
zoomX,
zoomY,
zoomScale,
set(curX, add(curX, deltaX)),
set(curY, add(curY, deltaY)),
set(curScale, multiply(curScale, deltaScale)),
],
),
);
}
opacityUpdate(
// Inputs
panState: Value,
pinchActive: Value,
panVelocityX: Value,
panVelocityY: Value,
curX: Value,
curY: Value,
roundedCurScale: Value,
// Outputs
curOpacity: Value,
dismissingFromPan: Value,
): Value {
const progressiveOpacity = max(
min(
sub(1, abs(divide(curX, this.screenWidth))),
sub(1, abs(divide(curY, this.screenHeight))),
),
0,
);
const panJustEnded = gestureJustEnded(panState);
const resetClock = new Clock();
const velocity = pow(add(pow(panVelocityX, 2), pow(panVelocityY, 2)), 0.5);
const shouldGoBack = and(
panJustEnded,
or(
greaterThan(velocity, 50),
greaterThan(0.7, progressiveOpacity),
),
);
const decayClock = new Clock();
const decay = [
set(curX, runDecay(decayClock, panVelocityX, curX, false)),
set(curY, runDecay(decayClock, panVelocityY, curY)),
];
return cond(
[ panJustEnded, dismissingFromPan ],
decay,
cond(
or(pinchActive, greaterThan(roundedCurScale, 1)),
set(curOpacity, runTiming(resetClock, curOpacity, 1)),
[
stopClock(resetClock),
set(curOpacity, progressiveOpacity),
set(dismissingFromPan, shouldGoBack),
cond(
shouldGoBack,
[
decay,
call([], this.close),
],
),
],
),
);
}
recenter(
// Inputs
resetXClock: Clock,
resetYClock: Clock,
activeInteraction: Value,
recenteredScale: Value,
horizontalPanSpace: Value,
verticalPanSpace: Value,
// Outputs
curScale: Value,
curX: Value,
curY: Value,
): Value {
const resetScaleClock = new Clock();
const recenteredX = clamp(
curX,
multiply(-1, horizontalPanSpace),
horizontalPanSpace,
);
const recenteredY = clamp(
curY,
multiply(-1, verticalPanSpace),
verticalPanSpace,
);
return cond(
activeInteraction,
[
stopClock(resetScaleClock),
stopClock(resetXClock),
stopClock(resetYClock),
],
[
cond(
or(
clockRunning(resetScaleClock),
neq(recenteredScale, curScale),
),
set(curScale, runTiming(resetScaleClock, curScale, recenteredScale)),
),
cond(
or(
clockRunning(resetXClock),
neq(recenteredX, curX),
),
set(curX, runTiming(resetXClock, curX, recenteredX)),
),
cond(
or(
clockRunning(resetYClock),
neq(recenteredY, curY),
),
set(curY, runTiming(resetYClock, curY, recenteredY)),
),
],
);
}
flingUpdate(
// Inputs
resetXClock: Clock,
resetYClock: Clock,
activeInteraction: Value,
panVelocityX: Value,
panVelocityY: Value,
horizontalPanSpace: Value,
verticalPanSpace: Value,
// Outputs
curX: Value,
curY: Value,
): Value {
const flingXClock = new Clock();
const flingYClock = new Clock();
const decayX = runDecay(flingXClock, panVelocityX, curX);
const recenteredX = clamp(
decayX,
multiply(-1, horizontalPanSpace),
horizontalPanSpace,
);
const decayY = runDecay(flingYClock, panVelocityY, curY);
const recenteredY = clamp(
decayY,
multiply(-1, verticalPanSpace),
verticalPanSpace,
);
return cond(
activeInteraction,
[
stopClock(flingXClock),
stopClock(flingYClock),
],
[
set(curX, recenteredX),
set(curY, recenteredY),
cond(
or(
clockRunning(resetXClock),
neq(decayX, recenteredX),
),
stopClock(flingXClock),
),
cond(
or(
clockRunning(resetYClock),
neq(decayY, recenteredY),
),
stopClock(flingYClock),
),
],
);
}
updateDimensions() {
this.screenWidth.setValue(width);
this.screenHeight.setValue(height);
this.centerX.setValue(width / 2);
this.centerY.setValue(height / 2);
this.imageWidth.setValue(width);
this.imageHeight.setValue(height);
}
componentDidUpdate(prevProps: Props) {
if (
this.props.screenDimensions !== prevProps.screenDimensions ||
this.props.contentVerticalOffset !== prevProps.contentVerticalOffset
) {
this.updateDimensions();
}
}
get screenDimensions(): Dimensions {
return { width, height }
}
get imageDimensions(): Dimensions {
return { width, height }
}
get imageContainerStyle() {
const { height, width } = this.imageDimensions;
const { height: screenHeight, width: screenWidth } = this.screenDimensions;
const top = (screenHeight - height) / 2;
const left = (screenWidth - width) / 2;
return {
height,
width,
marginTop: top,
marginLeft: left,
opacity: this.imageContainerOpacity,
};
}
static isActive(props) {
return true;
}
get contentContainerStyle() {
const fullScreenHeight = this.screenDimensions.height;
const top = 0;
const bottom = fullScreenHeight;
// margin will clip, but padding won't
const verticalStyle = MultimediaModal.isActive(this.props)
? { paddingTop: top, paddingBottom: bottom }
: { marginTop: top, marginBottom: bottom };
return [ styles.contentContainer, verticalStyle ];
}
render() {
const statusBar = null;
const backdropStyle = { opacity: this.opacity };
const closeButtonStyle = {
opacity: this.opacity,
top: 4,
};
const view = (
<Animated.View style={styles.container}>
{statusBar}
<Animated.View style={[ styles.backdrop, backdropStyle ]} />
<View style={this.contentContainerStyle}>
<Animated.View style={this.imageContainerStyle}>
<Svg
width={width}
height={height}
viewBox={`${origin} ${origin * ratio} ${width} ${width}`}
>
<AnimatedG
style={{
transform: [
{ translateX: this.x },
{ translateY: this.y },
{ translateX: oX },
{ scale: this.scale },
{ translateX: -oX },
],
}}
>
<G transform={`translate(${origin},${origin})`}>
<Rect
x={0}
y={0}
width={width}
height={width}
fill={fill()}
/>
<Polygons />
</G>
</AnimatedG>
</Svg>
</Animated.View>
</View>
<Animated.View style={[
styles.closeButtonContainer,
closeButtonStyle,
]}>
<TouchableOpacity onPress={this.close}>
<Text style={styles.closeButton}>
×
</Text>
</TouchableOpacity>
</Animated.View>
</Animated.View>
);
return (
<PinchGestureHandler
onGestureEvent={this.pinchEvent}
onHandlerStateChange={this.pinchEvent}
simultaneousHandlers={this.handlerRefs}
ref={this.pinchHandler}
>
<Animated.View style={styles.container}>
<PanGestureHandler
onGestureEvent={this.panEvent}
onHandlerStateChange={this.panEvent}
simultaneousHandlers={this.handlerRefs}
ref={this.panHandler}
avgTouches
>
<Animated.View style={styles.container}>
<TapGestureHandler
onHandlerStateChange={this.tapEvent}
simultaneousHandlers={this.handlerRefs}
ref={this.tapHandler}
waitFor={this.priorityHandlerRefs}
numberOfTaps={2}
>
{view}
</TapGestureHandler>
</Animated.View>
</PanGestureHandler>
</Animated.View>
</PinchGestureHandler>
);
}
close = () => {
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: "white",
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
closeButtonContainer: {
position: "absolute",
right: 4,
},
closeButton: {
paddingTop: 2,
paddingBottom: 2,
paddingLeft: 8,
paddingRight: 8,
fontSize: 36,
color: "white",
textShadowColor: "#000",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 1,
},
});
export default MultimediaModal
const xy = 0.9 * width;
const side = 0.1 * width;
const origin = -width / 2;
const ratio = width / height;
const oX = android ? origin : 0;
const pos = () => Math.random() * xy;
const byte = () => Math.floor(Math.random() * 256);
const fill = () => `rgba(${byte()}, ${byte()}, ${byte()}, 0.2)`;
const polygons = Array.from({ length: 200 }).map((_, i) => (
<Rect
key={i}
x={pos()}
y={pos()}
width={side}
height={side}
fill={fill()}
stroke="black"
/>
));
const Polygons = memo(() => polygons, () => true);
Try to zoom — urgh!