Skip to content

Commit

Permalink
feat: methods for programmatically scaling/translating the content
Browse files Browse the repository at this point in the history
  • Loading branch information
Ivanka Todorova committed Feb 2, 2022
1 parent 319a913 commit 1c7a5dd
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 72 deletions.
16 changes: 14 additions & 2 deletions README.md
Expand Up @@ -16,6 +16,7 @@ Even though the demo shows the library used with images, it was initially design
- Drag one finger to pan
- Keep content inside container boundaries
- Configurable minimum and maximum scale
- Methods for programmatically updating position and scale

Thanks to `react-native-reanimated` all animations are running on the UI thread, so no fps drops are experienced.

Expand All @@ -33,8 +34,8 @@ This library uses `react-native-reanimated` v2 and the new API of `react-native-

Before installing it, you need to install those two libraries and set them up in your project:

- `react-native-reanimated`: [INSTALLATION](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation)
- `react-native-gesture-handler`: [INSTALLATION](https://docs.swmansion.com/react-native-gesture-handler/docs/#installation)
- `react-native-reanimated`: [Installation & Setup](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation)
- `react-native-gesture-handler`: [Installation & Setup](https://docs.swmansion.com/react-native-gesture-handler/docs/#installation)

## 鈿欙笍 Installation

Expand Down Expand Up @@ -96,8 +97,19 @@ const styles = StyleSheet.create({
| maxScale | Number? | `4` | Maximum value of scale. |
| initialScale | Number? | `1` | Initial value of scale. |

## 馃洜 Methods

| Method | Params | Return | Description |
|----------------|-----------------------------------------|--------|----------------------------------------------------------------------------------------------|
| scaleTo | value: number, animated: boolean | void | Sets sharedValue `scale` to `value`,<br/> if `animated` is **true** uses `withTiming` |
| setContentSize | width: number, height: number | void | Updates sharedValue `contentSize` and overrides prop: `contentDimensions` |
| translateTo | x: number, y: number, animated: boolean | void | Updates content `translateX` / `translateY`, <br>if `animated` is **true** uses `withTiming` |
| setMinScale | value: number | void | Updates `minScale` value |
| setMaxScale | value: number | void | Updates `maxScale` value |
| getScale | | number | Returns current value of sharedValue `scale` |

You can also refer to the app inside `example/` for a running demo of this library.

## Contributing

See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.
Expand Down
2 changes: 1 addition & 1 deletion example/android/app/build.gradle
Expand Up @@ -76,7 +76,7 @@ import com.android.build.OutputFile
*/

project.ext.react = [
enableHermes: false, // clean and rebuild if changing
enableHermes: true, // clean and rebuild if changing
entryFile: "index.tsx",
]

Expand Down
Expand Up @@ -10,6 +10,8 @@
import com.facebook.soloader.SoLoader;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import com.facebook.react.bridge.JSIModulePackage;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;

public class MainApplication extends Application implements ReactApplication {

Expand All @@ -26,14 +28,18 @@ protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for PanPinchViewExample:
// packages.add(new MyReactNativePackage());

return packages;
}

@Override
protected String getJSMainModuleName() {
return "index";
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new ReanimatedJSIModulePackage();
}
};

@Override
Expand Down
63 changes: 61 additions & 2 deletions example/src/App.tsx
@@ -1,7 +1,16 @@
import * as React from 'react';

import { Image, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native';
import {
Button,
Image,
SafeAreaView,
StatusBar,
StyleSheet,
View,
} from 'react-native';
import PanPinchView from 'react-native-pan-pinch-view';
import { useRef } from 'react';
import type { PanPinchViewRef } from '../../src/types.js';

const CONTENT = {
width: 150,
Expand All @@ -14,11 +23,56 @@ const CONTAINER = {
};

export default function App() {
const panPinchViewRef = useRef<PanPinchViewRef>(null);

const scaleTo = (value: number) => {
panPinchViewRef.current?.scaleTo(value);
};

const moveTo = (x: number, y: number) => {
panPinchViewRef.current?.translateTo(x, y);
};

return (
<SafeAreaView style={styles.screen}>
<StatusBar />
<View style={styles.controls}>
<Button title="Scale to 0.5" onPress={() => scaleTo(0.5)} />
<Button title="Scale to 1.5" onPress={() => scaleTo(1.5)} />
<Button title="Scale to 2" onPress={() => scaleTo(2)} />
</View>
<View style={styles.controls}>
<Button
title="Center"
onPress={() =>
moveTo(
CONTAINER.width / 2 - CONTENT.width / 2,
CONTAINER.height / 2 - CONTENT.height / 2
)
}
/>
<Button
title="Bottom Right"
onPress={() =>
moveTo(
CONTAINER.width - CONTENT.width,
CONTAINER.height - CONTENT.height
)
}
/>
<Button
title="Bottom Center"
onPress={() =>
moveTo(
CONTAINER.width / 2 - CONTENT.width / 2,
CONTAINER.height - CONTENT.height
)
}
/>
</View>
<View style={styles.container}>
<PanPinchView
ref={panPinchViewRef}
minScale={1}
initialScale={1}
containerDimensions={{
Expand All @@ -44,10 +98,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
alignSelf: 'center',
borderWidth: 1,
marginVertical: 80,
marginVertical: 50,
},
image: {
width: CONTENT.width,
height: CONTENT.height,
},
controls: {
justifyContent: 'center',
flexDirection: 'row',
flexWrap: 'wrap',
},
});
158 changes: 92 additions & 66 deletions src/index.tsx
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { forwardRef, useImperativeHandle } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Gesture,
Expand All @@ -16,16 +16,22 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { clamp, useVector } from 'react-native-redash';
import type { PanPinchViewProps } from './types';

export default function PanPinchView({
containerDimensions = { width: 0, height: 0 },
contentDimensions = { width: 0, height: 0 },
minScale = 0.5,
maxScale = 4,
initialScale = 1,
children,
}: PanPinchViewProps) {
import type { PanPinchViewProps, PanPinchViewRef } from './types';

export default forwardRef(function PanPinchView(
{
containerDimensions = { width: 0, height: 0 },
contentDimensions = { width: 0, height: 0 },
minScale = 0.5,
maxScale = 4,
initialScale = 1,
children,
}: PanPinchViewProps,
ref: React.Ref<PanPinchViewRef>
) {
const currentMinScale = useSharedValue(minScale);
const currentMaxScale = useSharedValue(maxScale);

const scale = useSharedValue(initialScale);
const lastScale = useSharedValue(initialScale);

Expand All @@ -37,41 +43,62 @@ export default function PanPinchView({
const isPinching = useSharedValue(false);
const isResetting = useSharedValue(false);

const layout = useVector(contentDimensions.width, contentDimensions.height);

const animatedStyle = useAnimatedStyle(() => {
const translateX = offset.x.value + translation.x.value;
const translateY = offset.y.value + translation.y.value;
return {
transform: [{ translateX }, { translateY }, { scale: scale.value }],
};
});

const animateToInitialState = () => {
'worklet';
const contentSize = useVector(
contentDimensions.width,
contentDimensions.height
);

isResetting.value = true;
const setContentSize = ({
width,
height,
}: {
width: number;
height: number;
}) => {
contentSize.x.value = width;
contentSize.y.value = height;
};

scale.value = withTiming(initialScale);
lastScale.value = withTiming(initialScale);
const scaleTo = (value: number, animated: boolean) => {
scale.value = animated ? withTiming(value) : value;
lastScale.value = value;
};

translation.x.value = withTiming(0);
translation.y.value = withTiming(0);
const translateTo = (x: number, y: number, animated: boolean) => {
translation.x.value = 0;
translation.y.value = 0;
offset.x.value = animated ? withTiming(x) : x;
offset.y.value = animated ? withTiming(y) : y;
};

offset.x.value = withTiming(0);
offset.y.value = withTiming(0);
const setMinScale = (value: number) => {
currentMinScale.value = value;
};

adjustedFocal.x.value = withTiming(0);
adjustedFocal.y.value = withTiming(0);
const setMaxScale = (value: number) => {
currentMaxScale.value = value;
};

origin.x.value = withTiming(0);
origin.y.value = withTiming(0);
const getScale = (): number => {
return scale.value;
};

layout.x.value = contentDimensions.width;
layout.y.value = contentDimensions.height;
useImperativeHandle(ref, () => ({
scaleTo,
setContentSize,
translateTo,
setMinScale,
setMaxScale,
getScale,
}));

isPinching.value = false;
};
const animatedStyle = useAnimatedStyle(() => {
const translateX = offset.x.value + translation.x.value;
const translateY = offset.y.value + translation.y.value;
return {
transform: [{ translateX }, { translateY }, { scale: scale.value }],
};
});

const setAdjustedFocal = ({
focalX,
Expand All @@ -82,28 +109,37 @@ export default function PanPinchView({
}) => {
'worklet';

adjustedFocal.x.value = focalX - (layout.x.value / 2 + offset.x.value);
adjustedFocal.y.value = focalY - (layout.y.value / 2 + offset.y.value);
adjustedFocal.x.value = focalX - (contentSize.x.value / 2 + offset.x.value);
adjustedFocal.y.value = focalY - (contentSize.y.value / 2 + offset.y.value);
};

const getEdges = () => {
'worklet';
const edges = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } };

const newWidth = layout.x.value * scale.value;
let scaleOffsetX = (newWidth - layout.x.value) / 2;

edges.x.min = Math.round(
(newWidth - containerDimensions.width) * -1 + scaleOffsetX
);
edges.x.max = scaleOffsetX;
const newWidth = contentSize.x.value * scale.value;
let scaleOffsetX = (newWidth - contentSize.x.value) / 2;
if (newWidth > containerDimensions.width) {
edges.x.min = Math.round(
(newWidth - containerDimensions.width) * -1 + scaleOffsetX
);
edges.x.max = scaleOffsetX;
} else {
edges.x.min = scaleOffsetX;
edges.x.max = containerDimensions.width - newWidth + scaleOffsetX;
}

const newHeight = layout.y.value * scale.value;
let scaleOffsetY = (newHeight - layout.y.value) / 2;
edges.y.min = Math.round(
(newHeight - containerDimensions.height) * -1 + scaleOffsetY
);
edges.y.max = scaleOffsetY;
const newHeight = contentSize.y.value * scale.value;
let scaleOffsetY = (newHeight - contentSize.y.value) / 2;
if (newHeight > containerDimensions.height) {
edges.y.min = Math.round(
(newHeight - containerDimensions.height) * -1 + scaleOffsetY
);
edges.y.max = scaleOffsetY;
} else {
edges.y.min = scaleOffsetY;
edges.y.max = containerDimensions.height - newHeight + scaleOffsetY;
}
return edges;
};

Expand Down Expand Up @@ -218,16 +254,6 @@ export default function PanPinchView({

const gestures = Gesture.Race(panGesture, pinchGesture);

useEffect(() => {
animateToInitialState();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
containerDimensions.width,
containerDimensions.height,
contentDimensions.width,
contentDimensions.height,
]);

return (
<GestureHandlerRootView>
<GestureDetector gesture={gestures}>
Expand All @@ -244,8 +270,8 @@ export default function PanPinchView({
style={[
styles.content,
{
width: contentDimensions.width,
height: contentDimensions.height,
width: contentSize.x.value,
height: contentSize.y.value,
},
animatedStyle,
]}
Expand All @@ -256,7 +282,7 @@ export default function PanPinchView({
</GestureDetector>
</GestureHandlerRootView>
);
}
});

const styles = StyleSheet.create({
container: {
Expand Down

0 comments on commit 1c7a5dd

Please sign in to comment.