Can I mix UIKit and TVMLKit within one app?
Asked Answered
C

4

18

I'm exploring tvOS and I found that Apple offers nice set of templates written using TVML. I'd like to know if a tvOS app that utilises TVML templates can also use UIKit.

Can I mix UIKit and TVMLKit within one app?

I found a thread on Apple Developer Forum but it does not fully answer this question and I am going through documentation to find an answer.

Curare answered 23/10, 2015 at 14:52 Comment(0)
C
33

Yes, you can. Displaying TVML templates requires you to use an object that controls the JavaScript Context: TVApplicationController.

var appController: TVApplicationController?

This object has a UINavigationController property associated with it. So whenever you see fit, you can call:

let myViewController = UIViewController()
self.appController?.navigationController.pushViewController(myViewController, animated: true)

This allows you to push a Custom UIKit viewcontroller onto the navigation stack. If you want to go back to TVML Templates, just pop the viewController off of the navigation stack.

If what you would like to know is how to communicate between JavaScript and Swift, here is a method that creates a javascript function called pushMyView()

func createPushMyView(){

    //allows us to access the javascript context
    appController?.evaluateInJavaScriptContext({(evaluation: JSContext) -> Void in

        //this is the block that will be called when javascript calls pushMyView()
        let pushMyViewBlock : @convention(block) () -> Void = {
            () -> Void in

            //pushes a UIKit view controller onto the navigation stack
            let myViewController = UIViewController()
            self.appController?.navigationController.pushViewController(myViewController, animated: true)
        }

        //this creates a function in the javascript context called "pushMyView". 
        //calling pushMyView() in javascript will call the block we created above.
        evaluation.setObject(unsafeBitCast(pushMyViewBlock, AnyObject.self), forKeyedSubscript: "pushMyView")
        }, completion: {(Bool) -> Void in
        //done running the script
    })
}

Once you call createPushMyView() in Swift, you are free to call pushMyView() in your javascript code and it will push a view controller onto the stack.

SWIFT 4.1 UPDATE

Just a few simple changes to method names and casting:

