Updating from an API Walkthrough

The following is an example using parts of a Lightwell screen to implement a popup populated by dynamic content responding to a url request.

Before we begin, let’s setup an Xcode project to use as a base template. Note that while all of the functionality below can be included into many other setups, for this tutorial we will build a simple use case around a UIViewController. This will let us focus on the SDK functionality.

From Xcode create a new iOS, Single View App. Download this folder of assets. Include the asset catalog, and include and link the HullabaluStoryKit framework.

At this point if we run the application we should see a blank white screen.

Let’s begin by editing the default ViewController.

First we need to initialize a loading context to read the Lightwell data. We’ll add this as a property on the view controller so we can reference the actions from multiple entry points and preserve all animation targeting data.

Building the Foundation

let loadingContext = LWKLoadingContext(screenName: "promo")

The second is an API call to get the most up to date information and provide a hook to update the contents based on that response.


    func getRemoteData() {
        // Set api url
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        // Set method
        request.httpMethod = "GET"
        // Initialize data task
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            /* Next steps */
        }

        // Trigger API call
        task.resume()
    }
}

Bringing the two pieces together and adding a trigger for the API call:

// 0) Import the SDK
import LightwellKit

class ViewController: UIViewController {
    // 1) initialize the loading context for the promo popup
    let loadingContext = LWKLoadingContext(screenName: "promo")

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // Add a trigger to begin the asynchronous api call
        self.getRemoteData()
    }
}

extension ViewController {
    // 2) Implement web call
    func getRemoteData() {
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            /* Next steps */
        }

        task.resume()
    }
}

At this point we’ve updated our single view controller to call out to an API once it appears, and we have hooks to add on our Lightwell integration.

Adding Components to Screen

The LWKLoadingContext gives us a way to access the raw layer and animation data from the design document. It also provides accessors for default generated UIViews and CAAnimations, or a convenient wrapper LWKAction. You can use as much or as little from the document as you want. For our use case, we’re going to use some of the default generated UIViews and LWKActions:

// Only grab the views we care about in the design from Lightwell.
// 1) Add background overlay to screen
if let background = self.context?.view(for: "background") {
    background.frame = self.view.bounds
    self.view.addSubview(background)
}
// 2) Add popup to screen
if let popup = self.context?.view(for: "popup") {
    self.view.addSubview(popup)
}

// 3) Show the popup by running the animation action
self.context?.action(for: "show popup")?.run()

We can add this to our API response. Since we are updating the UI, this will need to be performed on the main thread.

import HullabaluStoryKit

class ViewController: UIViewController {
    let loadingContext = LWKLoadingContext("promo")

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.getRemoteData()
    }
}

extension ViewController {
    func getRemoteData() {
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            // Jump back to the main thread.
            DispatchQueue.main.async {
                // 1) Add background overlay to screen
                if let background = self.context?.view(for: "background") {
                    background.frame = self.view.bounds
                    self.view.addSubview(background)
                }
                // 2) Add popup to screen
                if let popup = self.context?.view(for: "popup") {
                    self.view.addSubview(popup)
                }

                // 3) Show the popup by running the animation action
                self.context?.action(for: "show popup")?.run()
            }
        }

        task.resume()
    }
}

If we run the applicaiton at this point, we should see the white screen from before. Then after however long the api call takes, we should see a popup with some static content animate in.

Interacting with the Popup

Now that we have all of the pieces we need, we can start to add some interactions to dismiss the popup or follow through on the example promo. For this use case we can utilize UIKit‘s built in tap gesture recognizers to add a tap outside of the popup to dismiss, and a tap on the popup’s button to follow through:

// 1) Add a gesture to dissmiss the popup
self.context?.view(for: "background")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))

// 2) Add a gesture to click through on the popup
self.context?.view(for: "button")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

Next we need to add the functions for the gesture recognizers to call. These can be as simple or as complex as needed to handle all of the intended behavior. For now they’ll either play a dismissal animation or a follow animation:

// 1) Add hook for dismissing the popup
@objc
func dismissPopup() {
    // Hide the popup and background
    context?.action(for: "dismiss popup")?.run()
}

// 2) Add hook for following through with the popup's call to action
@objc
func respondToButton() {
    // Play 'success' animation
    context?.action(for: "follow button")?.run()
}

Putting it all in context:

