Skip to content

Instantly share code, notes, and snippets.

@interstateone
Created December 4, 2013 19:12
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save interstateone/bbe1c567d2f288a95166 to your computer and use it in GitHub Desktop.
Save interstateone/bbe1c567d2f288a95166 to your computer and use it in GitHub Desktop.
A documented example of how a user might script a UIKit UI.
//
// ViewController.m
// PencilCaseJSDemo
//
// Created by Brandon on 12/3/2013.
// Copyright (c) 2013 Robots and Pencils. All rights reserved.
//
#import "ViewController.h"
@import JavaScriptCore;
@import ObjectiveC;
@protocol UIViewExport <JSExport>
@property (nonatomic) CGPoint center;
@property (nonatomic) CGRect frame;
@property (nonatomic, copy) UIColor *backgroundColor;
- (void)addSubview:(UIView *)view;
// Support for passing JS functions as method parameters to bound methods that expect blocks was removed from JSC
// That means methods like this won't work in JS
+ (void)animateWithDuration:(NSTimeInterval)duration animations:(void (^)(void))animations;
@end
// Based on https://github.com/steamclock/jscalc/tree/master/iOS/JSCalc
// Might be able to have more than one handler with some runtime magic
// Initial support for just touch up inside is probably fine though
@implementation UIControl (JSAction)
static const char *BlockKey = "SCBlocks";
- (void)addEventHandler:(JSValue *)handler forControlEvents:(UIControlEvents)controlEvents {
objc_setAssociatedObject(self, &BlockKey, handler, OBJC_ASSOCIATION_RETAIN);
[self addTarget:self action:@selector(handleControlEventWithSender:event:) forControlEvents:controlEvents];
}
- (void)setTouchUpInsideHandler:(JSValue *)handler {
[self addEventHandler:handler forControlEvents:UIControlEventTouchUpInside];
}
- (void)handleControlEventWithSender:(id)sender event:(UIControlEvents)event {
JSValue *handler;
handler = objc_getAssociatedObject(self, &BlockKey);
if (handler) {
[handler callWithArguments:@[]];
}
}
@end
@protocol UIControlExport <JSExport>
- (void)addEventHandler:(JSValue *)handler forControlEvents:(UIControlEvents)controlEvents;
- (void)setTouchUpInsideHandler:(JSValue *)handler;
@end
@protocol UIButtonExport <JSExport>
@property (nonatomic, retain) UIColor *tintColor;
- (void)setTitle:(NSString *)title forState:(UIControlState)state;
@end
@protocol UIColorExport <JSExport>
+ (UIColor *)redColor;
+ (UIColor *)blueColor;
+ (UIColor *)blackColor;
+ (UIColor *)whiteColor;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
JSContext *context = [[JSContext alloc] init];
// Add Lo-Dash
NSString *lodashPath = [[NSBundle mainBundle] pathForResource:@"lodash" ofType:@"js"];
NSString *lodash = [NSString stringWithContentsOfFile:lodashPath encoding:NSUTF8StringEncoding error:NULL];
[context evaluateScript:lodash];
// Add EventEmitter
NSString *eventEmitterPath = [[NSBundle mainBundle] pathForResource:@"EventEmitter" ofType:@"js"];
NSString *eventEmitter = [NSString stringWithContentsOfFile:eventEmitterPath encoding:NSUTF8StringEncoding error:NULL];
[context evaluateScript:eventEmitter];
// Automatic protocol creation doesn't work yet because of a bug (https://bugs.webkit.org/show_bug.cgi?id=122501)
//[self exportClass:[UIButton class] toContext:context];
//[self exportClass:[UIControl class] toContext:context];
//[self exportClass:[UIColor class] toContext:context];
// Export some classes
class_addProtocol([UIView class], @protocol(UIViewExport));
class_addProtocol([UIControl class], @protocol(UIControlExport));
class_addProtocol([UIButton class], @protocol(UIButtonExport));
class_addProtocol([UIColor class], @protocol(UIColorExport));
context[@"UIView"] = [UIView class];
context[@"UIControl"] = [UIControl class];
context[@"UIButton"] = [UIButton class];
context[@"UIColor"] = [UIColor class];
// Add EventEmitter to classes
[context evaluateScript:@"var event = new EventEmitter();"];
// We can expose functions like this
// Note that in this particular case we can simply use an object like { x: 0, y: 0, width: 100, height: 100 } in JS
// We get support for conversion of CGPoint, NSRange, CGRect and CGSize for free, but it's not hard to do
context[@"CGRectMake"] = (JSValue *) ^(CGFloat x, CGFloat y, CGFloat width, CGFloat height){
return CGRectMake(x, y, width, height);
};
// We will either need to write some categories for UIKit to avoid needing constants like this
// e.g. - setTitle:(NSString *)title;
// Or we can manually export all of the constants
context[@"UIControlStateNormal"] = @(UIControlStateNormal);
context[@"UIControlEventTouchUpInside"] = @(UIControlEventTouchUpInside);
context[@"UIButton"] = [UIButton class];
context[@"UIControl"] = [UIControl class];
context[@"UIColor"] = [UIColor class];
context[@"log"] = ^(NSString *message) {
NSLog(@"%@", message);
};
// Constructors from Objective-C won't work unless we build JavaScriptCore ourselves since the fix isn't in iOS7 yet
// This is how it *should* work though
// [context evaluateScript:@"button = UIButton.new(); button.frame = { x: 0, y: 0, width: 100, height: 44 }; button.setTitleForState('WTFJS', UIControlStateNormal); view.addSubview(button);"];
// Here's an example workaround:
context[@"createUIButton"] = (UIButton *)^{
return [UIButton buttonWithType:UIButtonTypeRoundedRect];
};
/* -----------------------------------------
Simulate deserializing a slide from JSON
-------------------------------------- */
// The parent view
context[@"view"] = self.view;
// Create a button
// Note that no frame is set yet
UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect];
[button setTitle:@"Test" forState:UIControlStateNormal];
[self.view addSubview:button];
NSString *buttonInstanceName = @"button";
context[buttonInstanceName] = button;
// Run the button's script property from the JSON
// The script is wrapped in a self-executing function and scoped to the UI element itself
// Scripts should be evaluated top-down in the view hierarchy, and shouldn't have access to subviews
NSString *buttonScript = @"this.frame = {x: 0, y: 0, width: 100, height: 44}; this.center = view.center; this.tintColor = UIColor.redColor();";
[self evaluateScript:buttonScript withInstanceName:buttonInstanceName inContext:context];
// Let's add a listener on the parent view
// Note that the scope of the event listener is the EventEmitter object itself
NSString *viewScript = @"var black = false; event.on('makeItBlack', function() { view.backgroundColor = black ? UIColor.whiteColor() : UIColor.blackColor(); black = !black; });";
[self evaluateScript:viewScript withInstanceName:@"view" inContext:context];
// Let's create another button in JS (with our constructor workaround) that will trigger that event
NSString *newButtonScript = @"var newButton = createUIButton(); newButton.frame = {x: 0, y: 0, width: 100, height: 44}; newButton.center = { x: view.center.x, y: view.center.y + 52 }; newButton.tintColor = UIColor.blueColor(); newButton.setTitleForState('Make it black', UIControlStateNormal); this.addSubview(newButton); newButton.addEventHandlerForControlEvents(function() { event.trigger('makeItBlack'); }, UIControlEventTouchUpInside);";
[self evaluateScript:newButtonScript withInstanceName:@"view" inContext:context];
}
- (void)evaluateScript:(NSString *)script withInstanceName:(NSString *)instanceName inContext:(JSContext *)context {
NSString *wrappedScript = [NSString stringWithFormat:@"(function() { %@ }).call(%@);", script, instanceName];
[context evaluateScript:wrappedScript];
}
- (void)exportClass:(Class)class toContext:(JSContext *)context {
// Create a protocol that inherits from JSExport and with all the public methods and properties of the class
const char *protocolName = class_getName(class);
Protocol *protocol = objc_allocateProtocol(protocolName);
protocol_addProtocol(protocol, objc_getProtocol("JSExport"));
// Add the public methods of the class to the protocol
NSUInteger methodCount, classMethodCount, propertyCount;
Method *methods, *classMethods;
objc_property_t *properties;
methods = class_copyMethodList(class, &methodCount);
for (NSUInteger methodIndex = 0; methodIndex < methodCount; ++methodIndex) {
Method method = methods[methodIndex];
protocol_addMethodDescription(protocol, method_getName(method), method_getTypeEncoding(method), YES, YES);
}
classMethods = class_copyMethodList(object_getClass(class), &classMethodCount);
for (NSUInteger methodIndex = 0; methodIndex < classMethodCount; ++methodIndex) {
Method method = classMethods[methodIndex];
protocol_addMethodDescription(protocol, method_getName(method), method_getTypeEncoding(method), YES, NO);
}
properties = class_copyPropertyList(class, &propertyCount);
for (NSUInteger propertyIndex = 0; propertyIndex < propertyCount; ++propertyIndex) {
objc_property_t property = properties[propertyIndex];
NSUInteger attributeCount;
objc_property_attribute_t *attributes = property_copyAttributeList(property, &attributeCount);
protocol_addProperty(protocol, property_getName(property), attributes, attributeCount, YES, YES);
free(attributes);
}
free(methods);
free(classMethods);
free(properties);
// Add the new protocol to the class
objc_registerProtocol(protocol);
class_addProtocol(class, protocol);
NSString *className = [NSString stringWithCString:class_getName(class) encoding:NSUTF8StringEncoding];
context[className] = class;
}
@end
@jspeth
Copy link

jspeth commented Feb 23, 2019

Hi! I sat down to write the exact function in exportClass:toContext: and found this. It's very nice. I'm working on an experiment to expose native objects to React Native. It looks like the JSC bug still exists. Did you ever make any more progress on it?

I'm thinking of trying to dump the protocols and compare the differences between ones created in code vs at runtime. There must be some difference that breaks things in JSWrapperMap.mm.

Cheers!

@interstateone
Copy link
Author

Hey @jspeth, sorry I never saw your comment until now. If you're still curious about this problem, I'd written more details about this in a blog post here. The short answer is that I never found a way to make it work, though. Hope that helps!

@jspeth
Copy link

jspeth commented Dec 6, 2020

Hi @interstateone! Thanks for the reply. That's a great investigation, nice work! I also ended up giving up on that experiment. But it's great to understand now why it wouldn't work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment