/WidgetKit

Compose native apps without a code using JSON and load them as NSBundle into another app dynamicly from local or remote locations.

Primary LanguageSwiftMIT LicenseMIT

WidgetKit for iOS   Tweet

Platform Twitter Carthage compatible

WidgetKit framework allows you to compose native apps without a code and load them as NSBundle into another app dynamicly from local or remote locations. No executables will be downloaded and loaded into memory, because widgets contain none of them. All logic and data flow are based on NSPredicate and NSExpression.

WidgetKit uses technique of mediating controllers, which were introduced in Cocoa Bindings (NSController and its descendants in AppKit for macOS). Although, this is not direct port of it. Read more about this here.

WidgetKit view controllers consist of predefined and 100% reusable objects (NSObjects), or mediating controllers, which control presentation of views inside their view controller. You put these mediator objects onto the view controllers' scene in the Interface Builder, set their properties via User Defined Runtime Attributes and connect outlets between them and your UI elements. Or, alternatively, you can load all this setup through corresponding JSON files, and that's what will be described in this document.

Features:

  • Bind UIKit elements to model's fields with data formatting;
  • Handle received JSON data and parse it directly to the CoreData (in the background);
  • Send forms (with media content) to any http server using plain UIKit controls;
  • Populate UITableView and UICollectionView with various types of data (JSON, array of NSObject or NSManagedObject);
  • Handle UIControl's interactions, including infinite scroll and pull to refresh;
  • Filter content in UITableView and UICollectionView via text input fields using predicates;
  • Delete content with respecting its ownership. You set ownership rules in CoreData Model Designer;
  • Propagate content object between view controllers on segue;
  • Control presentation of particular UI elements in the view controller when specific data changes;
  • Calculate views geometry in the background for faster scrolling;

WidgetKit is not a set of ready-to-use views and view controllers. Your UI is completly under your control.

Examples

  • WidgetDemo - main example, open it to follow explanations below.
  • WidgetHostDemo - widgets loader. This example is on the picture below and uses loading code similar to what is used in the beginning of the Usage section.

To install, download this repo, in Terminal go to the "Samples/***Demo" directory and run pod install command.

  • TwitterDemo - complex "real life" example with custom code integration. You can download it here.

WidgetHostDemo.GIF

Installation

CocoaPods

To install via CocoaPods add this line to your Podfile:

pod 'WidgetKit'

Then run pod install command.

Carthage

To install via Carthage add this to your Cartfile:

github "faviomob/WidgetKit"

Run carthage update --platform iOS to build the frameworks and drag built "*.framework" files into your Xcode project. Then open Build Phases section of your target and add new Copy Files Phase by pressing "+" button at the top left corner. Choose Frameworks as destination and add all the frameworks you've just dragged into the project.

Usage

First, let's see how you can integrate external NSBundle to yout host application. Drag new UIView to your view controller and set its custom class to WidgetView. Create @IBOutlet for this view in your view controller. Also drag UIButton and create @IBAction for it. The whole setup should look like this:

import WidgetKit

class MyHostViewController: UIViewController {
    
    @IBOutlet var remoteWidgetView: WidgetView!

    @IBAction func downloadAction(_ sender: UIButton) {
        sender.isEnabled = false
        remoteWidgetView.download(url: "https://<address>/YourWidget.zip") { widget, error in
            sender.isEnabled = true
        }
    }
}

You will also need to set widgetIdentifier for the widget view so, that it starts with bundleIdentifier of your widget's NSBundle. As alternative to the listing above, you can just set downloadUrl property of your widget view, but you will not be able to track failure or success manually in this case. All these properties you can set in the viewDidLoad method or in the User Defined Runtime Attributes section of the Interface Bulder. You can add as many widgets to the host view as you want, but all of them should have different widgetIdentifier.

Building a Widget

Now, let's see how you can create widget app itself. The easiest way to understand how things work is to open the WidgetDemo sample project and run it.

Concepts

WidgetDemo project contains Main.storyboard file, CoreData Model file and a couple of JSON files: one for each view controller, and one for setting up your networking stack.

Let's look at the "FeedViewController.json" and "NewPostViewController.json" files, which contain setup for our view controllers.

To load this type of files, the view contoller itself should be of ContentViewController custom class or its descendant. Also, the restorationIdentifier should be set to the name of the JSON file (without extension).

There are four base types of mediator objects you can create in this file, and all of them are inherited from CustomIBObject which in its turn is descendant of the NSObject:

  • BaseContentProvider
  • BaseDisplayController
  • ActionController
  • ActionStatusController

For each particular purpose you create one of the descendants of these four major types. For example, for displaying data inside UITableView you should choose TableDisplayController, and for setting view controllers' content object (which will update all binded UI controls), you create ContentDisplayController. For fetching data from CoreData store you connect these BaseDisplayController objects to the ManagedObjectsProvider content provider.

ActionController is an object, that can call some network action that you describe in your networking JSON, or it can call some selector if you set target outlet to it. In case when selector not found, it will try to call network action with the same name instead. The moment, when action happens depends on the concrete descendant of the ActionController. For example, for handling buttons pressing you create ButtonActionController and connect your button to the sender outlet, and OnLoadActionController will be triggered after the viewDidLoad.

More close look on the networking you will find in the Networking section of this document, but in brief, you have one JSON file (actually two: development and production) with a section for each named action, where you put path, httpMethod, parameters, resultType and other attributes of the network call. All parameters are automatically taken and substituted from ActionController. Also you have one common section, where you store your baseUrl and other defaults. After the network request completes, its response will be automatically parsed into CoreData objects in the backgound. The name of the class of objects created you set in the resultType.

Each action controller contains ActionStatusController in its status variable. So you can bind to status.inProgress key path and update your labels and activity indicators accordingly. You can also have independent ActionStatusController object for situations when action was initiated outside of the current view controller. For example, after loading current user, an action of loading feed content will be called automatically as a chain call (read about chained network calls below). Thus, to track the state of this call you need to create separate ActionStatusController object.

Fetch & Display

Ok, enough theory, let's move to our example. If you open "FeedViewController.json" you will find two sections in the root node: objects and elements. The first one should contain mediator objects, and the second one contains bindings for UI elements. You can also use this section to set initial values for properties (see attrs) the same way you do it in the User Defined Runtime Attributes, but additionally arrays are supported.

The widget starts its work from loading current user. In this sample we omit the authentication part of the networking layer, and assume that we already authenticated. To know how to setup real networking with complex authentication process check the TwitterDemo sample app.

To load the current user when widget starts we need OnLoadActionController object:

"currentUserAction": {
    "type": "OnLoadActionController",
    "attrs": {
        "actionName": "currentUser"
    }
}

This will initiate the call to the endpoint named "currentUser" and, upon receiving response JSON, Account managed object will be created, because the resultType attribute of the "currentUser" action was set as "Account" (see Networking section below).

Now we need to fetch this current user account from the local storage. ManagedObjectsProvider is used for this:

"currentUserProvider": {
    "type": "ManagedObjectsProvider",
    "attrs": {
        "entityName": "Account",
        "resultChain": [
            "wx_first"
        ]
    }
}

This object fetches all records with class name Account and takes the first one (and only one should actually exist).

resultChain is an array of functions you can apply to the fetched data set (you can read the full description of the resultChain in the source comments). All in all, we now have our account object and we want to display the name of a user in the title of our view controller. That's what BaseDisplayController is for. But in this case we need its descendant ContentDisplayController:

"currentUserDisplayController": {
    "type": "ContentDisplayController",
    "outlets": {
        "mainContentProvider": "currentUserProvider"
    }
}

As you can see, we use JSON object id as a reference across the entire view controller. When the "currentUserProvider" has a new data, it asks its consumer (which is "currentUserDisplayController" in this case) to render its content. For the ContentDisplayController it means setting the new value for the content property of its view controller. What in its turn causes an update for all UI elements, that have bindings. We have only two elements with bindings for this view controller: "titleLabel" and "footerActivityIndicator" (others are for UITableViewCell). Let's look at the "titleLabel" first:

