This tutorial will guide you through setting up a new React Native iOS project and configuring it so that you can use ClojureScript with Om for implementation.
Given the nascent state of the libraries and tooling, this tutorial hopefully helps you develop and understanding of the rational behind the approach, which is still evolving.
If you'd like to quickly set up a new project without manually going through the steps below, check out Natal by Dan Motzenbecker. With Natal installed, getting things up and running is as simple as
natal AwesomeProject
.
Set up a top-level directory which will contain the React Native project as well as the ClojureScript project.
mkdir Awesome
cd Awesome
Set up AwesomeProject
using
react-native init AwesomeProject
as described at React Native Getting Started, but don't yet start Xcode or edit any files.Next, we will set things up so that the Ambly iOS ClojureScript REPL can be used for development with this project. To do this, we will use CocoaPods.
Go into the AwesomeProject/iOS
directory and create a Podfile
file with the following contents:
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
pod "Ambly", "~> 0.6.0"
With this Podfile
in place, run
pod install
in the AwesomeProject/iOS
directory. When this completes, open AwesomeProject.xcworkspace
. You can do this withopen AwesomeProject.xcworkspace
Now, within Xcode, navigate to the Build Settings for the AwesomeProject target and find Other Linker Flags. It should currently be set to-ObjC
Revise this so that it is instead set to${inherited}
The React Native setup script creates an AppDelegate
implementation that loads React Native. Replace the contents of AppDelegate.m
with the code below. This is essentially the same as the file generated by react-native init
, but with additional initialization in order to bring up ClojureScript and set up the Ambly REPL. This code will probably be factored out into reusable bits in the future, but since things are evolving rapidly, it is all just dumped into AppDelegate
for now.
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "AppDelegate.h"
#import "RCTRootView.h"
#import "RCTEventDispatcher.h"
#import "ABYServer.h"
#import "ABYContextManager.h"
#import "RCTContextExecutor.h"
/**
This class exists so that a client-created `JSGlobalContextRef`
instance and optional JavaScript thread can be injected
into an `RCTContextExecutor`.
*/
@interface ABYContextExecutor : RCTContextExecutor
/**
Sets the JavaScript thread that will be used when `init`ing
an instance of this class. If not set, `[NSThread mainThread]`
will be used.
@param thread the thread
*/
+(void) setJavaScriptThread:(NSThread*)thread;
/**
Sets the context that will be used when `init`ing an instance
of this class.
@param context the context
*/
+(void) setContext:(JSGlobalContextRef)context;
@end
static NSThread* staticJavaScriptThread = nil;
static JSGlobalContextRef staticContext;
@implementation ABYContextExecutor
RCT_EXPORT_MODULE()
- (instancetype)init
{
id me = [self initWithJavaScriptThread:(staticJavaScriptThread ? staticJavaScriptThread : [NSThread mainThread])
globalContextRef:staticContext];
staticJavaScriptThread = nil;
JSGlobalContextRelease(staticContext);
return me;
}
+(void) setJavaScriptThread:(NSThread*)thread
{
staticJavaScriptThread = thread;
}
+(void) setContext:(JSGlobalContextRef)context
{
staticContext = JSGlobalContextRetain(context);
}
@end
@interface AppDelegate()
@property (strong, nonatomic) ABYServer* replServer;
@property (strong, nonatomic) ABYContextManager* contextManager;
@property (strong, nonatomic) NSURL* compilerOutputDirectory;
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSURL *jsCodeLocation;
/**
* Loading JavaScript code - uncomment the one you want.
*
* OPTION 1
* Load from development server. Start the server from the repository root:
*
* $ npm start
*
* To run on device, change `localhost` to the IP address of your computer
* (you can get this by typing `ifconfig` into the terminal and selecting the
* `inet` value under `en0:`) and make sure your computer and iOS device are
* on the same Wi-Fi network.
*/
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle"];
/**
* OPTION 2
* Load from pre-bundled file on disk. To re-generate the static bundle
* from the root of your project directory, run
*
* $ react-native bundle --minify
*
* see http://facebook.github.io/react-native/docs/runningondevice.html
*/
// jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
// Set up the ClojureScript compiler output directory
self.compilerOutputDirectory = [[self privateDocumentsDirectory] URLByAppendingPathComponent:@"cljs-out"];
// Set up our context manager
self.contextManager = [[ABYContextManager alloc] initWithContext:JSGlobalContextCreate(NULL)
compilerOutputDirectory:self.compilerOutputDirectory];
// Inject our context using ABYContextExecutor
[ABYContextExecutor setContext:self.contextManager.context];
// Set React Native to intstantiate our ABYContextExecutor, doing this by slipping the executorClass
// assignement between alloc and initWithBundleURL:moduleProvider:launchOptions:
RCTBridge *bridge = [RCTBridge alloc];
bridge.executorClass = [ABYContextExecutor class];
bridge = [bridge initWithBundleURL:jsCodeLocation
moduleProvider:nil
launchOptions:launchOptions];
// Set up a root view using the bridge defined above
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:@"AwesomeProject"
initialProperties:nil];
// Set up to be notified when the React Native UI is up
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(contentDidAppear)
name:RCTContentDidAppearNotification
object:rootView];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [[UIViewController alloc] init];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
return YES;
}
- (NSURL *)privateDocumentsDirectory
{
NSURL *libraryDirectory = [[[NSFileManager defaultManager] URLsForDirectory:NSLibraryDirectory inDomains:NSUserDomainMask] lastObject];
return [libraryDirectory URLByAppendingPathComponent:@"Private Documents"];
}
- (void)createDirectoriesUpTo:(NSURL*)directory
{
if (![[NSFileManager defaultManager] fileExistsAtPath:[directory path]]) {
NSError *error = nil;
if (![[NSFileManager defaultManager] createDirectoryAtPath:[directory path]
withIntermediateDirectories:YES
attributes:nil
error:&error]) {
NSLog(@"Can't create directory %@ [%@]", [directory path], error);
abort();
}
}
}
-(void)requireAppNamespaces:(JSContext*)context
{
[context evaluateScript:[NSString stringWithFormat:@"goog.require('%@');", [self munge:@"awesome-project.core"]]];
}
- (JSValue*)getValue:(NSString*)name inNamespace:(NSString*)namespace fromContext:(JSContext*)context
{
JSValue* namespaceValue = nil;
for (NSString* namespaceElement in [namespace componentsSeparatedByString: @"."]) {
if (namespaceValue) {
namespaceValue = namespaceValue[[self munge:namespaceElement]];
} else {
namespaceValue = context[[self munge:namespaceElement]];
}
}
return namespaceValue[[self munge:name]];
}
- (NSString*)munge:(NSString*)s
{
return [[[s stringByReplacingOccurrencesOfString:@"-" withString:@"_"]
stringByReplacingOccurrencesOfString:@"!" withString:@"_BANG_"]
stringByReplacingOccurrencesOfString:@"?" withString:@"_QMARK_"];
}
- (void)contentDidAppear
{
// Ensure private documents directory exists
[self createDirectoriesUpTo:[self privateDocumentsDirectory]];
// Copy resources from bundle "out" to compilerOutputDirectory
NSFileManager* fileManager = [NSFileManager defaultManager];
fileManager.delegate = self;
// First blow away old compiler output directory
[fileManager removeItemAtPath:self.compilerOutputDirectory.path error:nil];
// Copy files from bundle to compiler output driectory
NSString *outPath = [[NSBundle mainBundle] pathForResource:@"out" ofType:nil];
[fileManager copyItemAtPath:outPath toPath:self.compilerOutputDirectory.path error:nil];
[self.contextManager setUpAmblyImportScript];
NSString* mainJsFilePath = [[self.compilerOutputDirectory URLByAppendingPathComponent:@"main" isDirectory:NO] URLByAppendingPathExtension:@"js"].path;
NSURL* googDirectory = [self.compilerOutputDirectory URLByAppendingPathComponent:@"goog"];
[self.contextManager bootstrapWithDepsFilePath:mainJsFilePath
googBasePath:[[googDirectory URLByAppendingPathComponent:@"base" isDirectory:NO] URLByAppendingPathExtension:@"js"].path];
JSContext* context = [JSContext contextWithJSGlobalContextRef:self.contextManager.context];
[self requireAppNamespaces:context];
JSValue* initFn = [self getValue:@"init" inNamespace:@"awesome-project.core" fromContext:context];
NSAssert(!initFn.isUndefined, @"Could not find the app init function");
[initFn callWithArguments:@[]];
// Send a nonsense UI event to cause React Native to load our Om UI
RCTRootView* rootView = (RCTRootView*)self.window.rootViewController.view;
[rootView.bridge.modules[@"RCTEventDispatcher"] sendInputEventWithName:@"dummy" body:@{@"target": @1}];
// Now that React Native has been initialized, fire up our REPL server
self.replServer = [[ABYServer alloc] initWithContext:self.contextManager.context
compilerOutputDirectory:self.compilerOutputDirectory];
[self.replServer startListening];
}
@end
The code above expects AppDelegate
to satisfy the NSFileManagerDelegate
protocol, so modify AppDelegate.h
to add that:
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import <UIKit/UIKit.h>
@interface AppDelegate : UIResponder <UIApplicationDelegate, NSFileManagerDelegate>
@property (nonatomic, strong) UIWindow *window;
@end
Within the Awesome
directory do:
lein new awesome-project
Go into the resulting awesome-project
directory, and edit the project.clj
file. Change the dependencies to be:
:dependencies [[org.clojure/clojure "1.7.0"]
[org.clojure/clojurescript "0.0-3308"]
[org.omcljs/om "0.8.8"]
[org.omcljs/ambly "0.6.0"]]
Also add cljsbuild
support:
:plugins [[lein-cljsbuild "1.0.6"]]
:cljsbuild {:builds {:dev {:source-paths ["src"]
:compiler {:output-to "target/out/main.js"
:output-dir "target/out"
:optimizations :none}}}}
Rename src/awesome_project/core.clj
to be src/awesome_project/core.cljs
and replace its contents with a simple "Hello World!"-style Om UI:
;; Need to set js/React first so that Om can load
(set! js/React (js/require "react-native/Libraries/react-native/react-native.js"))
(ns awesome-project.core
(:require [om.core :as om]))
;; Reset js/React back as the form above loads in an different React
(set! js/React (js/require "react-native/Libraries/react-native/react-native.js"))
;; Setup some methods to help create React Native elements
(defn view [opts & children]
(apply js/React.createElement js/React.View (clj->js opts) children))
(defn text [opts & children]
(apply js/React.createElement js/React.Text (clj->js opts) children))
;; Set up our Om UI
(defonce app-state (atom {:text "Hello from ClojureScript!"}))
(defn widget [data owner]
(reify
om/IRender
(render [this]
(view {:style {:flexDirection "row" :margin 40 :backgroundColor "cyan"}}
(text nil (:text data))))))
(om/root widget app-state {:target 1})
(defn ^:export init []
((fn render []
(.requestAnimationFrame js/window render))))
The ClojureScript compiler doesn't yet have the support needed to consume the CommonJS and JSX artifacts that are used by React Native, so we instead rely on the app using the React Native packager. Owing to subtle issues with the way Om then loads, we have the code above perform a trick where we set js/React
, load Om, and then set it back to the one being used by React Native. This odd approach will likely go away as the ClosureScript compiler gains the ability to directly consume the artifacts, but this gets us by for now.
Now build the project using:
lein cljsbuild once dev
This will cause the ClojureScript compiler to emit its JavaScript into target/out
. To bundle this output with your app, using the OS X Finder, drag the target/out
directory into the AwesomeProject
tree in the Xcode project navigator. You can drop it anywhere; adjacent to the existing main.jsbundle
is fine. Make sure Copy items if needed is not checked, and that Create folder references is chosen.
Run the project in Xcode on one of the simulators. You should see the "AwesomeProject" launch screen, briefly followed by a screen displaying "Welcome to React Native!", followed by a screen indicating "Hello from ClojureScript!"
The intermediary "Welcome to React Native!" screen exists because of the state of ClojureScript's support. We are using the React Native packager, replacing the root app with our own. In the future we will be able to eliminate this completely.
But for now, edit Awesome/AwesomeProject/index.ios.js
file so that, while this interim view is created, it just looks like a temporary white screen:
var AwesomeProject = React.createClass({
render: function() {
return (
<View/>
);
}
});
You can also delete the styles while in there; they won't be used.
With this, restart your app in Xcode, and from the user's perspective you should see it transition from the launch image to your ClojureScript app.
Start up the Clojure REPL by running the following from within the Awesome/awesome-project
directory:
lein repl
Once the REPL starts, launch the Ambly REPL by evaluating these two forms:(require
'[cljs.repl :as repl]
'[ambly.core :as ambly])
(repl/repl (ambly/repl-env) :analyze-path "src")
You may bee curious about the :analyze-path
REPL option being passed above, it is documented here and some exposition on it is here.
The running AwesomeProject
app should be discovered and you can then connect to it.
Note: See Connectivity for details, should any networking difficulty arise.
[1] AwesomeProject on iPhone Simulator (My-MacBook-Pro)
[R] Refresh
Choice: 1
Connecting to AwesomeProject on iPhone Simulator (My-MacBook-Pro) ...
To quit, type: :cljs/quit
cljs.user=>
Then go into the awesome-project.core
namespace:
(in-ns 'awesome-project.core)
You can then update the app-state
atom to display a different string:
(swap! app-state assoc :text "Hello again!")
Update the rendering code in core.cljs
(say, revise the :backgroundColor
of the top-level View element), and then save the file and dynamically reload it:
(require 'awesome-project.core :reload)
The change should be immediately reflected in the simulator.To make it easier to start the Ambly REPL you could add a script somewhere in your project with the following contents. This script additionally checks for rlwrap
(which adds keyboard input editing and history support) and uses it if installed.
This script must be executed from Awesome/awesome-project
(where project.clj
resides). So, it could be placed in Awesome/awesome-project/script/repl
, for example, and started by executing script/repl
.
#!/bin/bash
type lein >/dev/null 2>&1 || { echo >&2 "I require lein but it's not installed. Aborting."; exit 1; }
if hash rlwrap 2>/dev/null; then
COMMAND="rlwrap lein"
else
COMMAND="lein"
fi
$COMMAND trampoline run -m clojure.main -e \
"(require '[cljs.repl :as repl])
(require '[ambly.core :as ambly])
(repl/repl (ambly/repl-env) :analyze-path \"src\")"
You can use the :reload
option to require
to reload changes to your code. While Ambly doesn't support Figwheel, it is possible to simulate the aspect of automatic reloading whenever changes to your code are made. This is described in this blog post and can be achieved with a revised start script:
#!/bin/bash
type lein >/dev/null 2>&1 || { echo >&2 "I require lein but it's not installed. Aborting."; exit 1; }
if hash rlwrap 2>/dev/null; then
COMMAND="rlwrap lein"
else
COMMAND="lein"
fi
$COMMAND trampoline run -m clojure.main -e \
"(require '[cljs.repl :as repl])
(require '[ambly.core :as ambly])
(let [repl-env (ambly.core/repl-env)]
(cljs.repl/repl repl-env
:watch \"src\"
:watch-fn
(fn []
(cljs.repl/load-file repl-env
\"src/awesome_project/core.cljs\"))
:analyze-path \"src\"))"
With this in place, changes to core.cljs
will get reflected in the UI automatically upon save.