Created
December 4, 2013 19:12
-
-
Save interstateone/bbe1c567d2f288a95166 to your computer and use it in GitHub Desktop.
A documented example of how a user might script a UIKit UI.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 |
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
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!