"titleLabel": {
    "bindings": [
        {
            "to": "text",
            "predicateFormat": "content == nil",
            "ifTrue": "",
            "ifFalse": "$content.name"
        }
    ]
}

Here you can see how logic can be integrated within the widget app - predicateFormat has a standard syntax of the NSPredicate(format:) and is evaluated against the scope of this view controller. So, if the value of content property for this view controller is equal to nil (i.e. there is no current user), then text property of the label will be set to the ifTrue expression value (empty string). Otherwise, it will be set to the result of the content.name substitution, i.e. name of the user.

For all UI elements, that we refer here, string identifier must be set via wx.identifier property in the User Defined Runtime Attributes section of the Interface Bulder.

Fine, now we have our view controller set with content object, and all elements are updated. Let's see then how we can populate our UITableView. First, we need to fetch data for our table, that's what the "homeFeedContentProvider" is for:

"homeFeedContentProvider": {
    "type": "ManagedObjectsProvider",
    "attrs": {
        "entityName": "Post",
        "predicateFormat": "favoritesCount > 0",
        "sortByFields": "timestamp",
        "sortAscending": false
    }
}

Here we select all Post records with favoritesCount greater then zero and sort them descending by timestamp. To show these fetched objects in our table we need TableDisplayController:

"tableDisplayController": {
    "type": "TableDisplayController",
    "outlets": {
        "tableView": "tableView",
        "mainContentProvider": "homeFeedContentProvider",
        "searchController": "searchController",
        "emptyDataView": "emptyDataView"
    }
}

You can see outlets, that connect our table with wx.identifier equal to "tableView" to the "tableDisplayController" and mainContentProvider connected to "homeFeedContentProvider". When "homeFeedContentProvider" got some changes in its fetched results, it asks "tableDisplayController" to render its content, that in case of TableDisplayController means reload connected tableView.

To fullfil UITableView cells automatically, you need to set their custom class to ContentTableViewCell and create bindings for UI elements inside the cell. We have four elements in the bindings section for our cell: "avatarView", "textLabel", "authorLabel" and "timeLabel". The structure for this section is flat, so identifiers inside repeatable content elements, such as table or collection view cells should not intersect with the view controllers elements. Let's look at the binding for "timeLabel" element:

"timeLabel": {
    "bindings": [
        {
            "placeholder": "--s",
            "from": "content.timestamp",
            "transformer": "ago"
        }
    ]
}

It hasn't to field, that means NSObject.wx_value property by default, which is overriden for each UI class and equal to text property for UILabel. Also, it has a transformer field, where you can refer to your custom NSValueTransformer, or some of the transformers, provided by this framework, f.e. ago, that just shows the amount of time passed (hours, days etc.) with a proper localization. All evaluations inside cells are, of cource, performed in the scope of the cell: content object is taken from ContentTableViewCell.content, not from this view controller's content.

Search

There are two other outlets in our "tableDisplayController": emptyDataView and searchController. The first one is just a view that will show up when there is no data to display in the table view. And the second one deserves more detailed explanation. First, look at its listing:

"searchController": {
    "type": "SearchActionController",
    "attrs": {
        "actionName": "searchPosts",
        "filterFormat": "text CONTAINS[cd] $input"
    },
    "outlets": {
        "sender": "searchBar"
    }
}

As you can see, "searchController" is a descendant of the ActionController, that can provide search capability to the table view, connected to the same "tableDisplayController". When you start typing in the "searchBar", "tableDisplayController" replaces its content provider with the content provider of the "searchController" (which is initially the same by default) and adds additional condition in filterFormat to the fetch request's NSPredicate. The value of $input is automatically taken from the "searchBar" text property.