extension ViewController {
    func getRemoteData() {
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in

            DispatchQueue.main.async {
                if let background = self.context?.view(for: "background") {
                    background.frame = self.view.bounds
                    self.view.addSubview(background)
                }
                if let popup = self.context?.view(for: "popup") {
                    self.view.addSubview(popup)
                }

                // 1) Add gestures to respond to promo
                self.context?.view(for: "background")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))
                self.context?.view(for: "button")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

                self.context?.action(for: "show popup")?.run()
            }
        }

        task.resume()
    }

    // 2) Add selectors for gestures to call
    @objc
    func dismissPopup() {
        // Hide the popup and background
        context?.action(for: "dismiss popup")?.run()
    }

    @objc
    func respondToButton() {
        // Play 'success' animation
        context?.action(for: "follow button")?.run()
    }
}

If we run the application at this point, we can tap on the dark background overlay or the button at the bottom of the popup to animate the popup off screen.

Customizing the Views with Dynamic Content

First we’ll need to get the dynamic data from our API endpoint and parse it into something usable. For this example the API will return a JSON object of string to string mappings:

// 1) Check integrity of request
guard let data = data, error == nil else {
    // Failed to get data
    if let error = error { print(error) }
    return
}

// 2) Parse the data into usable content
guard let parsedData = try? JSONSerialization.jsonObject(with: data, options: []),
    let usableData = parsedData as? [String: String] else {
        // Failed to read data
        return
}

Since the Lightwell SDK translates to native UIKit elements by default, we can edit the contents of the views content directly. We have three options to edit the text.

The first keeps the view in place, but removes the rich styling:

// Update text ignoring style
(self.context?.view(for: "button title") as? UILabel)?.text = usableData["button"]

The second option is to work with NSAttributedString directly to preserve the style:

// Manually update text preserving style
if let title = usableData["title"] {
    if let currentAttributes = (self.context?.view(for: "title") as? UILabel)?.attributedText {
        let attributedText = NSMutableAttributedString(attributedString: currentAttributes)
        attributedText.mutableString.setString(title)
        (self.context?.view(for: "title") as? UILabel)?.attributedText = attributedText
    }
}

The third option is to use a convenience extention the SDK adds to UILabel to edit the text preserving the style:

// Use a convenience function for updating text preserving style
(self.context?.view(for: "copy") as? UILabel)?.setText(usableData["copy"], keepingAttributes: true)

Adding the data parsing and view updating to our API handler:

func getRemoteData() {
    guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
        // 1) Process the API's callback
        guard let data = data, error == nil else {
            if let error = error { print(error) }
            return
        }
        guard let parsedData = try? JSONSerialization.jsonObject(with: data, options: []),
            let usableData = parsedData as? [String: String] else {
                return
        }


        DispatchQueue.main.async {
            if let background = self.context?.view(for: "background") {
                background.frame = self.view.bounds
                self.view.addSubview(background)
            }
            if let popup = self.context?.view(for: "popup") {
                self.view.addSubview(popup)
            }

            // 2) Update the content
            // Option 1 Update text ignoring style
            (self.context?.view(for: "button title") as? UILabel)?.text = usableData["button"]

            // Option 2 Manually update text preserving style
            if let title = usableData["title"] {
                if let currentAttributes = (self.context?.view(for: "title") as? UILabel)?.attributedText {
                    let attributedText = NSMutableAttributedString(attributedString: currentAttributes)
                    attributedText.mutableString.setString(title)
                    (self.context?.view(for: "title") as? UILabel)?.attributedText = attributedText
                }
            }

            // Option 3 Use a convenience function for updating text preserving style
            (self.context?.view(for: "copy") as? UILabel)?.setText(usableData["copy"], keepingAttributes: true)

            self.context?.view(for: "background")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))
            self.context?.view(for: "button")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

            self.context?.action(for: "show popup")?.run()
        }
    }

    task.resume()
}

Now if we run the application, we will see the popup with new content pulled from the api. The button will look different since we edited its contents without preserving the text style.

Bringing it all Together

At this point we have a popup implemented utilizing views automatically generated from a design document, animations playing without any need for configuration, and content updated dynamically from an API. Pulling it all together and using the convenience setter, our full ViewController.swift file should look something like this:

import UIKit
import LightwellKit

