MorphModalKit is a super neat Swift package recently made available by iOS developer, Joseph Smith. I'm constantly inspired by so many developers out there and being a technologist, I can't help but ask, what if?
We're going to go completely beyond consuming the Swift package made available here and utilized here. This is an advanced dive into what's possible with TypeScript today.
- → Part 1: Swift to 100% TypeScript with MorphModalKit
- Part 2: Coming soon Dynamically create NativeScript views to show in Morphing Modal
Let's write the entire implementation in 100% TypeScript with the aid of AI while maintaining identical behavior. Along the way I'll point out mistakes AI currently makes, how to fix them and we'll conclude with a completely 100% written TypeScript app to serve as a deeply informative example that can be built from your web browser using the superpower, NativeScript.
Using AI to write Swift as NativeScript
Here's the prompt!
Could you convert this Swift to NativeScript?
The response is nothing short of remarkable.

This process was done to achieve this Stackblitz containing MorphModalKit written completely with 100% TypeScript, give it a try. You can even edit and modify behavior instantaneously directly from your web browser.
We're going to dive into several considerations when working with NativeScript and AI. Although it does a pretty darn good job, it's not perfect.
I'll start this exploration by outlining a few NativeScript learning points that hopefully will help establish a fundamental understanding we can apply to the AI output.
Connect iOS control event handlers via ObjCExposedMethods
Consider a UIButton setup like this:
this.inputBtn = UIButton.buttonWithType(UIButtonType.System);
this.inputBtn.addTargetActionForControlEvents(this, "openInput", UIControlEvents.TouchUpInside);
In order for the openInput
method to fire on touch up, we need to add it to ObjCExposedMethods
:
@NativeClass()
export class MenuModal extends UIViewController implements ModalView {
static ObjCExposedMethods = {
openInput: {
returns: interop.types.void,
params: [interop.types.id],
},
};
private inputBtn: UIButton | null = null;
viewDidLoad() {
super.viewDidLoad();
this.inputBtn = UIButton.buttonWithType(UIButtonType.System);
this.inputBtn.addTargetActionForControlEvents(this, "openInput", UIControlEvents.TouchUpInside);
}
openInput(): void {
this.modalVC.push(InputModal.new() as InputModal);
}
}
This ensures that the button will invoke the openInput
method. The returns
and params
can be any type which is handled for the method. For example, the ModalInteractionController
handles a UIPanGestureRecognizer:
@NativeClass()
export class ModalInteractionController
extends NSObject
implements UIGestureRecognizerDelegate
{
static ObjCProtocols = [UIGestureRecognizerDelegate];
static ObjCExposedMethods = {
handlePan: {
returns: interop.types.void,
params: [UIPanGestureRecognizer],
},
};
static initWithGesture() {
const instance = ModalInteractionController.alloc().init() as ModalInteractionController;
// init pan recognizer and assign ourselves as its delegate
instance.sheetPan = UIPanGestureRecognizer.alloc().initWithTargetAction(
instance,
"handlePan"
);
instance.sheetPan.delegate = instance;
instance.bgStartTransforms = new Map<UIView, CGAffineTransform>()
return instance;
}
handlePan(g: UIPanGestureRecognizer): void {
// impl
}
}
Never initialize member variables in @NativeClass
Say you do something like this:
@NativeClass()
export class InputModal extends UIViewController implements ModalView {
// !! never do this in a native class implementation using NativeScript
private tf: UITextField = UITextField.alloc().init();
That tf
member will never be init because we aren't calling a constructor on InputModal
. Instead initialize it within viewDidLoad
:
@NativeClass()
export class InputModal extends UIViewController implements ModalView {
private tf: UITextField;
viewDidLoad(): void {
super.viewDidLoad();
// Initialize the text field
this.tf = UITextField.alloc().init();
Swift protocol to a TypeScript interface
Here's a Swift protocol:
protocol ModalInteractionDelegate: AnyObject {
func interactionCanDismiss(_ ic: ModalInteractionController) -> Bool
func interactionPrimaryContainer(_ ic: ModalInteractionController) -> UIView?
func interactionDidDismiss(_ ic: ModalInteractionController)
func interactionAnimationSettings(_ ic: ModalInteractionController) -> ModalAnimationSettings
}
Here's the matching TypeScript interface:
export interface ModalInteractionDelegate {
interactionCanDismiss(ic: ModalInteractionController): boolean;
interactionPrimaryContainer(ic: ModalInteractionController): UIView;
interactionDidDismiss(ic: ModalInteractionController): void;
interactionAnimationSettings(ic: ModalInteractionController): ModalAnimationSettings;
}
Initializers
When initializing an instance in Swift, it may look like this:
let sticky = ScrollStickyElements()
In NativeScript, we can init native instances several ways but the most common are:
const sticky = ScrollStickyElements.new() as ScrollStickyElements;
// or
const sticky = ScrollStickyElements.alloc().init() as ScrollStickyElements;
// or (it's common to init with empty frame - it will auto size itself once added to a view)
const sticky = ScrollStickyElements.alloc().initWithFrame(CGRectMake(0, 0, 0, 0)) as ScrollStickyElements;
The .new()
is a special initializer NativeScript adds on all native classes allowing you to empty init them.
Which one you use depends on the context (is it a UIViewController or just a UIView or something else). The casting at the end helps our TypeScript since the init
and initWithFrame
methods are just platforms types that return the base primitize, in this case UIView
so we're just letting TypeScript know which instance type of UIView in these cases.
In future NativeScript versions, it's possible that could be simplified by TypeScript generics.
Custom Initializers
You may want to create a native instance with default values for various members. Since we should never initialize member variables of @NativeClass usages as mentioned above, we can create custom initalizers which set various member values.
@NativeClass()
export class MenuModal extends UIViewController implements ModalView {
initSettings() {
this.canDismiss = true; // Allow dismissal by default
}
// more impl
}
This allows us to initialize members before adding to the view. This can be useful in cases where some values are needed before it's loaded in the view.
const menuModal = MenuModal.new() as MenuModal;
menuModal.initSettings();
// add to the view stack at some later time
You may opt for a static initializer as well which also works:
@NativeClass()
export class MorphModal extends UIViewController implements ModalView {
static initWithStep(step: MorphStep) {
const instance = MorphModal.alloc().init() as MorphModal;
instance.canDismiss = true; // Allow dismissal by default
return instance;
}
}
Swift method argument labels
Swift allows parameter names to also have custom labels.
public contextDidChange(
to newOwner: ModalView,
from oldOwner: ModalView | null,
animated: boolean
): void {
}
In TypeScript we just drop the custom argument labels:
public contextDidChange(
newOwner: ModalView,
oldOwner: ModalView | null,
animated: boolean
): void {
}
CACornerMask enum
Various enums in Swift may use shorthands that look like this:
gradientContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
With NativeScript, the strong typing tells us it's just this:
gradientContainer.layer.maskedCorners = CACornerMask.kCALayerMinXMinYCorner | CACornerMask.kCALayerMaxXMinYCorner;
NSArray properties
In Swift you may have something like this:
let gradient = CAGradientLayer()
gradient.colors = [
UIColor.secondarySystemGroupedBackground.cgColor,
UIColor.secondarySystemGroupedBackground.withAlphaComponent(0).cgColor
]
In NativeScript we get strong typing on the colors
property of CAGradientLayer
telling us it expects an NSArray. To satisfy our TypeScript, we can use a nifty core utility:
import { Utils } from '@nativescript/core';
const gradient = CAGradientLayer.alloc().init();
gradient.colors = Utils.ios.collections.jsArrayToNSArray([
UIColor.secondarySystemGroupedBackgroundColor.CGColor,
UIColor.secondarySystemGroupedBackgroundColor.colorWithAlphaComponent(0).CGColor,
]);
Add View+Extensions
It's a common Swift practice to add various extensions which extend iOS constructs with extra features and new APIs as follows:
UIViewController+MorphModal.swift
public extension UIViewController {
func presentModal(
_ root: ModalView,
options: ModalOptions = ModalOptions.default,
sticky: StickyOption = .none,
animated: Bool = true,
showsOverlay: Bool = true) {
let host = ModalViewController()
host.modalPresentationStyle = .overFullScreen
host.modalTransitionStyle = .crossDissolve
present(host, animated: false) {
host.present(
root,
options: options,
sticky: sticky,
animated: animated,
showsOverlay: showsOverlay)
}
}
}
UIViewController+MorphModal.ts
We can do the same thing using TypeScript.
UIViewController.prototype.presentModal = function (
root: ModalView,
options: ModalOptions = ModalOptions.default,
sticky: StickyOption = { type: "none" },
animated: boolean = true,
showsOverlay: boolean = true
): void {
const host = ModalViewController.alloc().init() as ModalViewController;
host.options = options;
host.modalPresentationStyle = UIModalPresentationStyle.OverFullScreen;
host.modalTransitionStyle = UIModalTransitionStyle.CrossDissolve;
this.presentViewControllerAnimatedCompletion(host, false, () => {
host.present(root, options, sticky, animated, showsOverlay);
});
};
// Get strong typing on the new extension as well
declare global {
interface UIViewController {
presentModal(
root: ModalView,
options?: ModalOptions,
sticky?: StickyOption,
animated?: boolean,
showsOverlay?: boolean
): void;
}
}
UIFont+Rounded.swift
Here's another example using UIFont.
extension UIFont {
static func rounded(ofSize size: CGFloat, weight: UIFont.Weight) -> UIFont {
let systemFont = UIFont.systemFont(ofSize: size, weight: weight)
let font: UIFont
if let descriptor = systemFont.fontDescriptor.withDesign(.rounded) {
font = UIFont(descriptor: descriptor, size: size)
} else {
font = systemFont
}
return font
}
}
UIFont+Rounded.ts
We can do the same thing using TypeScript.
UIFont.prototype.rounded = function (size: number, weight: number): UIFont {
let systemFont = UIFont.systemFontOfSizeWeight(size, weight);
let font: UIFont;
let descriptor = systemFont.fontDescriptor.fontDescriptorWithDesign(
UIFontDescriptorSystemDesignRounded
);
if (descriptor) {
font = UIFont.fontWithDescriptorSize(descriptor, size);
} else {
// Fallback to system font if rounded design is not available
font = systemFont;
}
return font;
};
// Provide strong types via TypeScript for custom UIFont extensions
declare global {
interface UIFont {
rounded(size: number, weight: any): UIFont;
}
}
Load custom view extensions before app boots
Make sure both are loaded before you app boots, commonly in app.ts
or main.ts
:
// load ios extensions
import './UIViewController+MorphModal';
import './UIFont+Rounded';
AI mistakes and how to fix them
Let's look at a few mistakes AI currently makes when converting Swift to NativeScript.
UIEdgeInsets
tv.textContainerInset = UIEdgeInsetsMake(32, 20, 32, 20);
There is no UIEdgeInsetsMake
. We can do the same with this though:
new UIEdgeInsets({
top: 6,
left: 0,
bottom: -6,
right: 0,
});
Member closures
Swift uses member closures often to create instances within a member getter, for example:
private readonly textView: UITextView = (() => {
const tv = UITextView.alloc().init();
// custom impl
return tv;
})();
With NativeScript, for the same reason as mentioned above in "member variables in @NativeClass", we can instead use a method to create our instance variable.
private textView: UITextView;
viewDidLoad() {
super.viewDidLoad();
this.textView = this.createTextView();
}
private createTextView(): UITextView {
const tv = UITextView.alloc().init();
// custom impl
return tv;
}
More NativeClass decorators than needed
AI may place more decorators above functions than needed like this:
@NativeClass()
export class StickyElements extends UIView {
@NativeClass()
private onBack(): void {
}
@NativeClass()
private onNext(): void {
}
}
That's a hullicination. It's only needed above the class
.
@NativeClass()
export class StickyElements extends UIView {
private onBack(): void {
}
private onNext(): void {
}
}
Text attribute transformers
Swift may have text attribute transformers like this:
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var out = incoming
out.font = .rounded(ofSize: 12, weight: .heavy)
out.foregroundColor = textColor
return out
}
AI may present you with invalid NativeScript like this:
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer.textAttributesTransformerWithHandler(
incoming => {
const out = incoming.mutableCopy();
out.font = UIFont.roundedSystemFontOfSizeWeight(12, UIFontWeightHeavy);
out.foregroundColor = textColor;
return out;
}
);
It's actually much simpler than that. It's just a closure function that returns a NSDictionary of the valid attribute key/values like this:
cfg.titleTextAttributesTransformer = incoming => {
return NSDictionary.dictionaryWithDictionary({
NSFont: UIFont.alloc().rounded(12, UIFontWeightHeavy),
NSColor: textColor,
} as any);
}
NSDirectionalEdgeInsets or UIEdgeInsets
Swift may have something like this:
var cfg = UIButton.Configuration.plain()
cfg.contentInsets = .init(top: 4, leading: 8, bottom: 4, trailing: 8)
AI may present you with invalid NativeScript like this:
const cfg = UIButton.Configuration.plainConfiguration();
cfg.contentInsets = UIEdgeInsetsMake(4, 8, 4, 8);
We can just correct the type usage as follows:
const cfg = UIButtonConfiguration.plainButtonConfiguration();
// can use this
cfg.contentInsets = new NSDirectionalEdgeInsets({
top: 4,
leading: 8,
bottom: 4,
trailing: 8
});
// or this
// cfg.contentInsets = NSDirectionalEdgeInsetsFromString("{ top: 4, leading: 8, bottom: 4, trailing: 8 }");
View bounds maxY utilities
Swift may use various min/max utilities like this:
view.bounds.maxY
AI may believe you can use that as a property of bounds. We can instead use c functions for the same thing in TypeScript provided by NativeScript:
CGRectGetMaxY(view.bounds)
Constraint anchors
Swift may have layout constraints setup like this:
morphContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
AI may present you with invalid NativeScript like this:
morphContainer.leadingAnchor.constraintEqualToAnchor(this.view.leadingAnchor, 20),
It's close but because it contains a constant as well, it's actually like this:
morphContainer.leadingAnchor.constraintEqualToAnchorConstant(this.view.leadingAnchor, 20)
Bounds or Size
Swift may use bounds to get the width:
view.bounds.width
AI may believe you can just use that but in NativeScript it's from the size
property on bounds:
view.bounds.size.width
Platform types imported vs global
AI may often suggest the following:
import {
NativeClass,
UIViewController,
UIView,
CGAffineTransformIdentity,
CGAffineTransformTranslate,
CGAffineTransformScale,
CGRectContainsPoint,
UIViewAnimationOptions,
UIColor,
} from "@nativescript/core";
However with NativeScript currently those are just provided automatically on global scope via the often used references.d.ts
file which brings in @nativescript/types
so you can simply delete those if mentioned.
It's often debated on whether future NativeScript versions should switch that up -- see more in this RFC here.
To write in Swift or TypeScript?
The goal of NativeScript is to give you the ability to achieve truly remarkable outcomes with the degree of flexibility projects demand, no matter if it's a super tight deadline or multi-year effort. Meaning if language barriers are an issue, you have tons of excellent options with NativeScript. At the end of the day, the choice is yours -- use a combination of both Swift and TypeScript or just one or the other. You decide what your project needs based on your teams skill and availability.
Try it on Stackblitz!
You can run this right now on Stackblitz: https://stackblitz.com/edit/morph-modal-kit-as-total-typescript?file=src%2Fmorph-modal-kit%2FExamples%2FMenuModal.ts