React Native UI Challenge: Building Instagram Zoom-Draggable Photo

Entering React and React Native world has been a fun yet challenging journey for me. The more I learn, the more I’m interested in building awesome cross-platform apps, which also leads me to venture into parts of UI I never dared to touch before such as Animation.

In most of my previous projects, I’ve used Animated API from React Native for simple declarative animations, but never actually implemented complex gesture-based animations. So, in order to learn gestures and animation even more deeply, I decided to challenge myself to build a prototype of Instagram’s photos, which you can zoom and drag around directly from the feed. It’s a fairly complex challenge because the photo needs to be promoted to it’s own layer above the other content in real-time. I will share how I used PanResponder and Animated API from React Native to build this feature. You can check the working Expo demo here.

Note: if you haven’t used Animated API or PanResponder before, I would suggest you to check out Michał Chudziak’s posts https://blog.callstack.io/react-native-animations-revisited-part-ii-8314a97162b0 and https://blog.callstack.io/react-native-animations-revisited-part-iii-41ed43d1ce2e. If you have a premium account in egghead, you can also hit up these lessons by Jason Brown https://egghead.io/courses/animate-react-native-ui-elements.

Okay first, let’s take a look at the feature we want to create.

  1. Instagram consists of photos in a feed that you can scroll through.
  2. Each photo will only be zoomable and draggable if there are two touches.
  3. With two fingers pressed, we can zoom and drag the photo around.
  4. When a touch is released it will animate back to the original size and position.

Alright! Now we understand the behavior we want to achieve, we can divide our development into five steps.

  1. Show a list of photos and make each one pressable. When one is pressed, render a copy of the photo in a new layer (selected photo) with exact same location and size. This should happen without any noticeable change or flicker.
  2. Allow the selected photo to be dragged
  3. Allow the selected photo to be zoomed if there are two touches happening.
  4. Animate the selected photo back to the original photo location and size upon release.
  5. Fit and finish

For the sake of simplicity, we will replace each photo in the list with a red square box and the selected photo with a blue square box. Let’s get coding!

Show List of Photos and Render Selected Photo

In order to render a zoomable photo in a layer on top of the original photo, we need to get the coordinates of the photo in the list and calculate it’s position on screen.