class ViewController: UIViewController {
    // Initialize the loading context for the promo popup
    let context = LWKLoadingContext(screenName: "promo")

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.getRemoteData()
    }

    func getRemoteData() {
        // Setup API call
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            guard let data = data, error == nil else {
                // Failed to get data
                if let error = error { print(error) }
                return
            }

            // Parse data
            guard let parsedData = try? JSONSerialization.jsonObject(with: data, options: []),
                let usableData = parsedData as? [String: String] else {
                // Failed to read data
                return
            }

            // Jump to main thread to edit the UI
            DispatchQueue.main.async {
                // Add popup views to screen
                if let background = self.context?.view(for: "background") {
                    background.frame = self.view.bounds
                    self.view.addSubview(background)
                }
                if let popup = self.context?.view(for: "popup") {
                    self.view.addSubview(popup)
                }

                // Update contents based on response, using a convenience function to preserve text style
                (self.context?.view(for: "title") as? UILabel)?.setText(usableData["title"], keepingAttributes: true)
                (self.context?.view(for: "copy") as? UILabel)?.setText(usableData["copy"], keepingAttributes: true)
                (self.context?.view(for: "button title") as? UILabel)?.setText(usableData["button"], keepingAttributes: true)

                // Add inputs to respond to popup
                self.context?.view(for: "background")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))
                self.context?.view(for: "button")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

                // Show the popup by running the animation action
                self.context?.action(for: "show popup")?.run()
            }
        }

        task.resume()
    }

    @objc
    func dismissPopup() {
        // Hide the popup and background
        context?.action(for: "dismiss popup")?.run()
    }

    @objc
    func respondToButton() {
        // Play 'success' animation
        context?.action(for: "follow button")?.run()
    }
}

Adding some final touches

Action Delegate

Presumably we’d want the button to actually trigger some functionality. We can either add that to the respondToButton function we’ve defined or wait until the animation is finished playing using the LWKAction.delegate:

extension ViewController {

    /* previous functionality */

    @objc
    func respondToButton() {
        // 1) Set action delegate
        context?.action(for: "follow button")?.delegate = self
        context?.action(for: "follow button")?.run()
    }
}

extension ViewController: LWKActionDelegate {
    // 2) respond to action state notification
    func actionDidEnd(_ action: LWKAction, finished: Bool) {
        print("decided to follow button")
    }
}

Images

Just as text content can be changed, images can be swapped out too. Layers linked to a rasterized image are converted to a UIImageView. If the api included a url for an updated image we could add the following:

if let rawImageURL = usableData["image url"], let imageUrl = URL(string: rawImageURL) {
    let imageDownloadTask = URLSession.shared.dataTask(with: imageUrl) { (data, response, error) in
        guard let data = data, let image = UIImage(data: data) else {
            // Failed to get image
            if let error = error { print(error) }
            else { print("Unable to read data as image.") }
            return
        }

        // Jump to main thread to edit the UI
        DispatchQueue.main.async {
            // Update image content
            (self.context?.view(for: "image") as? UIImageView)?.image = image

            // Show the popup by running the animation action
            self.context?.action(for: "show popup")?.run()
        }
    }
    imageDownloadTask.resume()
}

Adding this to the example above:

import UIKit
import LightwellKit

class ViewController: UIViewController {
    let context = LWKLoadingContext(screenName: "promo")

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.getRemoteData()
    }

    func getRemoteData() {
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            guard let data = data, error == nil else {
                if let error = error { print(error) }
                return
            }

            guard let parsedData = try? JSONSerialization.jsonObject(with: data, options: []),
                let usableData = parsedData as? [String: String] else {
                return
            }

            DispatchQueue.main.async {
                if let background = self.context?.view(for: "background") {
                    background.frame = self.view.bounds
                    self.view.addSubview(background)
                }
                if let popup = self.context?.view(for: "popup") {
                    self.view.addSubview(popup)
                }

                (self.context?.view(for: "title") as? UILabel)?.setText(usableData["title"], keepingAttributes: true)
                (self.context?.view(for: "copy") as? UILabel)?.setText(usableData["copy"], keepingAttributes: true)
                (self.context?.view(for: "button title") as? UILabel)?.setText(usableData["button"], keepingAttributes: true)

                self.context?.view(for: "background")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))
                self.context?.view(for: "button")?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

                    // 1) Check for image url in response
                if let rawImageURL = usableData["image url"], let imageUrl = URL(string: rawImageURL) {
                        // 2) Download image from url
                    let imageDownloadTask = URLSession.shared.dataTask(with: imageUrl) { (data, response, error) in
                        guard let data = data, let image = UIImage(data: data) else {
                            // Failed to get image
                            if let error = error { print(error) }
                            else { print("Unable to read data as image.") }
                            return
                        }

                        DispatchQueue.main.async {
                            // 3) Update image content
                            (self.context?.view(for: "image") as? UIImageView)?.image = image

                            // 4.a) Show the popup after successfully updating image
                            self.context?.action(for: "show popup")?.run()
                        }
                    }
                    imageDownloadTask.resume()
                } 
                else {
                    // 4.b) Show the popup if there is no image update expected
                    self.context?.action(for: "show popup")?.run()
                }
            }
        }

        task.resume()
    }

    @objc
    func dismissPopup() {
        // Hide the popup and background
        context?.action(for: "dismiss popup")?.run()
    }

    @objc
    func respondToButton() {
        context?.action(for: "follow button")?.delegate = self
        context?.action(for: "follow button")?.run()
    }
}

