Getting Started with MarkupKit

MarkupKit is an open-source framework for simplifying development of native iOS and tvOS applications. It allows developers to construct user interfaces declaratively using a human-readable, HTML-like markup language, rather than visually using Interface Builder or programmatically in code.

This tutorial introduces the MarkupKit framework by demonstrating how to construct a simple "Hello, World"-style application using markup:

Tapping the button causes a greeting to appear:

The code examples are written in Swift. Xcode 9 and iOS 9 or later are required.

Get MarkupKit

MarkupKit is developed as a freely available open-source project on GitHub. The latest release can be downloaded here.

The file named MarkupKit-iOS.tar.gz contains the iOS framework bundle, MarkupKit.framework. Download this archive and expand it somewhere on your local system.

Create the Xcode Project

In Xcode, select File | New | Project to create a new iOS project. Select the iOS/Application/Single View App template and click the "Next" button. Name the product "HelloMarkupKit" and select Swift as the development language.

Next, add the MarkupKit framework to the project:

  • Select the project root node in the Project Navigator view
  • Select the "HelloMarkupKit" target
  • Select the "General" tab
  • In the Finder, navigate to the location where you unzipped MarkupKit.framework
  • Drag MarkupKit.framework from the Project Navigator to the "Embedded Binaries" section
  • In the dialog that appears, ensure that "Copy items if needed" is checked and click "Finish"

Create the View Controller

In Project Navigator, select ViewController.swift. Import the MarkupKit module by adding the following line to the import section:

import MarkupKit

Within the ViewController class, add an outlet for the label that will be used to display the greeting:

@IBOutlet var greetingLabel: UILabel!

Next, add the following method, which will load the view from markup. The markup document, ViewController.xml, will be created later:

override func loadView() {
    view = LMViewBuilder.view(withName: "ViewController", owner: self, root: nil)
}

Finally, add an action to handle taps on the "Say Hello" button:

@IBAction func sayHello() {
    greetingLabel.text = "Hello, World!";
}

Your final view controller should look something like this:

import UIKit
import MarkupKit
    
class ViewController: UIViewController {
    @IBOutlet var greetingLabel: UILabel!
    
    override func loadView() {
        view = LMViewBuilder.view(withName: "ViewController", owner: self, root: nil)
    }
    
    @IBAction func sayHello() {
        greetingLabel.text = "Hello, World!";
    }
}

Create the View

Select File | New | File… from the menu. In the dialog that appears, select iOS > Other > Empty and click "Next". Name the file ViewController.xml, ensure that the "HelloMarkupKit" project target is selected, and click the "Create" button. Add the following markup to the newly created document:

<?xml version="1.0" encoding="UTF-8"?>

<LMColumnView backgroundColor="white">
    <LMSpacer height="96"/>

    <UIButton style="systemButton" title="Say Hello" onPrimaryActionTriggered="sayHello"/>
    <UILabel id="greetingLabel" font="headline" textAlignment="center"/>

    <LMSpacer/>
</LMColumnView>

This markup declares a simple view hierarchy containing a label and a button. View properties such as font and textAlignment are set using XML attributes. The "id" attribute creates an outlet for the label instance, which is used to associate the label with the instance variable defined in the controller class. The "onPrimaryActionTriggered" attribute defines an action handler for the button and associates it with the sayHello() method defined by the controller.

An instance of LMColumnView is used to automatically arrange the view elements in a vertical line. LMColumnView is an example of a layout view, a view whose sole responsibility is managing the size and position of its subviews. LMSpacer is used to provide fixed and flexible spacing at the top and bottom of the view, respectively.

Build and Run the App

The application is now ready to run. Ensure that "HelloMarkupKit" is selected in the scheme drop-down and launch the application by selecting Product | Run from the menu or by clicking the "Run" button:

Tapping the "Say Hello" button displays the greeting "Hello, World!":

Next Steps

This document introduced the MarkupKit framework by demonstrating how to build a simple, "Hello, World"-style application using markup. For more information, see the project README.

Implementing Auto-Complete with UITextField

I recently wanted to add a Safari-like auto-complete feature to an iOS app I’ve been working on. Specifically, I wanted the app to proactively suggest a complete word based on some initial characters entered by the user, similar to how Safari suggests URLs based on the first few letters in a web address:

As in Safari, tapping Return would allow the user to confirm the suggestion.

Since this is not a feature that Apple provides “out of the box”, I thought I would share the approach I took in case it is of interest to anyone.

In this example, the text field will suggest values for the user’s favorite color:

As the user types, a list of options is consulted to determine which value to suggest. For example:

Suggestions are defined as an array of strings:

let suggestions = [
    "red",
    "orange",
    "yellow",
    "green",
    "blue",
    "purple"
]

To handle user input, the view controller assigns itself as the text field’s delegate and implements the textField(_:shouldChangeCharactersIn:replacementString:) method, as shown below:

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    return !autoCompleteText(in: textField, using: string, suggestions: suggestions)
}

This method simply invokes the following method, which searches the suggestion list for the first entry with a prefix matching the user’s input. It then updates the text value with the identified suggestion and selects the remaining characters in the text field:

func autoCompleteText(in textField: UITextField, using string: String, suggestions: [String]) -> Bool {
    if !string.isEmpty,
        let selectedTextRange = textField.selectedTextRange, selectedTextRange.end == textField.endOfDocument,
        let prefixRange = textField.textRange(from: textField.beginningOfDocument, to: selectedTextRange.start),
        let text = textField.text(in: prefixRange) {
        let prefix = text + string
        let matches = suggestions.filter { $0.hasPrefix(prefix) }

        if (matches.count > 0) {
            textField.text = matches[0]

            if let start = textField.position(from: textField.beginningOfDocument, offset: prefix.characters.count) {
                textField.selectedTextRange = textField.textRange(from: start, to: textField.endOfDocument)

                return true
            }
        }
    }

    return false
}

The method returns true if a match was found and false otherwise. The delegate method returns the inverse of this value so the text field will continue to process keystrokes when a match is not found.

Finally, the controller implements the delegate’s textFieldShouldReturn(_:) method to “confirm” the suggestion:

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    textField.resignFirstResponder()

    return true
}

Note that the text field’s autocapitalizationType and autocorrectionType properties were set to .none and .no, respectively. Disabling auto-capitalization ensures that the lookup logic will correctly identify matches, since all of the suggestions begin with lowercase letters. Turning off auto-correction ensures that iOS’s built-in suggestion bar is not displayed, since suggestions will be made by the text field itself.

Hope you find it useful. For more ways to simplify iOS app development, please see my projects on GitHub:

  • MarkupKit – Declarative UI for iOS and tvOS
  • HTTP-RPC – Lightweight multi-platform REST

Creating a Universal Framework in Xcode 9

A while back, I posted a script for creating a universal iOS framework (i.e. one that will run in both the simulator as well as on an actual device) in Xcode 7. The following is an updated version for use with Xcode 9. This version should work with both Swift and Objective-C projects:

FRAMEWORK=<framework name>

BUILD=build
FRAMEWORK_PATH=$FRAMEWORK.framework

# iOS
rm -Rf $FRAMEWORK-iOS/$BUILD
rm -f $FRAMEWORK-iOS.framework.tar.gz

xcodebuild archive -project $FRAMEWORK-iOS/$FRAMEWORK-iOS.xcodeproj -scheme $FRAMEWORK -sdk iphoneos SYMROOT=$BUILD
xcodebuild build -project $FRAMEWORK-iOS/$FRAMEWORK-iOS.xcodeproj -target $FRAMEWORK -sdk iphonesimulator SYMROOT=$BUILD

