Instagram has made it no secret they use React Native to power their application. In this experiment, we'll take a look at the Instagram Stories feature and how we can create the swiping cube visual effect using React Native and some clever Objective-C.
This code is available on GitHub and as a React Native module via npm.
The effect is seemingly simple.
All of the criteria above can be done by taking over our view natively (iOS) and applying some fun math to the views.
We absolutely can achieve this effect in JavaScript, but there's a good chance we'd experience some lag in our application.
Why? Glad you asked!
React gives us a way to handle gestures within JavaScript; however, it only does this by exposing the native gesture API via a bridge. When you register events meant for native code in JavaScript, there's a short latency period from the moment you fire that event to the moment it's actually registered natively. It's not much, but it's enough to be noticable by your users in this case since they will expect the cube rotation to look and feel fluid (there are perfectly good reasons for why not everything should be optimized this way.)
We gain a significant performance boost by handling all our rendering logic natively since the latency between Objective-C and JavaScript by ways of bridging is eliminated.
With that in mind, let's talk about how we'd like to implement this.
Note: This walk-through will assume you already understand React Native and have some familiarity with Objective-C.
There are a few "cube rotation" modules out there for iOS. Why should we reinvent the wheel if there are plenty of modules that do this already?
I ended up trying a lot of different ways to achieve this effect and there were a few problems I ran into while trying to plug in an existing module.
UIViewController
which I didn't think was a good idea to mess with
since React is using it's own root UIViewController
and we're simply modifying a UIView
.So we'll write our own!
We want to create a React component that we can fill with components such as <View />
and <Image />
,
much like the sample below.
<RNCubeTransition>
<Image source={require('./asset/image-1.jpg')} />
<Image source={require('./asset/image-2.jpg')} />
<View>
<Text>Views would be cool, wouldn't they?</Text>
</View>
</RNCubeTransition>
This component will be backed by a native component that we'll write to uniquely handle the children
of <RNCubeTransition />
in order manipulate them to achieve our desired effect.
To do this, we'll start by setting up a small React Native bridge.
Let's start by creating a new React Native project.
$ react-native init RNExperimentInstagram && cd RNExperimentInstagram
To start this effect, we'll first need to write the native module for the initial rendering of our components.
Open up your project in XCode and create 4 new files.
RNCubeTransitionManager.m
RNCubeTransitionManager.h
RNCubeTransition.m
RNCubeTransition.h
First define our RNCubeTransition
component.
// RNCubeTransition.h
#import <UIKit/UIKit.h>
@interface RNCubeTransition : UIView
@end
We'll simply do nothing for now.
// RNCubeTransition.m
#import <Foundation/Foundation.h>
#import "RNCubeTransition.h"
@interface RNCubeTransition()
@end
@implementation RNCubeTransition
- (instancetype)init {
if ((self = [super init])) {
// nothing for now
}
return self;
}
@end
Now we're going to need a manager to handle this view. Our manager is essentially the singleton that React talks to when interacting with your view(s).
Let's define the component.
// RNCubeTransitionManager.h
#import "RCTViewManager.h"
@interface RNCubeTransitionManager : RCTViewManager
@end
Export the module for use in JavaScript and have it include our RNCubeTransition
view.
// RNCubeTransitionManager.m
#import <Foundation/Foundation.h>
#import "RNCubeTransitionManager.h"
#import "RNCubeTransition.h"
@implementation RNCubeTransitionManager
RCT_EXPORT_MODULE()
- (UIView *)view {
return [[RNCubeTransition alloc] init];
}
@end
Nothing too crazy so far. Let's create a JavaScript component so we can start using our native
code. For the sake of simplicity, I've written everything within index.ios.js
(but feel free to
organize however you'd like.)
// index.ios.js
import React, { Component, PropTypes } from 'react';
import {
AppRegistry,
Dimensions,
Image,
StyleSheet,
Text,
View,
requireNativeComponent,
} from 'react-native';
const RNCubeTransition = requireNativeComponent('RNCubeTransition', null);
export default class RNTransitionExample extends Component {
render() {
return (
<View style={styles.container}>
<RNCubeTransition style={styles.page}>
<Image
source={require('./assets/test-1.jpg')}
style={styles.face}
/>
<Image
source={require('./assets/test-2.jpg')}
style={styles.face}
/>
<View style={styles.face}>
<Text>and a view for good measure</Text>
</View>
</RNCubeTransition>
</View>
);
}
}
const { width, height } = Dimensions.get('window');
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
page: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
overflow: 'hidden',
justifyContent: 'flex-start',
alignItems: 'flex-start',
flexDirection: 'row',
},
face: {
width,
height,
resizeMode: 'stretch',
}
});
AppRegistry.registerComponent('RNTransitionExample', () => RNTransitionExample);
Great! At this point if we compile and run, we should have an application that shows a single image. Our native code is doing nothing but simply letting React do it's job. Time to shake that up a bit.
If you're not familiar with iOS development, it's worth taking a moment to talk about how React Native works under the hood.
<RNCubeTransition />
and each of the children we passed to it gets represented natively as a UIView
(with the children simply being "subviews" of a the UIView
RNCubeTransition
.)
This is great because we can leverage all of the powerful functionality outlined in the
Apple Developer API.
We're going to manipulate subviews heavily in this experiment. Whether or not this is ideal is a little beyond me but I'm highly responsive to good feedback and am happy to correct any mispresentations.
The source code is a bit long so you can view the rest of RNCubeTransition.m on GitHub to follow along.
- (instancetype)init {
if ((self = [super init])) {
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
[pan setMinimumNumberOfTouches:1];
[pan setMaximumNumberOfTouches:1];
[self addGestureRecognizer:pan];
self.initialized = false;
self.currentIndex = 0;
self.nextIndex = 0;
self.snap = false;
}
return self;
}
We first need to setup our gestures, in this case we'll setup a UIPanGestureRecognizer
to listen
for events while a user is panning (swiping.) We'll attach this to a function called handlePan
that we'll create later on in the code to setup and dismantle animations/subviews.
- (void)layoutSubviews {
if (!self.initialized) {
self.numberOfFaces = [self.subviews count];
self.initialized = true;
}
}
We need to know how many sides to the cube we're instantiating with. The method layoutSubviews
gets called at .. well, interesting times
but it's important because our init
method does not expose to us the number of subviews.
I set a flag here to simply set the number of faces once when we have the subviews available.
// Handle the pan gesture to rotate the cube
- (void)handlePan:(UIPanGestureRecognizer *)pan {
Now we get into the meat of the code, handlePan
. Most of this section is commented so I'll skim
to the good bits.
// Moving left
if (translation.x < 0) {
self.nextIndex = 0;
// Get the next subview in line
if (self.currentIndex + 1 < self.numberOfFaces) {
self.nextIndex = self.currentIndex + 1;
}
if (pan.state == UIGestureRecognizerStateBegan) {
// Take a screenshot of the next face on first gesture
self.nextSubview = [self.subviews objectAtIndex:self.nextIndex];
self.nextScreenshot = [self.nextSubview snapshotViewAfterScreenUpdates:NO];
// Start the animation
[CATransaction begin];
[self addSubview:self.nextScreenshot];
self.animation = [CATransition animation];
self.animation.duration = 1.0;
[self.animation setType:@"cube"];
[self.animation setSubtype:kCATransitionFromRight];
self.layer.speed = 0.0;
[[self layer] addAnimation:self.animation forKey:@"cube"];
[CATransaction commit];
self.panning = true;
}
While moving left, the event UIGestureRecognizerStateBegan
tells us the very first instance the user started
panning the screen, so we'll take the opportunity to setup our animation to transition to the next
cube. The animation was the trickiest part to figure out and here's how I understand it.
Animations in iOS implicitly call [CATransaction]
meaning any animation you add to a
UIView
layer will be handled by that animation, not ideal in many cases. In our case we simply
want to animate from one cube face to the next using the face we're currently on and the
nextSubview
. We call [CATransaction begin]
and [CATransaction commit]
to tell the system
that the procedures between those blocks are are the only things we want to animate at the moment.
We set an animation speed of 0
so we can modify the transition manually (by panning.)
Cool, so we setup a transaction for animations. But why are we taking a screenshot of the next view?!
Good question. We can't simply transition to nextSubview
as if we added nextSubview
as
a subview of our current view, we'd have duplicate subviews and that causes a lot of problems
when it comes to iterating over our subview index. So we do something interesting here, we take
a screenshot of the next view in line and push that onto the subview array to be animated.
This works for a couple of reasons:
Overall it works just fine.
// pan the cube
if (self.panning) {
self.layer.timeOffset = fabs(translation.x) / self.frame.size.width;
}
Now with our animation setup and our screenshot taken and appended, we rotate the cube by applying "time" to our animation. Here, time is simply the interval (0..1) between amount of movement and the total screen width.
// Once we stop the gesture, fulfill the animation
// Check to make sure we were panning though because sometimes we fail to take a screenshot
// and that makes it look bad
if (pan.state == UIGestureRecognizerStateEnded && self.panning) {
// Continue with the animation
[self.layer removeAllAnimations];
[self.nextScreenshot removeFromSuperview];
self.layer.speed = 1.0;
[CATransaction begin];
self.animation = [CATransition animation];
self.animation.duration = 1.0;
// If we're past a certain time, just move forward
if (self.layer.timeOffset >= 0.5) {
self.snap = YES;
self.animation.speed = -0.75;
self.animation.beginTime = CACurrentMediaTime() + ((self.layer.timeOffset - 1.0) * 1.25);
} else {
self.animation.speed = 0.75;
self.animation.beginTime = CACurrentMediaTime() - ((1.0 - self.layer.timeOffset) * 1.25);
}
self.animation.fillMode = kCAFillModeForwards;
self.animation.removedOnCompletion = NO; // prevents image from flickering
[self.animation setType:@"cube"];
[self.animation setSubtype:kCATransitionFromLeft];
More animation trickery.
The first thing we do is stop the initial animation we setup before. We then remove the
nextScreenshot
as a subview to prevent any weird glitchyness. Here's why:
Remember when I said [CATransaction begin]
is called whenever animations are applied even if you
don't specify it? Here's an example of why this matters. We remove the nextScreenshot
before
calling the animation transaction because if we didn't and removed it within the transaction, we'd
actually be animating the removal of this subview and not what we actually want to animate (the rest
of what we started.)
When the user stops panning, we need to factor in:
We account for this information by applying an almost similar animation to the subviews but this
time setting a layer speed of 1.0
and an animation speed of 0.75
(to make it look a little smoother).
Our layer speed is what gave us full control of the animation before so by setting it to 1.0
we
ensure that the animation plays on it's own (we don't want our user to control it anymore since
they're done panning.)
[CATransaction setCompletionBlock:^{
[self.layer removeAllAnimations];
[self.nextScreenshot removeFromSuperview];
if (self.snap == YES) {
// Move the next image/view into place
CGRect currentSubviewOffsetFrame = [[_currentSubview.layer presentationLayer] frame];
currentSubviewOffsetFrame.origin.x = -1 * _currentSubview.bounds.size.width * (self.currentIndex + 1);
_currentSubview.frame = currentSubviewOffsetFrame;
CGRect nextSubviewOffsetFrame = [[self.nextSubview.layer presentationLayer] frame];
nextSubviewOffsetFrame.origin.x = 0;
self.nextSubview.frame = nextSubviewOffsetFrame;
self.currentIndex = self.nextIndex;
}
self.snap = NO;
}];
[[self layer] addAnimation:self.animation forKey:@"cube"];
[CATransaction commit];
self.panning = false;
}
When the animation is complete and we've rotated, remove the animation (since we explicitly told
our animation to not removeOnCompletion
), remove the screenshot we took, and move the current
subview out of position and the next subview into position. This is all invisible to the user
and is a bit of a hack that happens to work.
The logic that handles panning right is near identical to the above.
I hope you learned a little about how you can use React Native and some powerful iOS programming to create really neat effects.
If you enjoyed this article and want to hear more about these types of experiments, follow me on Twitter, share the article, and stay tuned!
Cheers!
Tom is the founder of Astral TableTop. He's a homebrewer, hiker, and has an about page. Follow @tlackemann on Twitter for more discussions like this.