diff --git a/apps/common-app/src/legacy/v2_api/transformations/index.tsx b/apps/common-app/src/legacy/v2_api/transformations/index.tsx index d2cf33c13b..3923b25ce4 100644 --- a/apps/common-app/src/legacy/v2_api/transformations/index.tsx +++ b/apps/common-app/src/legacy/v2_api/transformations/index.tsx @@ -8,70 +8,41 @@ import Animated, { import SIGNET from '../../../ListWithHeader/signet.png'; -function identity4() { +function identity3() { 'worklet'; - return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + return [1, 0, 0, 0, 1, 0, 0, 0, 1]; } -function multiply4(a: number[], b: number[]) { +function multiply3(a: number[], b: number[]) { 'worklet'; return [ - a[0] * b[0] + a[1] * b[4] + a[2] * b[8] + a[3] * b[12], - a[0] * b[1] + a[1] * b[5] + a[2] * b[9] + a[3] * b[13], - a[0] * b[2] + a[1] * b[6] + a[2] * b[10] + a[3] * b[14], - a[0] * b[3] + a[1] * b[7] + a[2] * b[11] + a[3] * b[15], - a[4] * b[0] + a[5] * b[4] + a[6] * b[8] + a[7] * b[12], - a[4] * b[1] + a[5] * b[5] + a[6] * b[9] + a[7] * b[13], - a[4] * b[2] + a[5] * b[6] + a[6] * b[10] + a[7] * b[14], - a[4] * b[3] + a[5] * b[7] + a[6] * b[11] + a[7] * b[15], - a[8] * b[0] + a[9] * b[4] + a[10] * b[8] + a[11] * b[12], - a[8] * b[1] + a[9] * b[5] + a[10] * b[9] + a[11] * b[13], - a[8] * b[2] + a[9] * b[6] + a[10] * b[10] + a[11] * b[14], - a[8] * b[3] + a[9] * b[7] + a[10] * b[11] + a[11] * b[15], - a[12] * b[0] + a[13] * b[4] + a[14] * b[8] + a[15] * b[12], - a[12] * b[1] + a[13] * b[5] + a[14] * b[9] + a[15] * b[13], - a[12] * b[2] + a[13] * b[6] + a[14] * b[10] + a[15] * b[14], - a[12] * b[3] + a[13] * b[7] + a[14] * b[11] + a[15] * b[15], + a[0] * b[0] + a[1] * b[3] + a[2] * b[6], + a[0] * b[1] + a[1] * b[4] + a[2] * b[7], + a[0] * b[2] + a[1] * b[5] + a[2] * b[8], + a[3] * b[0] + a[4] * b[3] + a[5] * b[6], + a[3] * b[1] + a[4] * b[4] + a[5] * b[7], + a[3] * b[2] + a[4] * b[5] + a[5] * b[8], + a[6] * b[0] + a[7] * b[3] + a[8] * b[6], + a[6] * b[1] + a[7] * b[4] + a[8] * b[7], + a[6] * b[2] + a[7] * b[5] + a[8] * b[8], ]; } -function scale4(sx: number, sy: number, sz: number) { +function scale3(sx: number, sy: number) { 'worklet'; - return [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, 0, 0, 0, 1]; + return [sx, 0, 0, 0, sy, 0, 0, 0, 1]; } -function translate4(tx: number, ty: number, tz: number) { +function translate3(tx: number, ty: number) { 'worklet'; - return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1]; + return [1, 0, 0, 0, 1, 0, tx, ty, 1]; } -function rotate4(rad: number, x: number, y: number, z: number) { +function rotate3(rad: number) { 'worklet'; - const len = Math.hypot(x, y, z); const c = Math.cos(rad); const s = Math.sin(rad); - const t = 1 - c; - x /= len; - y /= len; - z /= len; - return [ - t * x * x + c, - t * x * y - s * z, - t * x * z + s * y, - 0, - t * x * y + s * z, - t * y * y + c, - t * y * z - s * x, - 0, - t * x * z - s * y, - t * y * z + s * x, - t * z * z + c, - 0, - 0, - 0, - 0, - 1, - ]; + return [c, -s, 0, s, c, 0, 0, 0, 1]; } function invert2(m: number[]) { @@ -82,6 +53,10 @@ function invert2(m: number[]) { const d = m[3]; const det = a * d - b * c; + if (Math.abs(det) < 1e-6) { + return [1, 0, 0, 1]; + } + return [d / det, -b / det, -c / det, a / det]; } @@ -90,7 +65,7 @@ function toTransformedCoords( matrix: number[] ) { 'worklet'; - const m2 = [matrix[0], matrix[1], matrix[4], matrix[5]]; + const m2 = [matrix[0], matrix[1], matrix[3], matrix[4]]; const inv = invert2(m2); const x = point.x; const y = point.y; @@ -107,21 +82,21 @@ function createMatrix( origin: { x: number; y: number } ) { 'worklet'; - let matrix = identity4(); + let matrix = identity3(); if (scale !== 1) { - matrix = multiply4(matrix, translate4(origin.x, origin.y, 0)); - matrix = multiply4(matrix, scale4(scale, scale, 1)); - matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0)); + matrix = multiply3(matrix, translate3(origin.x, origin.y)); + matrix = multiply3(matrix, scale3(scale, scale)); + matrix = multiply3(matrix, translate3(-origin.x, -origin.y)); } if (rotation !== 0) { - matrix = multiply4(matrix, translate4(origin.x, origin.y, 0)); - matrix = multiply4(matrix, rotate4(-rotation, 0, 0, 1)); - matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0)); + matrix = multiply3(matrix, translate3(origin.x, origin.y)); + matrix = multiply3(matrix, rotate3(-rotation)); + matrix = multiply3(matrix, translate3(-origin.x, -origin.y)); } if (translation.x !== 0 || translation.y !== 0) { - matrix = multiply4(matrix, translate4(translation.x, translation.y, 0)); + matrix = multiply3(matrix, translate3(translation.x, translation.y)); } return matrix; @@ -142,7 +117,7 @@ function applyTransformations( rotation, origin ); - return multiply4(transform, matrix); + return multiply3(transform, matrix); } function Photo() { @@ -154,7 +129,7 @@ function Photo() { const isRotating = useSharedValue(false); const isScaling = useSharedValue(false); - const transform = useSharedValue(identity4()); + const transform = useSharedValue(identity3()); const style = useAnimatedStyle(() => { const matrix = applyTransformations( @@ -167,8 +142,8 @@ function Photo() { return { transform: [ - { translateX: matrix[12] }, - { translateY: matrix[13] }, + { translateX: matrix[6] }, + { translateY: matrix[7] }, { scale: Math.hypot(matrix[0], matrix[1]) }, { rotateZ: `${Math.atan2(matrix[1], matrix[0])}rad` }, ], diff --git a/apps/common-app/src/new_api/complicated/transformations/index.tsx b/apps/common-app/src/new_api/complicated/transformations/index.tsx index fcc9435e6f..e9b9b010fa 100644 --- a/apps/common-app/src/new_api/complicated/transformations/index.tsx +++ b/apps/common-app/src/new_api/complicated/transformations/index.tsx @@ -16,70 +16,41 @@ import Animated, { // @ts-ignore it's an image import SIGNET from '../../../ListWithHeader/signet.png'; -function identity4() { +function identity3() { 'worklet'; - return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + return [1, 0, 0, 0, 1, 0, 0, 0, 1]; } -function multiply4(a: number[], b: number[]) { +function multiply3(a: number[], b: number[]) { 'worklet'; return [ - a[0] * b[0] + a[1] * b[4] + a[2] * b[8] + a[3] * b[12], - a[0] * b[1] + a[1] * b[5] + a[2] * b[9] + a[3] * b[13], - a[0] * b[2] + a[1] * b[6] + a[2] * b[10] + a[3] * b[14], - a[0] * b[3] + a[1] * b[7] + a[2] * b[11] + a[3] * b[15], - a[4] * b[0] + a[5] * b[4] + a[6] * b[8] + a[7] * b[12], - a[4] * b[1] + a[5] * b[5] + a[6] * b[9] + a[7] * b[13], - a[4] * b[2] + a[5] * b[6] + a[6] * b[10] + a[7] * b[14], - a[4] * b[3] + a[5] * b[7] + a[6] * b[11] + a[7] * b[15], - a[8] * b[0] + a[9] * b[4] + a[10] * b[8] + a[11] * b[12], - a[8] * b[1] + a[9] * b[5] + a[10] * b[9] + a[11] * b[13], - a[8] * b[2] + a[9] * b[6] + a[10] * b[10] + a[11] * b[14], - a[8] * b[3] + a[9] * b[7] + a[10] * b[11] + a[11] * b[15], - a[12] * b[0] + a[13] * b[4] + a[14] * b[8] + a[15] * b[12], - a[12] * b[1] + a[13] * b[5] + a[14] * b[9] + a[15] * b[13], - a[12] * b[2] + a[13] * b[6] + a[14] * b[10] + a[15] * b[14], - a[12] * b[3] + a[13] * b[7] + a[14] * b[11] + a[15] * b[15], + a[0] * b[0] + a[1] * b[3] + a[2] * b[6], + a[0] * b[1] + a[1] * b[4] + a[2] * b[7], + a[0] * b[2] + a[1] * b[5] + a[2] * b[8], + a[3] * b[0] + a[4] * b[3] + a[5] * b[6], + a[3] * b[1] + a[4] * b[4] + a[5] * b[7], + a[3] * b[2] + a[4] * b[5] + a[5] * b[8], + a[6] * b[0] + a[7] * b[3] + a[8] * b[6], + a[6] * b[1] + a[7] * b[4] + a[8] * b[7], + a[6] * b[2] + a[7] * b[5] + a[8] * b[8], ]; } -function scale4(sx: number, sy: number, sz: number) { +function scale3(sx: number, sy: number) { 'worklet'; - return [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, 0, 0, 0, 1]; + return [sx, 0, 0, 0, sy, 0, 0, 0, 1]; } -function translate4(tx: number, ty: number, tz: number) { +function translate3(tx: number, ty: number) { 'worklet'; - return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1]; + return [1, 0, 0, 0, 1, 0, tx, ty, 1]; } -function rotate4(rad: number, x: number, y: number, z: number) { +function rotate3(rad: number) { 'worklet'; - const len = Math.hypot(x, y, z); const c = Math.cos(rad); const s = Math.sin(rad); - const t = 1 - c; - x /= len; - y /= len; - z /= len; - return [ - t * x * x + c, - t * x * y - s * z, - t * x * z + s * y, - 0, - t * x * y + s * z, - t * y * y + c, - t * y * z - s * x, - 0, - t * x * z - s * y, - t * y * z + s * x, - t * z * z + c, - 0, - 0, - 0, - 0, - 1, - ]; + return [c, -s, 0, s, c, 0, 0, 0, 1]; } function invert2(m: number[]) { @@ -90,6 +61,10 @@ function invert2(m: number[]) { const d = m[3]; const det = a * d - b * c; + if (Math.abs(det) < 1e-6) { + return [1, 0, 0, 1]; + } + return [d / det, -b / det, -c / det, a / det]; } @@ -98,7 +73,7 @@ function toTransformedCoords( matrix: number[] ) { 'worklet'; - const m2 = [matrix[0], matrix[1], matrix[4], matrix[5]]; + const m2 = [matrix[0], matrix[1], matrix[3], matrix[4]]; const inv = invert2(m2); const x = point.x; const y = point.y; @@ -115,21 +90,21 @@ function createMatrix( origin: { x: number; y: number } ) { 'worklet'; - let matrix = identity4(); + let matrix = identity3(); if (scale !== 1) { - matrix = multiply4(matrix, translate4(origin.x, origin.y, 0)); - matrix = multiply4(matrix, scale4(scale, scale, 1)); - matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0)); + matrix = multiply3(matrix, translate3(origin.x, origin.y)); + matrix = multiply3(matrix, scale3(scale, scale)); + matrix = multiply3(matrix, translate3(-origin.x, -origin.y)); } if (rotation !== 0) { - matrix = multiply4(matrix, translate4(origin.x, origin.y, 0)); - matrix = multiply4(matrix, rotate4(-rotation, 0, 0, 1)); - matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0)); + matrix = multiply3(matrix, translate3(origin.x, origin.y)); + matrix = multiply3(matrix, rotate3(-rotation)); + matrix = multiply3(matrix, translate3(-origin.x, -origin.y)); } if (translation.x !== 0 || translation.y !== 0) { - matrix = multiply4(matrix, translate4(translation.x, translation.y, 0)); + matrix = multiply3(matrix, translate3(translation.x, translation.y)); } return matrix; @@ -150,7 +125,7 @@ function applyTransformations( rotation, origin ); - return multiply4(transform, matrix); + return multiply3(transform, matrix); } function Photo() { @@ -162,7 +137,7 @@ function Photo() { const isRotating = useSharedValue(false); const isScaling = useSharedValue(false); - const transform = useSharedValue(identity4()); + const transform = useSharedValue(identity3()); const style = useAnimatedStyle(() => { const matrix = applyTransformations( @@ -175,8 +150,8 @@ function Photo() { return { transform: [ - { translateX: matrix[12] }, - { translateY: matrix[13] }, + { translateX: matrix[6] }, + { translateY: matrix[7] }, { scale: Math.hypot(matrix[0], matrix[1]) }, { rotateZ: `${Math.atan2(matrix[1], matrix[0])}rad` }, ], diff --git a/packages/docs-gesture-handler/docs/guides/transformations.mdx b/packages/docs-gesture-handler/docs/guides/transformations.mdx new file mode 100644 index 0000000000..29a4147afc --- /dev/null +++ b/packages/docs-gesture-handler/docs/guides/transformations.mdx @@ -0,0 +1,346 @@ +--- +id: transformations +title: Transforming a view with multiple gestures +sidebar_label: Transforming a view +sidebar_position: 6 +--- + +Combining [Pan](/docs/gestures/use-pan-gesture), [Pinch](/docs/gestures/use-pinch-gesture) and [Rotation](/docs/gestures/use-rotation-gesture) on a single view — the "photo viewer" interaction where you can drag, zoom and twist an image at the same time — is a common use case. Gesture Handler doesn't ship a dedicated component for it, but you can build it yourself on top of the existing gestures. There are two ideas that make it work; the rest is wiring. + +This guide assumes you're using [Reanimated](https://docs.swmansion.com/react-native-reanimated/) to run the transformation on the UI thread. The [full example](#full-example) is at the end of this page, and a version of it can be found in the [example app](https://github.com/software-mansion/react-native-gesture-handler/tree/main/apps/common-app/src/new_api/complicated/transformations). + +## Use a matrix, not separate transforms + +The obvious approach — keeping separate `scale`, `rotation` and `translation` values and passing them to a `transform` array — falls apart once you want each gesture to build on the last. The array does let you control the order its entries apply in, but there's no clean place to accumulate the running result: to preserve what previous gestures did, you'd have to keep appending an entry for every incremental translation, scale and rotation, and React would rebuild the whole matrix from that ever-growing list on each render. + +A single affine matrix fixes that. It bundles translation, scale and rotation into one value that you compose by multiplying matrices together in whatever order you need, and since the result is itself a matrix you can store it and let each new gesture build on top. That gives you a natural split: the accumulated transform lives in the matrix, while the in-progress gesture stays as the plain `scale`, `rotation` and `translation` the callbacks hand you as the fingers move. Each frame, combine the two to draw the view. When the gesture ends, fold its values into the matrix and reset them, so the next gesture picks up where it left off. You don't need to follow the underlying math — the [full example](#full-example) has the handful of small helper functions that implement it. + +## Keep the origin stable + +Scaling and rotation pivot around the origin, but the user expects them to pivot around the point between their fingers. To move the pivot, wrap the transform between two translations — shift that point to the origin, apply the scale or rotation, then shift it back. Do this while building the matrix, once for the scale and once for the rotation: + +```ts +matrix = multiply(matrix, translate(origin.x, origin.y)); +matrix = multiply(matrix, scale(scaleValue, scaleValue)); +matrix = multiply(matrix, translate(-origin.x, -origin.y)); +``` + +Capture that pivot once, when the gesture activates, and keep it in a shared value: + +```jsx +const pinch = usePinchGesture({ + onActivate: (e) => { + origin.value = { + x: -(e.focalX - size.width / 2), + y: -(e.focalY - size.height / 2), + }; + }, + // ... +}); +``` + +Set it only on activation and leave it untouched until the gesture ends, so the pivot stays stable — recomputing it every frame, or letting a second simultaneous gesture overwrite it, makes the view jump. The focal point arrives relative to the view rather than its center, which is what the size-based adjustment handles (read the size with [`onLayout`](https://reactnative.dev/docs/view#onlayout)). And when Pinch and Rotation run together, let only the first one set the pivot. + +## Full example + +Put both ideas together and you have the whole interaction. Each gesture updates its own shared value as the fingers move and folds the result into the stored matrix when it ends; [`useSimultaneousGestures`](/docs/fundamentals/gesture-composition) runs `Pan`, `Pinch`, `Rotation` and a double-tap zoom at the same time. + + { + const matrix = applyTransformations( + translation.value, + scale.value, + rotation.value, + origin.value, + transform.value + ); + + return { + transform: [ + { translateX: matrix[6] }, + { translateY: matrix[7] }, + { scale: Math.hypot(matrix[0], matrix[1]) }, + { rotateZ: \`\${Math.atan2(matrix[1], matrix[0])}rad\` }, + ], + }; + }); + + const rotationGesture = useRotationGesture({ + onActivate: (e) => { + if (!isRotating.value && !isScaling.value) { + origin.value = { + x: -(e.anchorX - size.width / 2), + y: -(e.anchorY - size.height / 2), + }; + } + isRotating.value = true; + }, + onUpdate: (e) => { + rotation.value += e.rotationChange; + }, + onDeactivate: () => { + transform.value = applyTransformations( + translation.value, + scale.value, + rotation.value, + origin.value, + transform.value + ); + + rotation.value = 0; + translation.value = { x: 0, y: 0 }; + scale.value = 1; + isRotating.value = false; + }, + }); + + const scaleGesture = usePinchGesture({ + onActivate: (e) => { + if (!isRotating.value && !isScaling.value) { + origin.value = { + x: -(e.focalX - size.width / 2), + y: -(e.focalY - size.height / 2), + }; + } + isScaling.value = true; + }, + onUpdate: (e) => { + scale.value *= e.scaleChange; + }, + onDeactivate: () => { + transform.value = applyTransformations( + translation.value, + scale.value, + rotation.value, + origin.value, + transform.value + ); + rotation.value = 0; + translation.value = { x: 0, y: 0 }; + scale.value = 1; + isScaling.value = false; + }, + }); + + const panGesture = usePanGesture({ + averageTouches: true, + onUpdate: (e) => { + translation.value = { + x: translation.value.x + e.changeX, + y: translation.value.y + e.changeY, + }; + }, + onDeactivate: () => { + transform.value = applyTransformations( + translation.value, + scale.value, + rotation.value, + origin.value, + transform.value + ); + + rotation.value = 0; + translation.value = { x: 0, y: 0 }; + scale.value = 1; + }, + }); + + const doubleTapGesture = useTapGesture({ + numberOfTaps: 2, + onDeactivate: () => { + scale.value *= 1.25; + }, + }); + + const gesture = useSimultaneousGestures( + rotationGesture, + scaleGesture, + panGesture, + doubleTapGesture + ); + + return ( + + { + setSize({ + width: nativeEvent.layout.width, + height: nativeEvent.layout.height, + }); + }} + style={[styles.container, style]} + /> + + ); +} + +export default function Example() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + home: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + container: { + width: 240, + height: 240, + backgroundColor: '#5b6ef5', + elevation: 8, + borderRadius: 48, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }, +}); +`}/> +