cp -RL $FRAMEWORK-iOS/$BUILD/Release-iphoneos $FRAMEWORK-iOS/$BUILD/Release-universal
cp -RL $FRAMEWORK-iOS/$BUILD/Release-iphonesimulator/$FRAMEWORK_PATH/Modules/$FRAMEWORK.swiftmodule/* $FRAMEWORK-iOS/$BUILD/Release-universal/$FRAMEWORK_PATH/Modules/$FRAMEWORK.swiftmodule

lipo -create $FRAMEWORK-iOS/$BUILD/Release-iphoneos/$FRAMEWORK_PATH/$FRAMEWORK $FRAMEWORK-iOS/$BUILD/Release-iphonesimulator/$FRAMEWORK_PATH/$FRAMEWORK -output $FRAMEWORK-iOS/$BUILD/Release-universal/$FRAMEWORK_PATH/$FRAMEWORK

tar -czv -C $FRAMEWORK-iOS/$BUILD/Release-universal -f $FRAMEWORK-iOS.tar.gz $FRAMEWORK_PATH $FRAMEWORK_PATH.dSYM

When located in the same directory as the .xcodeproj file, this script will invoke xcodebuild twice on a framework project and join the resulting binaries together into a single universal binary. It will then package the framework up in a gzipped tarball and place it in the same directory.

However, apps that contain “fat” binaries like this don't pass app store validation. Before submitting an app containing a universal framework, the binaries need to be trimmed so that they include only iOS-native code. The following script can be used to do this:

FRAMEWORK=$1
echo "Trimming $FRAMEWORK..."

FRAMEWORK_EXECUTABLE_PATH="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/$FRAMEWORK.framework/$FRAMEWORK"

EXTRACTED_ARCHS=()

for ARCH in $ARCHS
do
    echo "Extracting $ARCH..."
    lipo -extract "$ARCH" "$FRAMEWORK_EXECUTABLE_PATH" -o "$FRAMEWORK_EXECUTABLE_PATH-$ARCH"
    EXTRACTED_ARCHS+=("$FRAMEWORK_EXECUTABLE_PATH-$ARCH")
done

echo "Merging binaries..."
lipo -o "$FRAMEWORK_EXECUTABLE_PATH-merged" -create "${EXTRACTED_ARCHS[@]}"
rm "${EXTRACTED_ARCHS[@]}"

rm "$FRAMEWORK_EXECUTABLE_PATH"
mv "$FRAMEWORK_EXECUTABLE_PATH-merged" "$FRAMEWORK_EXECUTABLE_PATH"

echo "Done."

To use this script:

  1. Place the script in your project root directory and name it trim.sh or something similar
  2. Create a new “Run Script” build phase after the “Embed Frameworks” phase
  3. Rename the new build phase to “Trim Framework Executables” or similar (optional)
  4. Invoke the script for each framework you want to trim (e.g. ${SRCROOT}/trim.sh)

For more ways to simplify iOS app development, please see my projects on GitHub:

  • MarkupKit – Declarative UI for iOS and tvOS
  • HTTP-RPC – Lightweight multi-platform REST

Dynamically Loading Recycler View Images in Android

As in iOS, Android applications often display thumbnail images alongside other text-based content such as contact names or product descriptions in recycler views. However, the images are not usually delivered with the initial request, but are instead retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the UI thread, which would temporarily render the application unresponsive.

For example, consider this sample REST service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "http://placehold.it/600/92c952",
    "thumbnailUrl": "http://placehold.it/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "http://placehold.it/600/771796",
    "thumbnailUrl": "http://placehold.it/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "http://placehold.it/600/24f355",
    "thumbnailUrl": "http://placehold.it/150/24f355"
  },
  ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

Photo Class

A class representing this data might be defined as follows:

public class Photo {
    private Number id;
    private Number albumId;
    private String title;
    private URL url;
    private URL thumbnailUrl;

    public Photo(Map<String, ?> map) {
        id = valueAt(map, "id");
        albumId = valueAt(map, "albumId");
        title = valueAt(map, "title");

        String url = valueAt(map, "url");
        String thumbnailUrl = valueAt(map, "thumbnailUrl");

        try {
            this.url = new URL(url);
            this.thumbnailUrl = new URL(thumbnailUrl);
        } catch (MalformedURLException exception) {
            throw new RuntimeException(exception);
        }
    }

    public int getId() {
        return id.intValue();
    }

    public int getAlbumId() {
        return albumId.intValue();
    }

    public String getTitle() {
        return title;
    }

    public URL getUrl() {
        return url;
    }

    public URL getThumbnailUrl() {
        return thumbnailUrl;
    }
}

The constructor extracts property data from a map instance using the static valueAt() method of the HTTP-RPC WebServiceProxy class. Instances of this class will be used later to retrieve the photo list as well as the thumbnails themselves.

Main Activity Class

A basic user interface for displaying service results in a recycler view is shown below:

The markup for the main activity simply declares an instance of RecyclerView that will occupy the entire screen:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"/>
</LinearLayout>

Item markup is shown below:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp">
    <ImageView android:id="@+id/image_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView android:id="@+id/text_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:layout_marginLeft="8dp"/>
</LinearLayout>

View holder and adapter classes are used to produce and configure individual photo item views. Item data is stored in a list of Photo instances, and previously loaded thumbnail images are stored in a map that associates Bitmap instances with photo IDs. The executor service will be used later by WebServiceProxy to execute service requests:

public class MainActivity extends AppCompatActivity {
    // Photo view holder
    private class PhotoViewHolder extends RecyclerView.ViewHolder {
        ...
    }

    // Photo adapter
    private class PhotoAdapter extends RecyclerView.Adapter<PhotoViewHolder> {
        ...
    }

    private RecyclerView recyclerView;

    private ArrayList<Photo> photos = null;

    private HashMap<Integer, Bitmap> photoThumbnails = new HashMap<>();

    private ExecutorService executorService = Executors.newSingleThreadExecutor();

    private static String TAG = MainActivity.class.getName();

    ...
}

When the activity is created, the view is loaded and the recycler view configured:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setContentView(R.layout.activity_main);

    recyclerView = (RecyclerView)findViewById(R.id.recycler_view);

    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    recyclerView.setAdapter(new PhotoAdapter());
}

The photo list is loaded the first time the activity resumes. An instance of WebServiceProxy is used to retrieve the data. If the call succeeds, the response data (a list of map objects) is transformed into a list of Photo objects and the recycler view is refreshed. Otherwise, an error message is logged:

@Override
protected void onResume() {
    super.onResume();

    // Load photo data
    if (photos == null) {
        URL serverURL;
        try {
            serverURL = new URL("https://jsonplaceholder.typicode.com");
        } catch (MalformedURLException exception) {
            throw new RuntimeException(exception);
        }

        WebServiceProxy serviceProxy = new WebServiceProxy(serverURL, executorService) {
            @Override
            protected void dispatchResult(Runnable command) {
                runOnUiThread(command);
            }
        };

        serviceProxy.invoke("GET", "/photos", (List<Map<String, ?>> result, Exception exception) -> {
            if (exception == null) {
                photos = new ArrayList<>(result.size());

                for (Map<String, ?> value : result) {
                    photos.add(new Photo(value));
                }

                recyclerView.getAdapter().notifyDataSetChanged();
            } else {
                Log.e(TAG, exception.getMessage());
            }
        });
    }
}

Note that the service proxy overrides dispatchResult() to ensure that the response handler is executed on the UI thread.

Item content is managed by the view holder and adapter classes. The corresponding Photo instance is retrieved from the photos list and used to configure the item view:

private class PhotoViewHolder extends RecyclerView.ViewHolder {
    private ImageView imageView;
    private TextView textView;

    public PhotoViewHolder(View view) {
        super(view);

        imageView = (ImageView)view.findViewById(R.id.image_view);
        textView = (TextView)view.findViewById(R.id.text_view);
    }
}

private class PhotoAdapter extends RecyclerView.Adapter<PhotoViewHolder> {
    @Override
    public PhotoViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new PhotoViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_photo, parent, false));
    }

    @Override
    public void onBindViewHolder(PhotoViewHolder holder, int position) {
        Photo photo = photos.get(position);

        // Attempt to load image from cache
        Bitmap thumbnail = photoThumbnails.get(photo.getId());

        if (thumbnail == null) {
            // Image was not found in cache; load it from the server

            ...
        } else {
            holder.imageView.setImageBitmap(thumbnail);
        }

        holder.textView.setText(photo.getTitle());
    }

    @Override
    public int getItemCount() {
        return (photos == null) ? 0 : photos.size();
    }
}

If the thumbnail image is already available in the cache, it is used to populate the item's image view. Otherwise, it is loaded from the server and added to the cache as shown below. If the item is still visible when the image request returns, it is updated immediately:

// Image was not found in cache; load it from the server
URL thumbnailUrl = photo.getThumbnailUrl();

URL serverURL;
try {
    serverURL = new URL(String.format("%s://%s", thumbnailUrl.getProtocol(), thumbnailUrl.getHost()));
} catch (MalformedURLException exception) {
    throw new RuntimeException(exception);
}

WebServiceProxy serviceProxy = new WebServiceProxy(serverURL, executorService) {
    @Override
    protected Object decodeImageResponse(InputStream inputStream, String imageType) {
        return BitmapFactory.decodeStream(inputStream);
    }

    @Override
    protected void dispatchResult(Runnable command) {
        runOnUiThread(command);
    }
};

serviceProxy.invoke("GET", thumbnailUrl.getPath(), (Bitmap result, Exception exception) -> {
    photoThumbnails.put(photo.getId(), result);

    // If view is still visible, update image
    PhotoViewHolder viewHolder = (PhotoViewHolder)recyclerView.findViewHolderForAdapterPosition(position);

    if (viewHolder != null) {
        viewHolder.imageView.setImageBitmap(result);
    }
});

Summary

This article provided an overview of how images can be dynamically loaded as needed to populate recycler views in Android. For more information, please see the HTTP-RPC project on GitHub.

Dynamically Loading Table View Images in iOS

iOS applications often display thumbnail images in table views alongside other text-based content such as contact names or product descriptions. However, these images are not usually delivered with the initial request, but must instead be retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the main thread, which would temporarily render the user interface unresponsive.

For example, consider this REST service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "http://placehold.it/600/92c952",
    "thumbnailUrl": "http://placehold.it/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "http://placehold.it/600/771796",
    "thumbnailUrl": "http://placehold.it/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "http://placehold.it/600/24f355",
    "thumbnailUrl": "http://placehold.it/150/24f355"
  },
  ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

Photo Class

A class representing this data might be defined as follows:

class Photo: NSObject {
    var id: Int = 0
    var albumId: Int = 0
    var title: String?
    var url: URL?
    var thumbnailUrl: URL?

    init(dictionary: [String: Any]) {
        super.init()

        setValuesForKeys(dictionary)
    }

    override func setValue(_ value: Any?, forKey key: String) {
        switch key {
        case #keyPath(url):
            url = URL(string: value as! String)

        case #keyPath(thumbnailUrl):
            thumbnailUrl = URL(string: value as! String)

        default:
            super.setValue(value, forKey: key)
        }
    }
}

Since instances of this class will be populated using dictionary values returned by the web service, an initializer that takes a dictionary argument is provided. The setValuesForKeys(_:) method of NSObject is used to map dictionary entries to property values. The ID and title properties are handled automatically by this method; the class overrides setValue(_:forKey:) to convert the URLs from strings into actual URL instances.

View Controller Class

A basic user interface for displaying service results in a table view is shown below:

Row data is stored in an array of Photo instances. Previously loaded thumbnail images are stored in a dictionary that associates UIImage instances with photo IDs:

class ViewController: UITableViewController {
    // Row data
    var photos: [Photo]!

    // Image cache
    var thumbnailImages: [Int: UIImage] = [:]

    ...    
}

The photo list is loaded the first time the view appears. The WSWebServiceProxy class of the open-source HTTP-RPC library is used to retrieve the data. If the call succeeds, the response data (an array of dictionary objects) is transformed into an array of Photo objects and the table view is refreshed. Otherwise, an error message is displayed:

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

    // Load photo data
    if (photos == nil) {
        let serviceProxy = WSWebServiceProxy(session: URLSession.shared, serverURL: URL(string: "https://jsonplaceholder.typicode.com")!)

        serviceProxy.invoke("GET", path: "/photos") { result, error in
            if (error == nil) {
                let photos = result as! [[String: Any]]

                self.photos = photos.map({
                    return Photo(dictionary: $0)
                })

                self.tableView.reloadData()
            } else {
                let alertController = UIAlertController(title: "Error", message: error!.localizedDescription, preferredStyle: .alert)

                alertController.addAction(UIAlertAction(title: "OK", style: .default))

                self.present(alertController, animated: true)
            }
        }
    }
}

Cell content is generated as follows. The corresponding Photo instance is retrieved from the photos array and used to configure the cell:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return (photos == nil) ? 0 : photos.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    var cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier)

    if (cell == nil) {
        cell = UITableViewCell(style: .default, reuseIdentifier: cellIdentifier)
    }

    let photo = photos[indexPath.row];

    cell!.textLabel!.text = photo.title

    // Attempt to load image from cache
    cell!.imageView!.image = thumbnailImages[photo.id]

    if (cell!.imageView!.image == nil && photo.thumbnailUrl != nil) {
        // Image was not found in cache; load it from the server

        ...
    }

    return cell!
}

If the thumbnail image is already available in the cache, it is used to populate the cell's image view. Otherwise, it is loaded from the server and added to the cache as shown below. If the cell is still visible when the image request returns, it is updated immediately and reloaded:

let serverURL = URL(string: String(format: "%@://%@", photo.thumbnailUrl!.scheme!, photo.thumbnailUrl!.host!))!

let serviceProxy = WSWebServiceProxy(session: URLSession.shared, serverURL: serverURL)

serviceProxy.invoke("GET", path: photo.thumbnailUrl!.path) { result, error in
    if (error == nil) {
        // If cell is still visible, update image and reload row
        let cell = tableView.cellForRow(at: indexPath)

        if (cell != nil) {
            if let thumbnailImage = result as? UIImage {
                self.thumbnailImages[photo.id] = thumbnailImage

                cell!.imageView!.image = thumbnailImage

                tableView.reloadRows(at: [indexPath], with: .none)
            }
        }
    } else {
        print(error!.localizedDescription)
    }
}

Finally, if the system is running low on memory, the image cache is cleared:

override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()

    // Clear image cache
    thumbnailImages.removeAll()
}

Summary

This article provided an overview of how images can be dynamically loaded as needed to populate table view cells in iOS. For more ways to simplify iOS app development, please see my projects on GitHub:

  • MarkupKit – declarative UI for iOS and tvOS
  • HTTP-RPC – lightweight multi-platform REST

Caching Web Service Response Data in iOS

Many iOS applications obtain data via HTTP web services that return JSON documents. For example, the following code uses the HTTP-RPC WSWebServiceProxy class to invoke a simple web service that returns a simulated list of users as JSON. The results are stored as a dictionary and presented in a table view:

class UserViewController: UITableViewController {
    var users: [[String: Any]]! = nil

    ...

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

        if (users == nil) {
            // Load the data from the server
            AppDelegate.serviceProxy.invoke("GET", path: "/users") { result, error in
                if (error == nil) {
                    self.users = result as? [[String: Any]]

                    self.tableView.reloadData()
                } 
            }
        }
    }

    ...
}

This works fine when both the device and the service are online, but it fails if either one is not. In some cases this may be acceptable, but other times it might be preferable to show the user the most recent response when more current data is not available.

To facilitate offline support, the response data must be cached. However, since writing to the file system is a potentially time-consuming operation, it should be done in the background to avoid blocking the main (UI) thread. Here, the data is written using an operation queue to ensure that access to it is serialized:

class UserViewController: UITableViewController {
    var users: [[String: Any]]! = nil

    var userCacheURL: URL?
    let userCacheQueue = OperationQueue()

    override func viewDidLoad() {
        super.viewDidLoad()

        ...

        if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
            userCacheURL = cacheURL.appendingPathComponent("users.json")
        }
    }

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

        if (users == nil) {
            // Load the data from the server
            AppDelegate.serviceProxy.invoke("GET", path: "/users") { result, error in
                if (error == nil) {
                    self.users = result as? [[String: Any]]

                    self.tableView.reloadData()

                    // Write the response to the cache
                    if (self.userCacheURL != nil) {
                        self.userCacheQueue.addOperation() {
                            if let stream = OutputStream(url: self.userCacheURL!, append: false) {
                                stream.open()

                                JSONSerialization.writeJSONObject(result!, to: stream, options: [.prettyPrinted], error: nil)

                                stream.close()
                            }
                        }
                    }
                } 
            }
        }
    }

    ...
}

Finally, the data can be retrieved from the cache if the web service call fails. The data is read from the cache in the background, and the UI is updated by reloading the table view on the main thread:

class UserViewController: UITableViewController {
    var users: [[String: Any]]! = nil

    var userCacheURL: URL?
    let userCacheQueue = OperationQueue()

    ...

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

        if (users == nil) {
            // Load the data from the server
            AppDelegate.serviceProxy.invoke("GET", path: "/users") { result, error in
                if (error == nil) {
                    self.users = result as? [[String: Any]]

                    self.tableView.reloadData()

                    // Write the data to the cache
                    if (self.userCacheURL != nil) {
                        self.userCacheQueue.addOperation() {
                            if let stream = OutputStream(url: self.userCacheURL!, append: false) {
                                stream.open()

                                JSONSerialization.writeJSONObject(result!, to: stream, options: [.prettyPrinted], error: nil)

                                stream.close()
                            }
                        }
                    }
                } else if (self.userCacheURL != nil) {
                    // Read the data from the cache
                    self.userCacheQueue.addOperation() {
                        if let stream = InputStream(url: self.userCacheURL!) {
                            stream.open()

                            self.users = (try? JSONSerialization.jsonObject(with: stream, options: [])) as? [[String: Any]]

                            stream.close()
                        }

                        // Update the UI
                        OperationQueue.main.addOperation() {
                            self.tableView.reloadData()
                        }
                    }
                }
            }
        }
    }

    ...
}

Now, as long as the application has been able to connect to the server at least once, it can function either online or offline, using the cached response data.

For more ways to simplify iOS app development, please see my projects on GitHub:

  • MarkupKit – Declarative UI for iOS and tvOS
  • HTTP-RPC – Lightweight multi-platform REST

Applying Style Sheets Client-Side in iOS

While native mobile applications can often provide a more seamless and engaging user experience than a browser-based app, it is occasionally convenient to present certain types of content using a web view. Specifically, any content that is primarily text-based and requires minimal user interaction may be a good candidate for presentation as HTML; for example, product descriptions, user reviews, or instructional content.

However, browser-based content often tends to look out of place within a native app. For example, consider the following simple HTML document:

<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="initial-scale=1.0"/>
</head>
<body>
    <h1>Lorem Ipsum</h1>
    <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
    <ul>
    <li>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</li> 
    <li>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</li>
    <li>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</li>
    </ul>
</body>
</html>

Rendered by WKWebView, the result looks like this:

Because the text is displayed using the default browser font rather than the system font, it is immediately obvious that the content is not native. To make it appear more visually consistent with other elements of the user interface, a stylesheet could be used to render the document using the system font:

<head>
    ...
    <style>
    body {
        font-family: '-apple-system';
        font-size: 10pt;
    }
    </style>
</head>

The result is shown below. The styling of the text now matches the rest of the UI:

However, while this approach may work for this simple example, it does not scale well. Different app (or OS) versions may have different styling requirements.

By applying the stylesheet on the client, the presentation can be completely separated from the content. This can be accomplished by linking to the stylesheet rather than embedding it inline:

<head>
    ...
    <link rel="stylesheet" type="text/css" href="example.css"/>
</head>

However, instead of downloading the stylesheet along with the HTML document, it is distributed with the application itself and applied using the loadHTMLString:baseURL: method of the WKWebView class. The first argument to this method contains the (unstlyed) HTML content, and the second contains the base URL against which relative URLs in the document (such as stylesheets) will be resolved:

class ViewController: UIViewController {
    var webView: WKWebView!

    var serviceProxy: WSWebServiceProxy!

    override func loadView() {
        webView = WKWebView()

        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Local CSS Example"

        serviceProxy = WSWebServiceProxy(session: URLSession.shared, serverURL: URL(string: "http://localhost")!)
    }

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

        serviceProxy.invoke("GET", path: "example.html") { result, error in
            if (result != nil) {
                self.webView.loadHTMLString(result as! String, baseURL: Bundle.main.resourceURL!)
            }
        }
    }
}

In this example, the document, example.html, is loaded using the HTTP-RPC WSWebServiceProxy class. The stylesheet, example.css, is stored in the resource folder of the application's main bundle:

body {
    font-family: '-apple-system';
    font-size: 10pt;
}

The results are identical to the previous example. However, the content and visual design are no longer tightly coupled and can vary independently:

For more ways to simplify iOS app development, please see my projects on GitHub:

  • MarkupKit – Declarative UI for iOS and tvOS
  • HTTP-RPC – Lightweight multi-platform REST