Custom Software Consulting & Development

1.347.674.6277
hello@rapidapplabs.com

React Native Experiments: Instagram Stories Effect

by Thomas Lackemann Posted on Dec 24, 2016 in

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.

Instagram Stories, as seen in Instagram

Source Code

This code is available on GitHub and as a React Native module via npm.

The Experiment

The effect is seemingly simple.

  • If the user drags the screen, the cube will rotate and reveal the next or previous image on the newly exposed face.
  • If the user stops dragging the cube, the cube resets to the original face.
  • If the user drags past a certain threshold (such as the midway point) and stops dragging, the cube will snap to the mostly exposed face.
  • If the user flicks the screen left, the cube will swiftly and smoothly rotate to the next image.
  • If the user flicks the screen right, the cube will swiftly and smoothly rotate to the previous image.

All of the criteria above can be done by taking over our view natively (iOS) and applying some fun math to the views.

Why Not JavaScript?

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.

Existing Solutions

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.

  • Some modules assumed the use of a 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.
  • Some modules were written 4-5 years ago which made the compiler complain a lot.
  • Some modules just had too many features and effects which seemed ridiculous to include if it wasn’t going to be utilized.

So we’ll write our own!

The Plan

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.

Setting Up

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

RNCubeTransition

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

RNCubeTransitionManager

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

JavaScript

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.

UIView

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.

Code

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:

  • It’s relatively inexpensive to do this
  • We don’t have to worry about indexing problems as we simply remove the screenshot subview when we’re done animating
  • The screenshot is static, so moving videos/gifs look like they would in Instagram’s effect.

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:

  • How far the current animation has gone?
  • Is the cube in a position to continue rotating?
  • Is the cube is a position to rotate back?

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.

Algebraic!

The logic that handles panning right is near identical to the above.

Conclusion

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!

Comments