Instagram Photo (source: https://www.instagram.com/?hl=en)

Let’s start with creating a helper function called measureNode that uses UIManager from React Native to measure layout relative to its parent.

// @flow
import {UIManager} from 'react-native';
export default function measureNode(node: ?number) {
return new Promise((resolve, reject) => {
UIManager.measureLayoutRelativeToParent(
node,
(e) => reject(e),
(x, y, w, h) => {
resolve({x, y, w, h});
}
);
});
}

Time to render the photos! Each list item consists of a header and a red box, representing the actual photo, with full width and some height (we will ignore the like and comment features). We will wrap the red box in a touchable component so we can listen for onPress event and render the blue box in the correct position. We'll need a measurement function to get the width, height and also x/y coordinates of the photo relative to its container (and the container relative to the parent component). The x/y coordinates will be used to find the exact location of the selected photo relative to the list.

 async _measureSelectedPhoto() {
let parent = ReactNative.findNodeHandle(this._parent);
let photoComponent = ReactNative
.findNodeHandle(this._photoComponent);
    let [parentMeasurement, photoMeasurement] = await Promise.all([
measureNode(parent),
measureNode(photoComponent),
]);
    return {
x: photoMeasurement.x,
y: parentMeasurement.y + photoMeasurement.y,
w: photoMeasurement.w,
h: photoMeasurement.h,
};
}

Okay, now we have the measurement and coordinates of the selected photo relative to the list. But, what happens if the user scrolls? To render correctly on the screen, we need the photo’s coordinates relative to the root element, not just the list. We need to track the scroll position by using Animated.event and Animated.Value. Using coordinates and scroll position, we can render the selected photo in the correct place even when the list has been scrolled.

export default class App extends React.Component {
constructor() {
super(...arguments);
this.scrollValue = new Animated.Value(0);
this.state = {};
}
render() {
let {selectedPhotoMeasurement} = this.state;
let onScroll = Animated.event([
{nativeEvent: {contentOffset: {y: this.scrollValue}}},
]);
return (
<View style={styles.container}>
<ScrollView scrollEventThrottle={16} onScroll={onScroll}>
{photos.map((photo, key) => {
return (
<Photo
key={key}
photo={photo}
onPress={(measurement: Measurement) => {
this.setState(
{selectedPhotoMeasurement: measurement}
);
}}
/>
);
})}
</ScrollView>
{selectedPhotoMeasurement
? <View
style={{
position: 'absolute',
zIndex: 10,
width: selectedPhotoMeasurement.w,
height: selectedPhotoMeasurement.h,
backgroundColor: 'blue',
transform: [
{
translateY:
selectedPhotoMeasurement.y -
this.scrollValue.__getValue(),
},
],
}}
/>
: null}
</View>
);
}
}

Great! That covers our first step of development.

working demo step one

Draggable Photo

The next step is to allow the blue box to be dragged around. Now things will get a little complicated, so fasten your seat belts! First, we’ll need to render the SelectedPhoto component outside the list (preferably in the root element) and pass an object to the children representing the measurement and scroll values.

// App render function
{selectedPhotoMeasurement ?
<SelectedPhoto
selectedPhotoMeasurement={selectedPhotoMeasurement}
scrollValue={{y: this.scrollValue.__getValue()}}
/>
: null
}

In the SelectedPhoto component, let’s create a gesture handler with PanResponder.create() and gesture position with Animated.ValueXY. There are four functions that we need define as shown in the code below. onStartShouldSetPanResponder and onMoveShouldSetPanResponder basically allow the component to receive the gesture, onPanResponderGrant will be triggered when the touch has been granted, onPanResponderMove will be triggered every time a finger is moved while a gesture is happening, and onPanResponderRelease will be triggered once the last touch has been released. Because we only want to achieve drag feature in this step, we can simply use Animated.event in onPanResponderMove which will put the dx and dy value into our Animated.ValueXY whenever it changes.

// SelectedPhoto.js
_generatePanHandlers(selectedPhotoMeasurement, scrollValue) {
this.gestureHandler = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
this.gesturePosition.setOffset({
x: 0,
y: selectedPhotoMeasurement.y - scrollValue.y,
});
        // set initial gesture value
this.gesturePosition.setValue({
x: 0,
y: 0,
});
this.setState({isDragging: true});
},
onPanResponderMove: Animated.event([
null,
{dx: this.gesturePosition.x, dy: this.gesturePosition.y},
]),
onPanResponderRelease: () => {
this.setState({isDragging: false});
},
});
}

We will implement the gesture handler on our blue box with Animated.View. For calculating the translation, Animated.ValueXY provides us with an awesome function that can do it automatically, which is getTranslateTransform(). We also set the initial position with the same formula in the first step so whenever the isDragging state change to false, it will come back to its original position.

// SelectedPhoto.js
render() {
let {isDragging} = this.state;
let {selectedPhotoMeasurement, scrollValue} = this.props;
    let animatedStyle = {
transform: this.gesturePosition.getTranslateTransform(),
};
    let initialStyle = {
transform: [
{translateY: selectedPhotoMeasurement.y - scrollValue.y}
],
};
    let style = [
{
position: 'absolute',
zIndex: 10,
width: selectedPhotoMeasurement.w,
height: selectedPhotoMeasurement.h,
backgroundColor: 'blue',
},
isDragging ? animatedStyle : initialStyle,
];
    return (
<Animated.View
style={style}
{...this.gestureHandler.panHandlers}
/>
);
}

Voila! We have successfully created a draggable view.

working demo step two

Scalable Photo

This step comes with two challenges. First, we need to determine how to get the distance between touches, and second, we need a formula to determine the new scale based on that distance. To get the distance, I took a reference from https://github.com/keske/react-native-easy-gestures and implemented a simple formula. Okay, let’s start with creating a new Animated.Value called scaleValue and add it in our style.

// SelectedPhoto.js render function
let animatedStyle = {
transform: this.gesturePosition.getTranslateTransform(),
};
animatedStyle.transform.push({
scale: this.scaleValue,
});

Then, we’ll create a getDistance helper function to get the distance between two touches.