appController?.evaluate(inJavaScriptContext: {(evaluation: JSContext) -> Void in

and

evaluation.setObject(unsafeBitCast(pushMyViewBlock, to: AnyObject.self), forKeyedSubscript: "pushMyView" as NSString)
Cantabile answered 4/11, 2015 at 20:31 Comment(6)
Fantastic! This answered more questions than the question asked for, such as interops between JS and Swift in this context. It would appear that all javascript functions (such as this example) should be injected prior to calling the application.js. Fair?Shererd
@FrankC. I'm glad this helped you! Yes, since this method essentially injects javascript into the context, you need to call createPushMyView() sometime before you call pushMyView(). If you attempt to call pushMyView() in JS without ever calling createPushMyView() in Swift, you'll be making a call to an undefined object and it will not work.Cantabile
@Cantabile This is a great answer, thanks a lot. I just want to point out although this push function is just an example of what you explained such functions of that nature should already be created on the JS backend side beforehand. As seen from other examples and tutorials.Antoineantoinetta
if I could have voted this answer to infinity I would have done it twice!!Francisco
I create and after creating I call the createPushMyView func inside the application func in AppDelegate, before doing anything else, and I am still unable to call the pushMyView() func from JS. What am I doing wrong?Ferrara
Can you show how and where the TVApplicationController is instantiated?Faso
C
7

As mentioned in the accepted answer, you can call pretty much any Swift function from within the JavaScript context. Note that, as the name implies, setObject:forKeyedSubscript: will also accept objects (if they conform to a protocol that inherits from JSExport) in addition to blocks, allowing you to access methods and properties on that object. Here's an example

import Foundation
import TVMLKit

// Just an example, use sessionStorage/localStorage JS object to actually accomplish something like this
@objc protocol JSBridgeProtocol : JSExport {
    func setValue(value: AnyObject?, forKey key: String)
    func valueForKey(key: String) -> AnyObject?
}

class JSBridge: NSObject, JSBridgeProtocol {
    var storage: Dictionary<String, String> = [:]
    override func setValue(value: AnyObject?, forKey key: String) {
        storage[key] = String(value)
    }
    override func valueForKey(key: String) -> AnyObject? {
        return storage[key]
    }
}

Then in your app controller:

func appController(appController: TVApplicationController, evaluateAppJavaScriptInContext jsContext: JSContext) {
    let bridge:JSBridge = JSBridge();
    jsContext.setObject(bridge, forKeyedSubscript:"bridge");
}

Then you can do this in your JS: bridge.setValue(['foo', 'bar'], "baz")

Not only that, but you can override views for existing elements, or define custom elements to use in your markup, and back them with native views:

// Call lines like these before you instantiate your TVApplicationController 
TVInterfaceFactory.sharedInterfaceFactory().extendedInterfaceCreator = CustomInterfaceFactory() 
// optionally register a custom element. You could use this in your markup as <loadingIndicator></loadingIndicator> or <loadingIndicator /> with optional attributes. LoadingIndicatorElement needs to be a TVViewElement subclass, and there are three functions you can optionally override to trigger JS events or DOM updates
TVElementFactory.registerViewElementClass(LoadingIndicatorElement.self, forElementName: "loadingIndicator")

Quick custom element example:

import Foundation
import TVMLKit

class LoadingIndicatorElement: TVViewElement {
    override var elementName: String {
        return "loadingIndicator"
    }

    internal override func resetProperty(resettableProperty: TVElementResettableProperty) {
        super.resetProperty(resettableProperty)
    }
    // API's to dispatch events to JavaScript
    internal override func dispatchEventOfType(type: TVElementEventType, canBubble: Bool, cancellable isCancellable: Bool, extraInfo: [String : AnyObject]?, completion: ((Bool, Bool) -> Void)?) {
        //super.dispatchEventOfType(type, canBubble: canBubble, cancellable: isCancellable, extraInfo: extraInfo, completion: completion)
    }

    internal override func dispatchEventWithName(eventName: String, canBubble: Bool, cancellable isCancellable: Bool, extraInfo: [String : AnyObject]?, completion: ((Bool, Bool) -> Void)?) {
        //...
    }
}

And here's how to set up a custom interface factory:

class CustomInterfaceFactory: TVInterfaceFactory {
    let kCustomViewTag = 97142 // unlikely to collide
    override func viewForElement(element: TVViewElement, existingView: UIView?) -> UIView? {

        if (element.elementName == "title") {
            if (existingView != nil) {
                return existingView
            }

            let textElement = (element as! TVTextElement)
            if (textElement.attributedText!.length > 0) {
                let label = UILabel()                    

                // Configure your label here (this is a good way to set a custom font, for example)...  
                // You can examine textElement.style or textElement.textStyle to get the element's style properties
                label.backgroundColor = UIColor.redColor()
                let existingText = NSMutableAttributedString(attributedString: textElement.attributedText!)
                label.text = existingText.string
                return label
            }
        } else if element.elementName == "loadingIndicator" {

            if (existingView != nil && existingView!.tag == kCustomViewTag) {
                return existingView
            }
            let view = UIImageView(image: UIImage(named: "loading.png"))
            return view // Simple example. You could easily use your own UIView subclass
        }

        return nil // Don't call super, return nil when you don't want to override anything... 
    }

    // Use either this or viewForElement for a given element, not both
    override func viewControllerForElement(element: TVViewElement, existingViewController: UIViewController?) -> UIViewController? {
        if (element.elementName == "whatever") {
            let whateverStoryboard = UIStoryboard(name: "Whatever", bundle: nil)
            let viewController = whateverStoryboard.instantiateInitialViewController()
            return viewController
        }
        return nil
    }


    // Use this to return a valid asset URL for resource:// links for badge/img src (not necessary if the referenced file is included in your bundle)
    // I believe you could use this to cache online resources (by replacing resource:// with http(s):// if a corresponding file doesn't exist (then starting an async download/save of the resource before returning the modified URL). Just return a file url for the version on disk if you've already cached it.
    override func URLForResource(resourceName: String) -> NSURL? {
        return nil
    }
}

Unfortunately, view/viewControllerForElement: will not be called for all elements. Some of the existing elements (like collection views) will handle the rendering of their child elements themselves, without involving your interface factory, which means you'll have to override a higher level element, or maybe use a category/swizzling or UIAppearance to get the effect you want.

Finally, as I just implied, you can use UIAppearance to change the way certain built-in views look. Here's the easiest way to change the appearance of your TVML app's tab bar, for example:

 // in didFinishLaunching...
 UITabBar.appearance().backgroundImage = UIImage()
 UITabBar.appearance().backgroundColor = UIColor(white: 0.5, alpha: 1.0)
Changeup answered 5/3, 2016 at 11:17 Comment(0)
K
6

If you already have a native UIKit app for tvOS, but would like to extend it by using TVMLKit for some part of it, You can.

Use the TVMLKit as a sub app in your native tvOS app. The following app shows how to do this, by retaining the TVApplicationController and present the navigationController from the TVApplicationController. The TVApplicationControllerContext is used to transfer data to the JavaScript app, as the url is transferred here :

class ViewController: UIViewController, TVApplicationControllerDelegate {
    // Retain the applicationController
    var appController:TVApplicationController?
    static let tvBaseURL = "http://localhost:9001/"
    static let tvBootURL = "\(ViewController.tvBaseURL)/application.js"

    @IBAction func buttonPressed(_ sender: UIButton) {
        print("button")

        // Use TVMLKit to handle interface

        // Get the JS context and send it the url to use in the JS app
        let hostedContContext = TVApplicationControllerContext()
        if let url = URL(string:  ViewController.tvBootURL) {
            hostedContContext.javaScriptApplicationURL = url
        }

        // Save an instance to a new Sub application, the controller already knows what window we are running so pass nil
        appController = TVApplicationController(context: hostedContContext, window: nil, delegate: self)

        // Get the navigationController of the Sub App and present it
        let navc = appController!.navigationController
        present(navc, animated: true, completion: nil)
    }
Killing answered 11/12, 2016 at 21:24 Comment(1)
Thanks, was looking for something in this direction. All the other answers were about the reverse (adding tvOS to TVMLKit)Faustofaustus
Q
-2

Yes. See the TVMLKit Framework, whose docs start with:

The TVMLKit framework enables you to incorporate JavaScript and TVML files in your binary apps to create client-server apps.

From a quick skim of those docs, it looks like you use the various TVWhateverFactory classes to create UIKit views or view controllers from TVML, after which you can insert them into a UIKit app.

Queensland answered 24/10, 2015 at 16:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.