Hello everyone,
during the webinar last week one of the questions we received was how to access and write platform-specific code with NativeScript, so we decided to create an iOS only application where you can use the entire native iOS api to create iOS-only app.
Here is how the app looks like:
It allows you to take pictures and to modify the picture properties like ISO, EXposure and WB(white balance) before capture the image.
You are going to see a lot of iOS specific APIs here. And frankly I'm so happy that you don't need to write those when creating cross-platform app with NativeScript. There is a lot of iOS specific knowledge required to understand this article, but you don't need to have that knowledge to write NativeScript apps.
This sample app is just to show you how you can access platform-specific code from NativeScript and to know that this code is there for you and you can use it when needed. It is very important to note that in the general case you will use only the NativeScripr cross-platform API and not those platform specific APIs.
Let the fun begin!
The source code is here:
https://github.com/NativeScript/sample-iOS-CameraApp/tree/master/CameraApp
You can clone the source code inside AppBuilder and install in on your iOS 8+ device immediately. To clone the app
- Open AppBuilder
- Choose "Create new Project"
- Choose "clone from existing repository" option
- Paste the git clone url of the repository. In this case the url is https://github.com/NativeScript/sample-iOS-CameraApp.git
- Click "Build" and follow the steps to deploy the app.
Below is an explanation of how the app is being build from one of the Technical Leads of NativeScript - Panayout Cankov.
Setting up EventLoop and MainViewController
The app.js is the entry point to you application and since we want to start up an UI we will have to run application loop ourselves. This is done by a call to UIApplicationMain and providing a UIApplicationDelegate.
var
AppDelegate = UIResponder.extend({
applicationDidFinishLaunchingWithOptions:
function
() {
this
._window =
new
UIWindow(UIScreen.mainScreen().bounds);
this
._window.backgroundColor = UIColor.blackColor();
console.log(
"Up and running?"
);
this
._window.makeKeyAndVisible();
return
true
;
}
}, {
name:
"AppDelegate"
,
protocols: [UIApplicationDelegate]
});
UIApplicationMain(0,
null
,
null
,
"AppDelegate"
);
This piece of code will create a new iOS class “AppDelegate” that extends UIResponder, and implements the UIApplicationDelegate protocol. The “extend” method receives two objects, the first defines new class members or overrides and the second describes additional attributes of the class.
Native classes are available in the global object as JavaScript constructor functions. Methods are renamed by removing the colons from the iOS selectors and using camel case. Static methods can be called on the constructor functions directly and instance methods can be called on the instances.
In NativeScript for iOS we expose init methods as constructors so you can call “new UIWindow(…)” which maps to iOS [[UIWindow alloc] initWithFrame].
iOS properties are exposed as JavaScript properties.
If you are familiar with the iOS API you would probably easily recognize the code.
The UIApplicationMain will start a message loop that would process system events and dispatch them through the UI layer.
So we have successfully set up the app delegate. We also need a root view controller.
Replace the “console.log” line from the AppDelegate with:
this
._window.rootViewController =
new
CameraViewController();
And then create a CameraViewController class just before the AppDelegate class:
var
CameraViewController = UIViewController.extend({
viewDidLoad:
function
() {
this
.
super
.viewDidLoad();
var
label =
new
UILabel(CGRectMake(0, 0, 200, 200));
label.text =
"Hello World"
;
label.textColor = UIColor.whiteColor();
this
.view.addSubview(label);
}
}, {
});
The CameraViewController for now will simply add a white label at the top left of the app. Prepare, launch the app. You should be able to see it.
A few more things to point is the super instance property you can use to call the implementation of the method in the base class.
You can also call the implementation in the base class by obtaining it from the prototype of the constructor function of UIViewController:
UIViewController.prototype.viewDidLoad.apply(
this
, arguments);
But using super is way more convenient.
Adding Camera Layer
We do not need the label. Instead the CameraViewController have to start a capturing session, add a capturing device and display a preview of the camera. We end up with the following CameraViewController:
var
CameraViewController = UIViewController.extend({
viewDidLoad:
function
() {
this
.
super
.viewDidLoad();
this
.createCamera();
},
createCamera:
function
() {
// Init capture session
this
.session =
new
AVCaptureSession();
this
.session.sessionPreset = AVCaptureSessionPreset1280x720;
// Adding capture device
this
.device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo);
this
.input = AVCaptureDeviceInput.deviceInputWithDeviceError(
this
.device,
null
);
if
(!
this
.input) {
throw
new
Error(
"Error trying to open camera."
);
}
this
.session.addInput(
this
.input);
this
.session.startRunning();
// Add a preview layer in the UI
this
.videoLayer = AVCaptureVideoPreviewLayer.layerWithSession(
this
.session);
this
.videoLayer.frame =
this
.view.bounds;
this
.view.layer.addSublayer(
this
.videoLayer);
},
shouldAutorotate:
function
() {
return
false
;
}
}, {
});
Prepare and launch the app. You may be asked for permissions from the app to access the camera. Now if you point the phone at something it should appear on the screen. If you are holding it on your desk like I did it would be blank black.
The CameraApp UI
We have the following images as a design so we will try to build something similar:
We will have 3 small circles as UI that when touched would bloom in a bigger radial menu. From them we will have a choice of several presets for white balance, exposure and ISO.
Implementing the Closed State of the Radial Menus
We will create a UIControl class that would encapsulate the functionality of the radial menus:
var
RELATIVE_DOT_SIZE = 0.2;
var
DialSelector = UIControl.extend({
initWithOptions:
function
(options) {
var
self =
this
.
super
.initWithFrame(options.frame);
if
(self) {
self.options = options;
self.backgroundColor = UIColor.clearColor();
self.childFrame = CGRectMake(0, 0, options.frame.size.width, options.frame.size.height);
self.dotBackgroundColor = UIColor.alloc().initWithRedGreenBlueAlpha(0, 0, 0, 0.7);
self.createLabel();
}
return
self;
},
createLabel:
function
() {
this
.label =
new
UILabel(
this
.childFrame);
this
.label.textAlignment = NSTextAlignment.NSTextAlignmentCenter;
this
.label.textColor = UIColor.whiteColor();
this
.label.text =
this
.options.text;
this
.addSubview(
this
.label);
},
get xCenter() {
return
this
.frame.size.width * 0.5;
},
get yCenter() {
return
this
.frame.size.height * 0.5;
},
get maxRadius() {
return
Math.min(
this
.frame.size.width * 0.5,
this
.frame.size.height * 0.5);
},
drawRect:
function
(rect) {
var
ctx = UIGraphicsGetCurrentContext();
this
.dotBackgroundColor.setFill();
CGContextAddArc(ctx,
this
.xCenter,
this
.yCenter,
this
.maxRadius * RELATIVE_DOT_SIZE, 0, Math.PI * 2, 0);
CGContextFillPath(ctx);
}
}, {
});
The new control have an initWithOptions initializer that invokes the super initWithRect, sets up colors and creates and adds a label as sub-view. We do not want to bloat up the code too much. The controls will be immutable except for the selection so all parameters will be passed in the options object.
You can see also three properties that would help us with the math during drawing, and then comes the drawRect override. In iOS UIView you can override drawRect and use the CoreGraphics drawing API to provide the visual representation for your control. We are using CGContextAddArc to create a small circle at the center of the control.
We will further extend these controls later but now let’s add three instances of them in the CameraViewController so we can check if everything is working fine. Add the createControls in the CameraViewController’s viewDidLoad:
viewDidLoad:
function
() {
UIViewController.prototype.viewDidLoad.apply(
this
, arguments);
this
.createCamera();
this
.createControls();
},
And the additional method that would create the three radial menus:
createControls:
function
() {
// Add white balance dial
this
.whiteBalance = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 70, 320, 320),
text:
"WB"
});
this
.view.addSubview(
this
.whiteBalance);
// Add iso dial
this
.iso = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 150, 320, 320),
text:
"ISO"
,
});
this
.view.addSubview(
this
.iso);
// Add exposure dial
this
.exposure = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 230, 320, 320),
text:
"EX"
,
});
this
.view.addSubview(
this
.exposure);
},
Prepare and run the app. This will pretty much show the first screen from our design without the big red “capture” button.
Implementing the Arcs for the Radial Menus
Next we will create an arc view. We will use it to draw the open version of the radial menu. It will consist of several segments of a donut and a thicker red line that shows selection.
Our control will draw such segment of a donut in its drawRect by drawing an inner and outer arc sharing the same center of origin, with different radiuses starting and ending at the same angles. After we draw the arcs we will use “fill path” which will connect the starting and ending points of the two arcs:
This is the implementation of our ArcView class:
var
ArcView = UIView.extend({
initWithOptions:
function
(options) {
var
self =
this
.
super
.initWithFrame(options.frame);
if
(self) {
self.options = options;
self.backgroundColor = UIColor.clearColor();
self.userInteractionEnabled =
false
;
}
return
self;
},
drawRect:
function
(rect) {
var
halfW = rect.size.width * 0.5;
var
halfH = rect.size.height * 0.5;
var
r = Math.min(halfW, halfH);
var
x = halfW;
var
y = halfH;
var
rInner = r *
this
.options.rInner;
var
rOuter = r *
this
.options.rOuter;
var
from =
this
.options.segment * -0.5;
var
to =
this
.options.segment * +0.5;
var
ctx = UIGraphicsGetCurrentContext();
this
.options.color.setFill();
CGContextAddArc(ctx, x, y, rOuter, from, to, 0);
CGContextAddArc(ctx, x, y, rInner, to, from, 1);
CGContextFillPath(ctx);
}
}, {
});
The expected options should have the following properties:
rInner – the inner radius as percent of the maximum radius that fits in the rect
rOuter – the outer radius as percent of the maximum radius that fits in the rect
segment – the angle, or length of the arc in radians
color – the background color used to fill the arc
Adding Arc Backgrounds in the Open State of the Radial Menu
In the DialSelector initWithOptions add:
self.optionsCount = 5;
self.segmentsCount = 8;
self.createButtons();
The first two fields describe the count of all “buttons” and the count of selectable “buttons”.
We need additional constants, declare them at the top of the file near the RELATIVE_DOT_SIZE:
var
GAP_EPSILON = 0.01;
var
BUTTON_RELATIVE_INNER_RADIUS = 0.3;
var
BUTTON_RELATIVE_OUTER_RADIUS = 0.92;
var
CLOSED_SCALE_FACTOR = 0.2;
var
CLOSED_ALPHA = 0;
var
OPENED_SCALE_FACTOR = 1;
var
OPENED_ALPHA = 1;
GAP_EPSILON would be the angle inset for the buttons so that they don’t touch each other. Then implement the createButtons in the DialSelector as follows:
createButtons:
function
() {
this
.buttons =
new
Array(
this
.segmentsCount);
var
segment = Math.PI * 2 /
this
.segmentsCount - GAP_EPSILON;
var
buttonOptions = {
frame:
this
.childFrame,
color: UIColor.alloc().initWithRedGreenBlueAlpha(0, 0, 0, 0.7),
rInner: BUTTON_RELATIVE_INNER_RADIUS,
rOuter: BUTTON_RELATIVE_OUTER_RADIUS,
segment: segment
};
for
(
var
i = 0; i <
this
.segmentsCount; i++) {
var
button = ArcView.alloc().initWithOptions(buttonOptions);
this
.buttons[i] = button;
button.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(CLOSED_SCALE_FACTOR, CLOSED_SCALE_FACTOR), Math.PI * 2 * i /
this
.segmentsCount);
button.alpha = CLOSED_ALPHA;
this
.addSubview(button);
}
},
This will create instances of the ArcView class, add them as sub-views. Note the transform, it applies a scale of factor 0.2 so the petals will be contracted and also a rotation is applied so they spread in circle around the center point. In addition alpha is set to 0 so they won’t be visible. This is their default state.
When the control is touched the buttons will be animated to spread out. For that we have to track the touches. We are programing at control level so we won’t have to add or remove event targets but instead override the following methods:
beginTrackingWithTouchWithEvent:
function
(touch, event) {
this
.sendActionsForControlEvents(UIControlEvents.UIControlEventEditingDidBegin);
this
.bloom(OPENED_SCALE_FACTOR, OPENED_ALPHA);
return
true
;
},
continueTrackingWithTouchWithEvent:
function
(touch, event) {
return
true
;
},
endTrackingWithTouchWithEvent:
function
(touch, event) {
this
.sendActionsForControlEvents(UIControlEvents.UIControlEventEditingDidEnd);
this
.bloom(CLOSED_SCALE_FACTOR, CLOSED_ALPHA);
},
beginTrackingWithTouchWithEvent will be invoked the control is touched. It returns true to indicate it would continuously observe the touch and continue will be invoked on drag. The continueTrackingWithTouchWithEvent will be invoked every time the touch is dragged. And finally endTrackingWithTouchWithEvent will be invoked when the touch is released. There is also cancelTrackingTouchWithEvent that we will ignore since our UI and interaction is fairly simple but you should generally implement it.
Also we will send the UIControlEventEditingDidBegin and UIControlEventEditingDidEnd events. We will need these along an upcoming UIControlEventValueChanged in the CameraViewController.
What is the bloom? It is another method that will handle the appearance and disappearance of the buttons:
bloom:
function
(scale, alpha) {
UIView.beginAnimationsContext(
null
,
null
);
UIView.setAnimationDelegate(
this
);
UIView.setAnimationCurve(UIViewAnimationCurve.UIViewAnimationCurveEaseOut);
for
(
var
i = 0; i <
this
.segmentsCount; i++) {
var
button =
this
.buttons[i];
UIView.setAnimationDuration(i * 0.05 + 0.03);
button.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(scale, scale), Math.PI * 2 * i /
this
.segmentsCount);
button.alpha = alpha;
}
UIView.commitAnimations();
}
The buttons inside bloom are animated to reach the scale and alpha settings provided from outside. bloom(1, 1) will show them and bloom(0.2, 0) will hide them. You can see that we will use the UIView.beginAnimationContext to record some changes on the UI. The rotation and scale of the buttons are recalculated. For fancier animation we also set different duration for each button. At the end UIView.commitAnimations() will collect the recorded changes and animate the buttons.
You can prepare and launch the app now. As a final touch on it we have to take care of the control’s hit area. Currently its hit are is as big as the bounding box of the opened state. Override point inside with event:
distance:
function
(point) {
return
Math.sqrt(Math.pow(point.x -
this
.xCenter, 2) + Math.pow(point.y -
this
.yCenter, 2));
},
pointInsideWithEvent:
function
(point, event) {
return
this
.distance(point) <
this
.maxRadius * RELATIVE_TOUCH_SIZE;
},
And you also need the constant:
var
RELATIVE_TOUCH_SIZE = 0.25;
Note that while the touch size is 0.25 and the actual drawing uses 0.2, the touch area will be a little bit bigger than the closed menu appears, courtesy of my fat thumbs. As for the math – the method simply calculates the distance from the center of the control to the touch point and returns true if that distance is less than a quarter of the radius of the maximum circle you can fit in the control’s frame.
Opening one Radial Menu Hides the Rest
When you press one of the menus the other two should disappear. As a bad practice I’ve seen people traversing the visual tree from their controls in similar situations where they would get the super-view of the DialSelector, iterate its sub-views and hide them. Instead we want to access only controls that we created or were given to us. This will improve the maintainability in future. So our CameraViewController will observe the DialSelectors in its createControls method:
this
.subscribeDialEditBeginEnd(
this
.whiteBalance);
this
.subscribeDialEditBeginEnd(
this
.iso);
this
.subscribeDialEditBeginEnd(
this
.exposure);
by adding targets
for
the begin-edit/end-edit events:
subscribeDialEditBeginEnd:
function
(control) {
control.addTargetActionForControlEvents(
this
,
"dialBeginEdit"
, UIControlEvents.UIControlEventEditingDidBegin);
control.addTargetActionForControlEvents(
this
,
"dialEndEdit"
, UIControlEvents.UIControlEventEditingDidEnd);
},
And similar to our DialSelector’s bloom method it will animate the alpha of the closed menus when one of them is open:
dialBeginEdit:
function
(control) {
UIView.beginAnimationsContext(
null
,
null
);
UIView.setAnimationDelegate(
this
);
UIView.setAnimationCurve(UIViewAnimationCurve.UIViewAnimationCurveEaseOut);
UIView.setAnimationDuration(0.2);
this
.whiteBalance.alpha = control ==
this
.whiteBalance ? 1 : 0;
this
.iso.alpha = control ==
this
.iso ? 1 : 0;
this
.exposure.alpha = control ==
this
.exposure ? 1 : 0;
UIView.commitAnimations();
},
dialEndEdit:
function
(control) {
UIView.beginAnimationsContext(
null
,
null
);
UIView.setAnimationDelegate(
this
);
UIView.setAnimationCurve(UIViewAnimationCurve.UIViewAnimationCurveEaseOut);
UIView.setAnimationDuration(0.2);
this
.whiteBalance.alpha = 1;
this
.iso.alpha = 1;
this
.exposure.alpha = 1;
UIView.commitAnimations();
},
If you prepare and run the app now you shall fail with:
2014-10-14 11:38:35.385 CameraApp[5764:2891504] -[UIViewController1 dialBeginEdit]: unrecognized selector sent to instance 0x15e0dbb0
The addTargetActionForControlEvents registers JavaScript function as action for native event. There are two very important points here:
A selector should be registered in the native class. The native iOS instance should be able to accept that selector and transition it to the JavaScript implementation.
The target in addTargetActionForControlEvents is kept in native as weak reference. It can be prematurely collected resulting in all sort of ’BAD ACCESS’ runtime errors. You would be responsible too keep the target alive by keeping a reference to it in JavaScript.
Point one is achieved by setting the exposed methods in the second parameter of the UIControl.extend call for our DialSelector. Remember how we set class name for AppDelegate? Our MainViewControler does not need a name but will expose the two selectors ‘dialBeginEdit’ and ‘dialEndEdit’ to the native class:
}, {
exposedMethods: {
'dialBeginEdit'
:
'v@'
,
'dialEndEdit'
:
'v@'
,
}
});
The string – string pairs in the exposedMethods object links a selector with iOS types. The types in both are void return type: ‘v’, and an id as first parameter: ‘@’.
Point two is already fine as the DialSelectors lifetime is bound to the application lifetime.
Adding Views in the Buttons
The first of our radial menus display images, the second and the third display labels. We will need the following two factory functions in the CameraViewController:
createLabels:
function
(texts) {
var
rect = CGRectMake(0, 0, 40, 40);
return
texts.map(
function
(t) {
var
l =
new
UILabel(rect);
l.text = t;
l.textAlignment = NSTextAlignment.NSTextAlignmentCenter;
l.textColor = UIColor.whiteColor();
return
l;
});
},
createImages:
function
(paths) {
var
rect = CGRectMake(0, 0, 40, 40);
return
paths.map(
function
(p) {
var
uiImage = UIImage.imageNamed(
"app/assets/"
+ p);
var
uiImageView =
new
UIImageView(rect);
uiImageView.image = uiImage
return
uiImageView;
});
},
The createLabels will build UILabels when provided an array with strings. The createImages will build UIImageViews when provided an array with image paths. The images should be located in the app/assets folder.
The MainViewController createControlls should look like this:
createControls:
function
() {
// Add white balance dial
this
.whiteBalance = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 70, 320, 320),
text:
"WB"
,
views: createImages([
"cloudy@2x.png"
,
"sunny@2x.png"
,
"auto@2x.png"
,
"tungsten@2x.png"
,
"fluorescent@2x.png"
]),
values: [
{ auto:
false
, redGain: 2.4, greenGain: 1, blueGain: 2.2 },
{ auto:
false
, redGain: 2.2, greenGain: 1, blueGain: 2.1 },
{ auto:
true
, redGain: 2, greenGain: 1, blueGain: 2 },
{ auto:
false
, redGain: 1.8, greenGain: 1, blueGain: 1.9 },
{ auto:
false
, redGain: 2.1, greenGain: 1, blueGain: 2 }
]
});
this
.subscribeDialEditBeginEnd(
this
.whiteBalance);
this
.whiteBalance.addTargetActionForControlEvents(
this
,
"updateCamera"
, UIControlEvents.UIControlEventValueChanged);
this
.view.addSubview(
this
.whiteBalance);
// Add iso dial
this
.iso = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 150, 320, 320),
text:
"ISO"
,
views: createLabels([
"32"
,
"64"
,
"128"
,
"256"
,
"512"
]),
values: [34, 65, 128, 256, 512]
});
this
.subscribeDialEditBeginEnd(
this
.iso);
this
.iso.addTargetActionForControlEvents(
this
,
"updateCamera"
, UIControlEvents.UIControlEventValueChanged);
this
.view.addSubview(
this
.iso);
// Add exposure dial
this
.exposure = DialSelector.alloc().initWithOptions({
frame: CGRectMake(110, 230, 320, 320),
text:
"EX"
,
views: createLabels([
"5"
,
"10"
,
"20"
,
"50"
,
"100"
]),
values: [5, 10, 20, 50, 100]
});
this
.subscribeDialEditBeginEnd(
this
.exposure);
this
.exposure.addTargetActionForControlEvents(
this
,
"updateCamera"
, UIControlEvents.UIControlEventValueChanged);
this
.view.addSubview(
this
.exposure);
},
As you may note we have added five UI elements in the options for each DialSelector along with five values that would be used as selected value upon selection.
TODO: The minimum/maximum values in the ISO and exposure should be obtained from the device.
We also subscribe for the UIControlEventValueChangedevent on the control. We will use that to update the camera settings.
Let’s first display the options.
addSelectionViews:
function
() {
for
(
var
i = 0; i <
this
.options.views.length; i++) {
var
item =
this
.options.views[i];
item.center = { x:
this
.xCenter, y:
this
.yCenter };
this
.addSubview(item);
item.alpha = CLOSED_ALPHA;
}
},
Add the following line in the DialSelector initWithOptions:
self.addSelectionViews();
It will simply iterate all vies in the options and add them as subviews. It will also center the views over the dot and set the alpha to 0 to make them invisible.
We have a final touch to add in the DialSelector bloom method that would animate the views along the buttons. At the end, just before the commit-animation add:
for
(
var
i = 0; i <
this
.optionsCount; i++) {
var
item =
this
.options.views[i];
var
angle = i / (
this
.optionsCount - 1) * -Math.PI - Math.PI * 0.5;
UIView.setAnimationDuration((
this
.optionsCount - 1 - i) * 0.05 + 0.1);
item.transform = CGAffineTransformMakeTranslation(Math.cos(angle) *
this
.maxRadius * RELATIVE_ICONS_DISTANCE * scale, Math.sin(angle) *
this
.maxRadius * RELATIVE_ICONS_DISTANCE * scale);
item.alpha = alpha;
}
We have to declare yet another constant:
var
RELATIVE_ICONS_DISTANCE = 0.7;
The loop will iterate through all view items. An angle relative to the DialSelector center is calculated based on their index. And then they are positioned with a transformation using some trigonometry. The views will start from the top and rotate counter clock wise to the left and bottom.
Implementing Selection
Our DialSelector will have selectedIndex and selectedAngle properties that will be used to draw the red selection arc. We will consider them read-only for the outside world, and we do not expect other controls to set them. In the DialSelector initWithOptions method add the following code to initialize the selected index and angle to the middle/left item:
self.selectedIndex = Math.floor(self.optionsCount / 2);
self.selectedAngle = (-self.selectedIndex / self.segmentsCount - 0.25) * Math.PI * 2;
self.createSelection();
And in the DialSelector add the createSelection:
createSelection:
function
() {
var
segment = Math.PI * 2 /
this
.segmentsCount - GAP_EPSILON;
this
.selectorArc = ArcView.alloc().initWithOptions({
frame:
this
.childFrame,
color: UIColor.redColor(),
rInner: SELECTION_RELATIVE_INNER_RADIUS,
rOuter: SELECTION_RELATIVE_OUTER_RADIUS,
segment: segment
});
this
.selectorArc.transform = CGAffineTransformScale(CGAffineTransformMakeRotation(
this
.selectedAngle), 0.2, 0.2);
this
.selectorArc.alpha = 0;
this
.addSubview(
this
.selectorArc);
},
The red arc will now be added as sub-view. There are several points where we will interact with the alpha and the transformation of that arc. In the bloom method we will animate it first, just after the setAnimationCurve:
UIView.setAnimationDuration(0.2);
this
.selectorArc.transform = CGAffineTransformScale(CGAffineTransformMakeRotation(
this
.selectedAngle), scale, scale);
this
.selectorArc.alpha = alpha;
As you can see we will animate it to have scale factor of 0.2 for closed state and 1 for open and the alpha to be 0 for closed state and 1 for open. The rotation will come from the selectedAngle property.
If you run the project now you should be able to touch and hold the DialSelectors and the red arc would appear as if the left item is selected.
We have one more step, we should allow dragging over an item to change the selection. This happens in the DialSelector continueTrackingWithTouchWithEvent:
continueTrackingWithTouchWithEvent:
function
(touch, event) {
var
location = touch.locationInView(
this
);
var
wHalf =
this
.frame.size.width * 0.5;
var
hHalf =
this
.frame.size.height * 0.5;
var
r = Math.min(wHalf, hHalf);
var
xCenter = wHalf;
var
yCenter = hHalf;
var
dx = location.x - xCenter;
var
dy = location.y - yCenter;
var
distance = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
if
(distance <= r * 0.23) {
// We are too close to the center so we won't change the selection.
}
else
{
var
angle = Math.atan2(dy, dx);
var
twoPi = Math.PI * 2;
var
steps =
this
.segmentsCount;
var
newSelectedIndex = (Math.round(-angle / twoPi * steps) + Math.round(
this
.segmentsCount * 0.75)) %
this
.segmentsCount;
var
newSelectedAngle = -newSelectedIndex / steps * twoPi - Math.PI * 0.5;
if
(newSelectedIndex >= 0 && newSelectedIndex <
this
.optionsCount &&
this
.selectedIndex != newSelectedIndex) {
// Set newly selected values.
this
.selectedIndex = newSelectedIndex;
this
.selectedAngle = newSelectedAngle;
this
.value =
this
.options.values[newSelectedIndex];
// Rotate the selector arc with animation.
UIView.beginAnimationsContext(
null
,
null
);
UIView.setAnimationDelegate(
this
);
UIView.setAnimationDuration(0.2);
UIView.setAnimationCurve(UIViewAnimationCurve.UIViewAnimationCurveEaseOut);
this
.selectorArc.transform = CGAffineTransformMakeRotation(
this
.selectedAngle);
UIView.commitAnimations();
// Notify observers.
// this.sendActionsForControlEvents(UIControlEvents.UIControlEventValueChanged);
}
}
return
true
;
},
The method first calculates the location of the touch within the coordinate system of the control using locationInView. Then it calculates the dx - horizontal and dy - vertical distance from the touch point to the center of the control. Then, using the Pythagorean Theorem (c2 = a2 + b2), it calculates the distance from the center of the control to the touch point. If that distance is too small we will ignore the move. Otherwise the selection will change at the very moment you open the menu and the direction you point would be too random to consume.
If you’ve touched the dot and move far enough to the side the selection should change. Then we use Math.atan2 to calculate the angle from the center to the touch point, and recalculate the selectedIndex and selectedAngle properties. We have introduced a new property – value. It will contain an item from options.values that is located at the selectedIndex and would be extremely handy to use from the UIControlEventValueChanged event handlers. Finally we run new animation only for the selection arc just like we did recently in the bloom method. In addition we need to initialize the value in the initWithOptions so that it is correct at before any selection:
self.value = self.options.values[self.selectedIndex];
Run the application now. You should be able to touch one of the three DialSelectors, drag to the side and change the selection by rotating the selection arc.
Note that the sendActionsForControlEvents was commented out. We have not implemented the listeners in our CameraViewController. Uncomment that method and run the app again. Change the selection and the app will crash with unrecognized selector.
Add the updateCamera method in the CameraViewController:
updateCamera:
function
() {
this
.device.lockForConfiguration(
null
);
this
.device.whiteBalanceMode =
this
.whiteBalance.value.auto ? AVCaptureWhiteBalanceMode.ContinuousAutoWhiteBalance : AVCaptureWhiteBalanceMode.Locked;
if
(!
this
.whiteBalance.value.auto) {
this
.device.setWhiteBalanceModeLockedWithDeviceWhiteBalanceGainsCompletionHandler(
this
.whiteBalance.value,
null
);
}
this
.device.setExposureModeCustomWithDurationISOCompletionHandler({ value:
this
.exposure.value, timescale: 1000, epoch: 0, flags: 1 },
this
.iso.value,
null
);
this
.device.unlockForConfiguration();
},
Along with its definition in exposedMethods:
'updateCamera'
:
'v'
There are several key points here.
The lockForConfiguration and unlockForConfiguration will allow our app to apply changes on the device. Setting whiteBalanceMode is straight forward, our white balance values have auto property that we simply set on the whiteBalanceMode of the device. Setting white balance gains however is not that simple.
The native method behind setWhiteBalanceModeLockedWithDeviceWhiteBalanceGainsCompletionHandler expects a first argument that is a struct with three float fields: redGain, greenGain and blueGain. Structs are passed by value and our runtime is smart enough to get the JavaScript object and transform it to native struct, successfully removing the auto property we have in excess, and converting the JavaScript numbers to floats.
We have no use of completion handler so we simply pass null as second parameter.
The setExposureModeCustomWithDurationISOCompletionHandler is similar. It expects a CMTime which has quite a lot of fields there, so we do not want to create it in the iso DialSelector values. Instead we will create a new JavaScript object and let the runtime convert it to a struct during marshallization. It is important to know that the actual duration of the CMTime is value/timescale secs, and the CMTime behaves like fraction. So you can consider the value to be in milliseconds here as long as the timescale is 1000. The flags property set to 1 means the CMTime is valid, this is important for the framework to accept your value. There are several other flags similar to NaN that you may generate if you work with CMTime but you don’t have to worry for them now. It may seem overly complicated but it allows the iOS to work with very small or large timescales without losing precision.
The second parameter for the ISO is float so you can simply pass JavaScript number. And the third is completion handler that we once again do not use and pass null.
We should not miss a call to updateCamera in CameraViewController’s viewDidLoad so that the camera is initialized with our settings at start. At the end of the viewDidLoad add:
this
.updateCamera();
Launch the app. You now have fully functional radial menus with selection integrated with the camera.
We have created controls with far more sophisticated shapes than the capture button. Here is the code that would create a button with the form of a big red dot with white stroke:
var
CAPTURE_BUTTON_STROKE_WIDTH = 3;
var
ShotButton = UIControl.extend({
initWithFrame:
function
(frame) {
var
self =
this
.
super
.initWithFrame(frame);
if
(self) {
self.backgroundColor = UIColor.clearColor();
}
return
self;
},
drawRect:
function
(rect) {
var
halfW = rect.size.width * 0.5 - CAPTURE_BUTTON_STROKE_WIDTH * 0.5;
var
halfH = rect.size.height * 0.5 - CAPTURE_BUTTON_STROKE_WIDTH * 0.5;
var
r = Math.min(halfW, halfH);
var
x = halfW;
var
y = halfH;
var
ctx = UIGraphicsGetCurrentContext();
UIColor.redColor().setFill();
UIColor.whiteColor().setStroke();
CGContextAddArc(ctx, rect.size.width / 2, rect.size.height / 2, r, 0, Math.PI * 2, 0);
CGContextFillPath(ctx);
CGContextSetLineWidth(ctx, CAPTURE_BUTTON_STROKE_WIDTH);
CGContextAddArc(ctx, rect.size.width / 2, rect.size.height / 2, r, 0, Math.PI * 2, 0);
CGContextStrokePath(ctx);
}
}, {
});
It looks fairly similar to the ArcView however it extends UIControl so we can consume its native events and instead of drawing two arcs it draws one arc to fill with red and a second one to draw the white outline.
We have yet to add it in the CameraViewController. Add a call to createShotButton in the viewDidLoad method:
this
.createShotButton();
And the following methods to actually create the button and add it as sub-view:
createShotButton:
function
() {
this
.shotButton = ShotButton.alloc().initWithFrame(CGRectMake(130, 500, 60, 60));
this
.shotButton.addTargetActionForControlEvents(
this
,
"takePhoto"
, UIControlEvents.UIControlEventTouchUpInside);
this
.view.addSubview(shot);
},
takePhoto:
function
() {
console.log(
"capture image"
);
},
And list the exposed method in CameraViewController’s exposeMethods:
'takePhoto'
:
'v'
We will implement the takePicture later. Now let’s run the app. You should be able to see the red button.
Taking a Photo
To take a photo we will need an output for our camera input. Remember at the start we have created an AVCaptureSession session and an AVCaptureDeviceInput input? Well we need an output, lets add in the CameraViewController createCamera method an output of type AVCaptureStillImageOutput. This should be just before the this.session.startRunning() call:
this
.output =
new
AVCaptureStillImageOutput();
this
.session.addOutput(
this
.output);
Now we are ready to implement the takePhoto:
takePhoto:
function
() {
var
videoConnection =
this
.output.connections[0];
this
.output.captureStillImageAsynchronouslyFromConnectionCompletionHandler(videoConnection,
function
(buffer, error) {
var
imageData = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(buffer);
var
image =
new
UIImage(imageData);
UIImageWriteToSavedPhotosAlbum(image,
null
,
null
);
AudioServicesPlaySystemSound(144);
});
},
Here we get the video connection. We have managed the connections ourselves so we are quite sure it’s the first connection in the output. Then we call captureStillImageAsynchronouslyFromConnectionCompletionHandler. It has a connection as first parameter and a completion handler as second. The second parameter in iOS is a block. For known block types we manage to wrap JavaScript functions in iOS blocks and handle the marshalling gracefully. When the capturing completes, our callback function will be invoked with eventual image buffer and an error if any. For simplicity we discard the error and save the captured image as JPEG, raising a system sound to notify a photo was taken.
Summary
The app has a lot of room for improvement. There are a lot of chokepoints where the app may break, where error messages should be handled. With the increasing variety of iOS devices the UI should be accommodated accordingly. As well as different cameras will support different ranges of exposure duration and ISO. This will be left to you for further development. However we have managed to build ourselves quite functional and appealing application using NativeScript for iOS in less than 500 lines of JavaScript code.
Enjoy!