// getDistance.js
export function pow2abs(a: number, b: number) {
return Math.pow(Math.abs(a - b), 2);
}
function getDistance(touches: Array<Touch>) {
const [a, b] = touches;

if (a == null || b == null) {
return 0;
}
  return Math.sqrt(
pow2abs(a.pageX, b.pageX) + pow2abs(a.pageY, b.pageY)
);
}
export default getDistance;

Ok, first challenge solved. Now we need a way to calculate the new scale of the image based on the calculated distance. The way we will calculate the new scale value is by dividing the current touch distance by the initial distance and multiply it by a constant scale multiplier to “magnify” the scaling effect.

// getScale.js
const SCALE_MULTIPLIER = 1.2;
export default function getScale(
currentDistance: number,
initialDistance: number
) {
return currentDistance / initialDistance * SCALE_MULTIPLIER;
}

Initial touch will be retrieved in onPanResponderGrant and current touch will be retrieved in onPanResponderMove (remember! You can only do this if the number of touches is ≥ 2). Because we need more complex logic in our onPanResponderMove, we need to pull it of from Animated.event and create our own function.

onPanResponderGrant: (event, gestureState) => {
this.gesturePosition.setOffset({
x: 0,
y: selectedPhotoMeasurement.y - scrollValue.y,
});
this.gesturePosition.setValue({
x: 0,
y: 0,
}); // to clear animation

this.setState({isDragging: true});
   // set initial touches
this._initialTouches = event.nativeEvent.touches;
},
onPanResponderMove: this._onGestureMove,
...
_onGestureMove(event: Event, gestureState: GestureState) {
let {touches} = event.nativeEvent;
if (touches.length < 2) {
// Trigger a realease
this._onGestureRelease(event, gestureState);
return;
}
   // for moving photo around
let {gesturePosition, scaleValue} = this;
let {dx, dy} = gestureState;
gesturePosition.x.setValue(dx);
gesturePosition.y.setValue(dy);
    // for scaling photo
let currentDistance = getDistance(touches);
let initialDistance = getDistance(this._initialTouches);
let newScale = getScale(currentDistance, initialDistance);
scaleValue.setValue(newScale);
}

Alright! Mission accomplished, we’ve finished our third step.

working demo step three

Set to Original Size and Location on Gesture Release

We have covered the main functions of our prototype Yay! Now, let’s add a little “wow” factor to it. Currently, when gesture release happens, it will snap back to its initial size and position. Surely we want to add an animation for that, right? In order to transform the gesture position and scale value back to its initial value, we’ll use Animated.timing (this is just my preference, but you can use any animation you want). Also, because we want all the animation to happen at the same time, we need to combine it using Animated.parallel. Don’t forget to set gesture position offset to the selected photo’s position and change isDragging state back to false after the animation ends.

onPanResponderRelease: this._onGestureRelease,
...
_onGestureRelease(event, gestureState) {
let {selectedPhotoMeasurement, scrollValue} = this.props;
  Animated.parallel([
Animated.timing(this.gesturePosition.x, {
toValue: 0,
duration: RESTORE_ANIMATION_DURATION,
easing: Easing.ease,
}),
Animated.timing(this.gesturePosition.y, {
toValue: 0,
duration: RESTORE_ANIMATION_DURATION,
easing: Easing.ease,
}),
Animated.timing(this.scaleValue, {
toValue: 1,
duration: RESTORE_ANIMATION_DURATION,
easing: Easing.ease,
}),
]).start(() => {
this.gesturePosition.setOffset({
x: 0,
y: selectedPhotoMeasurement.y - scrollValue.y,
});
this.setState({isDragging: false});
this._initialTouches = [];
});
}
working demo step four

Connect the Gesture Handler between Photo and Selected Photo

Currently, all the dragging and scaling happens after we tap the photo (red box) and then drag the selected photo (blue box). If we check Instagram, a user can directly zoom and drag the photo without first tapping it. So, let’s make that happen! We’ll need to move all the gesture and scaling logic out from the selected photo and put it inside the Photo component as we will handle the gesture in Photo but apply the transformation in SelectedPhoto. To connect the transformation values between two different components, I decided to put the gesture position, scale, and scroll value into App.js and use context to pass it down to the components without explicitly using props. Using this approach, we'll also avoid re-rendering the components each time the transformation values change. That’s the beauty of Animated API!