Also, if you set actionName property, besides just filtering local data, it will ask network layer to make requests. It doesn't flood with requests while you typing, firing events only after you make a short typing pause. You can set this timeout adjusting actionThrottleInterval property. After search request completes, its response will be automatically parsed into CoreData objects, and if there are new items available, they will be immidiatly displayed in the table view via ManagedObjectsProvider->TableDisplayController connection.

Tracking State

The last object in our file is the "homeFeedStatusController":

"homeFeedStatusController": {
    "alias": "homeFeedStatus",
    "type": "ActionStatusController",
    "attrs": {
        "actionName": "homeFeed",
        "errorMessage": "Failed to load feed!"
    }
}

Its main purpose is to track status of the "homeFeed" network action, because it is started indirectly after "currentUser" action. Moreover, providing dedicated ActionStatusController you have an ability to set an errorMessage, which will be shown to the user in case of action failure. Let's look how we can update activity indicators in our view controller with the help of ActionStatusController:

"footerActivityIndicator": {
    "bindings": [
        {
            "predicateFormat": "currentUserAction.status.inProgress == 1 OR homeFeedStatus.inProgress == 1"
        }
    ]
}

In this case, we have left all the attributes of this binding to their default values: to defaults to UIActivityIndicatorView.wx_value, which is UIActivityIndicatorView.isAnimating under the hood, and ifTrue / ifFalse defaults to true / false respectively. As you can see we refer to the "homeFeedStatusController" by its alias. Thus, "footerActivityIndicator" starts animating when either "currentUser" or "homeFeed" actions are in progress and stops when nothing in progress. Worth to mention, that bindings for elements of the ActionStatusController are refreshed before and after the action call (if elements outlets are not connected - everything refreshed).

Error Handling

By default ActionStatusController will display errorMessage in the UIAlertController. Additionally you can provide errorTitle. But you can alter this behavior by overriding ContentViewController.handleError method:

class MyViewController: ContentViewController {

    override open func handleError(_ error: Error, sender: ActionStatusController) {
        // oops! it happened!
    }
}

Of course, you must include this code into your host application, as widgets should not contain any code at all.

Networking

Finally, let's dig little bit deeper into networking with WidgetKit. First, look at a special @self element in the bindings section. It used to set properties and their bindings for the current view controller:

"@self": {
    "attrs": {
        "serviceProviderClassName": "StubServiceProvider"
    }
}

Service provider is just a NSObject conforming to ServiceProviderProtocol. By default, StandardServiceProvider is used, but you can override it for each view controller and for each ActionController as well. You do this by setting serviceProviderClassName property for the view controller, and serviceProvider or serviceProviderClassName properties for ActionController.

Service provider got its configuration from the object, conforming to ServiceConfigurationProtocol. StandardServiceProvider uses StandardServiceConfiguration, which loads its setup from service description JSON file - "Service.json" (or "Service.dev.json" for development configuration).

Here we use StubServiceProvider, a dummy provider, that just responds for each action name with predefined JSON objects from "StubService.json". Also, it shows how you can setup an actual service. Let's start from the defaults section:

"defaults": {
    "baseUrl": "https://demo.io/v1",
    "httpMethod": "GET",
    "resultKeyPath": ""
}

Every parameter here can be overriden in the actions section, except baseUrl. But you can use full address in the path attribute of each action. resultKeyPath is a key path in the response dictionary to access actual data. Empty string or absence means that response will be parsed "as is".

Let's move to the actions section:

"actions": {
    "currentUser": {
        "path": "me",
        "resultType": "Account",
        "nextAction": "homeFeed"
    },
    "homeFeed": {
        "resultType": "[Post]",
        "clearPolicy": "after",
        "parameters": {
            "count": 20
        }
    },
    "newPost": {
        "path": "new",
        "httpMethod": "POST",
        "resultType": "Post",
        "parameters": {
            "message": "$text"
        }
    }
}