extension ViewController: LWKActionDelegate {
    func actionDidEnd(_ action: LWKAction, finished: Bool) {
        print("decided to follow button")
    }
}

Alternate Approach to Optionals

Since the data for the promo screen is packaged with the application, after testing we can use syntax similar to that used with interface builder. With this we can move away from optional chaining, keeping the code a little more succinct:

import UIKit
import LightwellKit

class ViewController: UIViewController {
    // Initialize the loading context for the promo popup
    let context: LWKLoadingContext! = LWKLoadingContext(screenName: "promo")
    // Link to key views in the context
    lazy var background: UIView! = { return self.context.view(for: "background") }()
    lazy var popup: UIView! = { return self.context.view(for: "popup") }()
    lazy var button: UIView! = { return self.context.view(for: "button") }()
    lazy var popupTitle: UILabel! = { return self.context.view(for: "title") as? UILabel }()
    lazy var popupCopy: UILabel! = { return self.context.view(for: "copy") as? UILabel }()
    lazy var popupImage: UIImageView! = { return self.context.view(for: "image") as? UIImageView }()
    lazy var buttonTitle: UILabel! = { return self.context.view(for: "button title") as? UILabel }()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.getRemoteData()
    }
}

extension ViewController {
    func getRemoteData() {
        guard let url = URL(string: "https://s3.amazonaws.com/lightwell/docs/sample/promo-api.json") else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            guard let data = data, error == nil else {
                // Failed to get data
                if let error = error { print(error) }
                return
            }

            // Parse data
            guard let parsedData = try? JSONSerialization.jsonObject(with: data, options: []),
                let usableData = parsedData as? [String: String] else {
                    // Failed to read data
                    return
            }

            // Jump to main thread to edit the UI
            DispatchQueue.main.async {
                self.background.frame = self.view.bounds
                self.view.addSubview(self.background)

                self.view.addSubview(self.popup)

                self.buttonTitle.setText(usableData["button"], keepingAttributes: true)
                self.popupTitle.setText(usableData["title"], keepingAttributes: true)
                self.popupCopy.setText(usableData["copy"], keepingAttributes: true)

                // Add inputs
                self.background.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissPopup)))
                self.button.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.respondToButton)))

                if let rawImageURL = usableData["image url"], let imageUrl = URL(string: rawImageURL) {
                    // Download image
                    let imageDownloadTask = URLSession.shared.dataTask(with: imageUrl) { (data, response, error) in
                        guard let data = data, let image = UIImage(data: data) else {
                            // Failed to get image
                            if let error = error { print(error) }
                            else { print("Unable to read data as image.") }
                            return
                        }

                        DispatchQueue.main.async {
                            // Update image
                            self.popupImage.image = image

                            // Show popup
                            self.context.action(for: "show popup")?.run()
                        }
                    }
                    imageDownloadTask.resume()
                }
                else {
                    // Show the popup without an updated image
                    self.context?.action(for: "show popup")?.run()
                }
            }
        }

        task.resume()
    }

    @objc
    func dismissPopup() {
        context?.action(for: "dismiss popup")?.run()
    }

    @objc
    func respondToButton() {
        context?.action(for: "follow button")?.delegate = self
        context?.action(for: "follow button")?.run()
    }
}

extension ViewController: LWKActionDelegate {
    func actionDidEnd(_ action: LWKAction, finished: Bool) {
        print("decided to follow button")
    }
}

The API is currently in beta and everything inside of these docs will be changing to improve the SDK. If there is a change you’d like to see or have any feedback, please email us at dev@lightwell.pro.