export default class App extends React.Component {
state: State;
_scrollValue: Animated.Value;
_scaleValue: Animated.Value;
_gesturePosition: Animated.ValueXY;
...
static childContextTypes = {
gesturePosition: PropTypes.object,
getScrollValue: PropTypes.func,
scaleValue: PropTypes.object,
};
getChildContext() {
return {
gesturePosition: this._gesturePosition,
scaleValue: this._scaleValue,
getScrollValue: () => {
return this._scrollValue.__getValue();
},
};
}
...
}

Fit and Finish

Alright! We’re almost there. Let’s add some styling and change the red and blue boxes to actual photos. I used react-native-elements which provides some predefined components such as ListItem which I used for the photo’s header. To render a full-width photo in the list without defining the height and width, I used react-native-flex-image library (if you don't already know about it, totally check it out!).

Let’s add some details. We can set the opacity of the photo in the list to zero while the selected photo is being dragged around so it will feel like it’s being “pulled out” from the list and placed in a layer on top of everything else. As we already use Animated API in many places, let’s stick with it to change the opacity of the photo. You can use Animated.timing or simply call setValue(newOpacityValue) to change the opacity value.

// Photo.js
constructor() {
...
this._opacity = new Animated.Value(1);
}
render() {
return (
<View ref={(parentNode) => (this._parent = parentNode)}>
<View>
<ListItem
roundAvatar
avatar={{uri: data.avatar.uri}}
title={`${data.name}`}
subtitle="example of subtitle"
rightIcon={{name: 'more-vert'}}
/>
</View>
<Animated.View
ref={(node) => (this._photoComponent = node)}
{...this._gestureHandler.panHandlers}
style={{opacity: this._opacity}}
>
<FlexImage source={{uri: data.photo.uri}} />
</Animated.View>
</View>
);
}

We can also add a semi-transparant overlay behind the selected photo which should become darker as the photo is being zoomed. For this, we can use interpolate.

// SelectedPhoto render function
let {scaleValue} = this.context;
let backgroundOpacityValue = scaleValue.interpolate({
inputRange: [1.2, 3],
outputRange: [0, 0.6],
});
return (
<View style={styles.root}>
<Animated.View
style={[
styles.background,
{
opacity: backgroundOpacityValue,
},
]}
/>
<Animated.Image
style={imageStyle}
onLoad={() => this.setState({isLoaded: true})}
source={{
uri: selectedPhoto.photoURI,
}}
/>
</View>
);

Now we have a properly working Instagram photo viewer!

You can check the full source code here.

Side Note

When building this prototype, I faced an issue that occasionally makes the app seem like it crashes without any error. It seemed to happen with very fast gestures. It would just freeze and stop responding to touches. Turns out the scrollable list component was taking control of the gesture while the drag was in progress. This made the gesture responder stop emitting move events but scrolling would remain disabled since the onPanResponderRelease was never triggered. After digging into the docs, I found that PanResponder provides another lifecycle method, which is onPanResponderTerminate and onPanResponderTerminationRequest. The latter will be triggered if another component wants to override the gesture handler that currently happening and onPanResponderTerminate will happen afterward. So I made onPanResponderTerminationRequest return false if the gesture is still happening and made onPanResponderTerminate run our _onGestureRelease. One important note for android, based on this discussion, is onPanResponderTerminationRequest won’t be triggered as it will directly run onPanResponderTerminate.

onPanResponderTerminationRequest: () => {
return this._gestureInProgress == null;
},
onPanResponderTerminate: (event, gestureState) => {
// the same function in onPanResponderRelease
return this._onGestureRelease(event, gestureState);
},

Wrapping Up

It’s lot of fun to do UI challenges like this. Not only to learn new things, but also to try building impressive UI behaviors from popular apps. There are some improvements that can be done in the future, one of them is to keep the zooming in the center of the initial touches which I haven’t covered here. This is my first post and I’m planning to do this kind of challenge more often and share my experience as I go. I hope you enjoy it!


Special thanks to Simon Sturmer for the advice while I was doing the coding and for making sure that this post can be understood not only by me. Follow me on Twitter and Github.