Note the nextAction attribute of the "currentUser" action. This is a "chain call". Chain call are only performed in a case of success of the previous call and takes a parsed object as its argument. If the previous call returned an array, no argument will be passed to the next action. So, the parsed object of the "currentUser" is an Account instance, so it will be passed to the "homeFeed" action. But it's just not used there for any substitutions.

Now look at the resultType of "homeFeed" action. The class name is enclosed in square braces. It means that response will contain an array. clearPolicy is used to clear old data before parsing a new one. after means cleaning after request (but before parse), so user will see immidiate replacement of the data, and before means that the old data will be removed before request is started, so user will see a blank screen. parameters contains count, which will be packed to the request's body (it uses Alamofire.URLEncoding for this). If you want to pass parameters in the url, put them in the path ($ substitution notation supported).

Forms

And we arrived to the culmination of the networking topic - forms submission! Form submission is a breeze with WidgetKit. You just need to set a custom class to FormDisplayView for a form view and connect mandatoryFields / optionalFields outlets. Also you will need to set wx_fieldName for each form field in the Interface Builder - it will be used as a key in the form's dictionary ("text" in this sample). To see how it works open the second view controler's JSON file - "NewPostViewController.json". You will find this object there:

"newPostActionController": {
    "type": "BarButtonActionController",
    "attrs": {
        "actionName": "newPost"
    },
    "outlets": {
        "form": "formView",
        "sender": "postButton"
    }
}

And this outlet connection:

"formView": {
    "outlets": {
        "mandatoryFields": [
            "textView"
        ]
    }
}

"newPostActionController" connects itself to the button with "postButton" identifier and the form with "formView" identifier. Thus, when you press the button, BarButtonActionController asks FormDisplayView to collect fields values into the dictionary. The values of fields are taken from the UIView.wx_fieldValue property, which is overriden for every UIView descendant, that can be used as a form field (equals to UITextView.text for "textView").

If any of the mandatoryFields occurs to be empty, the appropriate view will be "shaked" by default, but you can change this behavior by overriding FormDisplayView.highlightField method. If all values are good, "newPost" action will be called with the value of "text" field name substituted in the parameters dictionary.

Your service should return the newly created object, otherwise it will not be displayed, because local object creation is not yet supported.

Dates

The last thing I would like to discuss is processing of dates. You can receive various type of date strings from different services. So you might need to provide a proper convertion for them into the Date object. You do this by installing date transformer in the options section of your service description JSON:

"options": {
    "transformers": {
        "date": {
            "strToDate": {
                "format": "EEE MMM d HH:mm:ss Z y",
                "locale": "en_US_POSIX"
            }
        }
    },
    "debugDelay": 0.25
}

Then you refer this transformer by its id in CoreData Model Designer as described here.

debugDelay is used by StubServiceProvider for simulation of a loading process, so you can check how your UI looks like during network delays.

Deploy

After you finish all layers of your widget, you will need to properly pack it into the bundle. Thus, build your app as usual, navigate to "Products" group in the Xcode Project Navigator and choose "Show in Finder" in the context menu. Now press cmd+i and replace ".app" extension with ".bundle". Then open context menu of this file in Finder and choose "Show Package Contents". It will throw you into the bundle's folder. Now you need to remove everything, that somehow related to an executable code:

app itself (no extension), "Frameworks" folder, any "*.dylib" files, "PkgInfo" file, "CodeSignature" folder.

You can leave only ".json", ".momd", ".car", ".storyboardc", "*.nib" files, any images and "Info.plist".

After this total cleanup, you can include this bundle into your host application directly and load it via WidgetView.load(resource:) method, or zip it and upload to a remote server, where it can be accessed via http.

That's pretty it! I would very appreciate your feedback and contribution.

P.S. Don't forget to check the TwitterDemo - it provides "real world" example with much more sofisticated setup: you will learn how to make conditional actions, pull to refresh and infinite scroll, upload images, delete content respecting ownership and many other tricks, including integration with your custom code.

License

MIT, Copyright (c) 2018 Favio Mobile

Contacts

You can reach me via faviomob@gmail.com