Morphing Swift to 100% TypeScript with NativeScript + AI

Build Swift in a web browser? Well TypeScript can so let's do exactly that using NativeScript.

Nathan Walker
Posted on

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.

Using ChatGPT to convert Swift to 100% TypeScript with NativeScript

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:

ts
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:

ts
@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:

ts
@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:

ts
@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:

ts
@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:

swift
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:

ts
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:

swift
let sticky = ScrollStickyElements()

In NativeScript, we can init native instances several ways but the most common are:

ts
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.

ts
@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.

ts
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:

ts
@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.

swift
public contextDidChange(
  to newOwner: ModalView,
  from oldOwner: ModalView | null,
  animated: boolean
): void {
  
}

In TypeScript we just drop the custom argument labels:

ts
public contextDidChange(
  newOwner: ModalView,
  oldOwner: ModalView | null,
  animated: boolean
): void {
  
}

CACornerMask enum

Various enums in Swift may use shorthands that look like this:

swift
gradientContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]

With NativeScript, the strong typing tells us it's just this:

ts
gradientContainer.layer.maskedCorners = CACornerMask.kCALayerMinXMinYCorner | CACornerMask.kCALayerMaxXMinYCorner;

NSArray properties

In Swift you may have something like this:

swift
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:

ts
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

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.

ts
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.

swift
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.

ts
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:

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

ts
tv.textContainerInset = UIEdgeInsetsMake(32, 20, 32, 20);

There is no UIEdgeInsetsMake. We can do the same with this though:

ts
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:

swift
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.

ts
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:

ts
@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.

ts
@NativeClass()
export class StickyElements extends UIView {

  private onBack(): void {

  }

  private onNext(): void {

  }
}

Text attribute transformers

Swift may have text attribute transformers like this:

swift
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:

ts
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:

ts
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:

swift
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:

ts
const cfg = UIButton.Configuration.plainConfiguration();
cfg.contentInsets = UIEdgeInsetsMake(4, 8, 4, 8);

We can just correct the type usage as follows:

ts
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:

swift
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:

ts
CGRectGetMaxY(view.bounds)

Constraint anchors

Swift may have layout constraints setup like this:

swift
morphContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),

AI may present you with invalid NativeScript like this:

ts
morphContainer.leadingAnchor.constraintEqualToAnchor(this.view.leadingAnchor, 20),

It's close but because it contains a constant as well, it's actually like this:

ts
morphContainer.leadingAnchor.constraintEqualToAnchorConstant(this.view.leadingAnchor, 20)

Bounds or Size

Swift may use bounds to get the width:

swift
view.bounds.width

AI may believe you can just use that but in NativeScript it's from the size property on bounds:

ts
view.bounds.size.width

Platform types imported vs global

AI may often suggest the following:

ts
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


More from our Blog