Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can I mix UIKit and TVMLKit within one app?

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.

like image 982
Rafa de King Avatar asked Oct 23 '15 14:10

Rafa de King


3 Answers

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)
like image 77
shirefriendship Avatar answered Sep 18 '22 21:09

shirefriendship


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)
like image 40
Jason Newell Avatar answered Sep 22 '22 21:09

Jason Newell


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)
    }
like image 33
Sverrisson Avatar answered Sep 19 '22 21:09

Sverrisson