📖

Progressive Web Apps on iOS

This is a beginner's guide about creating PWAs for iOS including custom icons, splash screens, safe area and dark mode support.

iOS PWA

How to make a PWA for iOS?

A progressive web application is just a special kind of website, that can look and behave like a native iOS app. In order to build a PWA, first we're going to create a regular website using SwiftHtml. We can start with a regular executable Swift package with the following dependencies.

// swift-tools-version:5.5
import PackageDescription

let package = Package(
    name: "Example",
    platforms: [
        .macOS(.v12)
    ],
    dependencies: [
        .package(url: "https://github.com/binarybirds/swift-html", from: "1.2.0"),
        .package(url: "https://github.com/vapor/vapor", from: "4.54.0"),
    ],
    targets: [
        .executableTarget(name: "Example", dependencies: [
            .product(name: "SwiftHtml", package: "swift-html"),
            .product(name: "Vapor", package: "vapor"),
        ]),
        .testTarget(name: "ExampleTests", dependencies: ["Example"]),
    ]
)

As you can see we're going to use the vapor Vapor library to serve our HTML site. If you don't know much about Vapor let's just say that it is a web application framework, which can be used to build server side Swift applications, it's a pretty amazing tool I have a beginner's guide post about it.

Of course we're going to need some components for rendering views using SwiftHtml, you can use the source snippets from my previous article, but here it is again how the SwiftHtml-based template engine should look like. You should read my other article if you want to know more about it. 🤓

import Vapor
import SwiftSgml

public protocol TemplateRepresentable {
    
    @TagBuilder
    func render(_ req: Request) -> Tag
}

public struct TemplateRenderer {
    
    var req: Request
    
    init(_ req: Request) {
        self.req = req
    }

    public func renderHtml(_ template: TemplateRepresentable, minify: Bool = false, indent: Int = 4) -> Response {
        let doc = Document(.html) { template.render(req) }
        let body = DocumentRenderer(minify: minify, indent: indent).render(doc)
        return Response(status: .ok, headers: ["content-type": "text/html"], body: .init(string: body))
    }
}

public extension Request {
    var templates: TemplateRenderer { .init(self) }
}

We're also going to need an index template for our main HTML document. Since we're using a Swift DSL to write HTML code we don't have to worry too much about mistyping a tag, the compiler will protect us and helps to maintain a completely valid HTML structure.

import Vapor
import SwiftHtml


struct IndexContext {
    let title: String
    let message: String
}

struct IndexTemplate: TemplateRepresentable {
    
    let context: IndexContext
    
    init(_ context: IndexContext) {
        self.context = context
    }
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(context.title)
                
                Meta().charset("utf-8")
                Meta().name(.viewport).content("width=device-width, initial-scale=1")
            }
            Body {
                Main {
                    Div {
                        H1(context.title)
                        P(context.message)
                    }
                }
            }
        }
    }
}

Finally we can simply render the bootstrap our Vapor server instance, register our route handler and render the index template inside the main entry point of our Swift package by using the previously defined template helper methods on the Request object.

import Vapor
import SwiftHtml

var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }

app.get { req -> Response in
    let template = IndexTemplate(.init(title: "Hello, World!",
                                    message: "This page was generated by the SwiftHtml library."))
    
    return req.templates.renderHtml(template)
}

try app.run()

It is just that easy to setup and bootstrap a fully working web server that is capable of rendering a HTML document using the power of Swift and the Vapor framework. If you run the app you should be able to see a working website by visiting the http://localhost:8080/ address.

Turning a website into a real iOS PWA

Now if we want to transform our website into a standalone PWA, we have to provide a link a special web app manifest file inside the head section of the index template.

Meta tags vs manifest.json

Seems like Apple follows kind of a strange route if it comes to PWA support. They have quite a history of "thinking outside of the box", this mindset applies to progressive web apps on iOS, since they don't tend to follow the standards at all. For Android devices you could create a manifest.json file with some predefined keys and you'd be just fine with your PWA. On the other hand Apple nowadays prefers various HTML meta tags instead of the web manifest format.

Personally I don't like this approach, because your HTML code will be bloated with all the PWA related stuff (as you'll see this is going to happen if it comes to launch screen images) and I believe it's better to separate these kind of things, but hey it's Apple, they can't be wrong, right? 😅

Anyways, let me show you how to support various PWA features on iOS.

Enabling standalone app mode

The very first few keys that we'd like to add to the index template has the apple-mobile-web-app-capable name and you should use the "yes" string as content. This will indicate that the app should run in full-screen mode, otherwise it's going to be displayed using Safari just like a regular site.

struct IndexTemplate: TemplateRepresentable {
    
    let context: IndexContext
    
    init(_ context: IndexContext) {
        self.context = context
    }
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(context.title)
                
                Meta().charset("utf-8")
                Meta().name(.viewport).content("width=device-width, initial-scale=1")

                Meta()
                    .name(.appleMobileWebAppCapable)
                    .content("yes")
            }
            Body {
                Main {
                    Div {
                        H1(context.title)
                        P(context.message)
                    }
                }
            }
        }
    }
}

We should change the hostname of the server and listen on the 0.0.0.0 address, this way if your phone is on the same local WiFi network you should be able to reach your web server directly.

import Vapor
import SwiftHtml

var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }

app.http.server.configuration.hostname = "0.0.0.0"
if let hostname = Environment.get("SERVER_HOSTNAME") {
    app.http.server.configuration.hostname = hostname
}

app.get { req -> Response in
    let template = IndexTemplate(.init(title: "Hello, World!",
                                    message: "This page was generated by the SwiftHtml library."))
    
    return req.templates.renderHtml(template)
}

try app.run()

You can find out your local IP address by typing the following command into the Terminal app.

# using ifconfig & grep
ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1'
# using ifconfig & sed
ifconfig | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*\.){3}[0-9]*).*/\2/p'

Just use that IP address and go to the http://[ip-address]:8080/ website using your iOS device, then you should be able to add your website to your home screen as a bookmark. Just tap the Share icon using Safari and select the Add to Home Screen menu item from the list. On the new screen tap the Add button on the top right corner, this will create a new icon on your home screen as a bookmark to your page. Optionally, you can provide a custom name for the bookmark. ☺️

Since we've added the meta tag, if you touch the newly created icon it should open the webpage as a standalone app (without using the browser). Of course the app is still just a website rendered using a web view. The status bar won't match the white background and it has no custom icon or splash screen yet, but we're going to fix those issues right now. 📱

Custom name and icon

To provide a custom name we just have to add a new meta tag, fortunately the SwiftHtml library has predefined enums for all the Apple related meta names, so you don't have to type that much. The icon situation is a bit more difficult, since we have to add a bunch of size variants.

struct IndexTemplate: TemplateRepresentable {
    
    let context: IndexContext
    
    init(_ context: IndexContext) {
        self.context = context
    }
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(context.title)
                
                Meta().charset("utf-8")
                Meta().name(.viewport).content("width=device-width, initial-scale=1")
                
                Meta()
                    .name(.appleMobileWebAppCapable)
                    .content("yes")
                
                Meta()
                    .name(.appleMobileWebAppTitle)
                    .content("Hello PWA")
                
                Link(rel: .appleTouchIcon)
                    .href("/img/apple/icons/192.png")

                for size in [57, 72, 76, 114, 120, 144, 152, 180] {
                    Link(rel: .appleTouchIcon)
                        .sizes("\(size)x\(size)")
                        .href("/img/apple/icons/\(size).png")
                }
            }
            Body {
                Main {
                    Div {
                        H1(context.title)
                        P(context.message)
                    }
                }
            }
        }
    }
}

As you can see icons are referenced by using the Link tag, using the Apple touch icon rel attribute. The default icon without the sizes attribute can be a 192x192 pixel image, plus I'm providing some smaller sizes by using a for loop here. We also need to serve these icon files by using Vapor, that's why we're going to alter the configuration file and enable the FileFiddleware.

import Vapor
import SwiftHtml

var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }

app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))

app.http.server.configuration.hostname = "0.0.0.0"
if let hostname = Environment.get("SERVER_HOSTNAME") {
    app.http.server.configuration.hostname = hostname
}

app.get { req -> Response in
    let template = IndexTemplate(.init(title: "Hello, World!",
                                    message: "This page was generated by the SwiftHtml library."))
    
    return req.templates.renderHtml(template)
}

try app.run()

By adding the FileMiddleware to the app with the public directory path configuration your server app is able to serve static files from the Public directory. Feel free to create it and place the app icons under the Public/img/apple/icons folder. If you are running the server from the command line you'll be fine, but if you are using Xcode you have to specify a custom working directory for Vapor, this will allow the system to look up the public files from the right place.

Your custom icons won't show up if you are using a self-signed certificate.

Build and run the server and try to bookmark your page again using your phone. When you see the add bookmark page you should be able to validate that the app now uses the predefined Hello PWA name and the image preview should show the custom icon file instead of a screenshot of the page.

Proper status bar color for iOS PWAs

Long story short, there is a great article on CSS-Tricks about the most recent version of Safari and how it handles various theme colors on different platforms. It's a great article, you should definitely read it, but in most of the cases you won't need this much info, but you simply want to support light and dark mode for your progressive web app. That's what I'm going to show you here.

For light mode we're going to use a white background color and for dark mode we use black. We're also going to link a new style.css file so we can change the background of the site and the font color according to the current color scheme. First, the new meta tags to support theme colors both for light and dark mode.

struct IndexTemplate: TemplateRepresentable {
    
    let context: IndexContext
    
    init(_ context: IndexContext) {
        self.context = context
    }
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(context.title)
                
                Meta().charset("utf-8")
                Meta().name(.viewport).content("width=device-width, initial-scale=1")
                
                Meta()
                    .name(.appleMobileWebAppCapable)
                    .content("yes")
                Meta()
                    .name(.appleMobileWebAppTitle)
                    .content("Hello PWA")
                
                Meta()
                    .name(.colorScheme)
                    .content("light dark")
                Meta()
                    .name(.themeColor)
                    .content("#fff")
                    .media(.prefersColorScheme(.light))
                Meta()
                    .name(.themeColor)
                    .content("#000")
                    .media(.prefersColorScheme(.dark))
                
                Link(rel: .stylesheet)
                    .href("/css/style.css")
                
                Link(rel: .appleTouchIcon)
                    .href("/img/apple/icons/192.png")
                for size in [57, 72, 76, 114, 120, 144, 152, 180] {
                    Link(rel: .appleTouchIcon)
                        .sizes("\(size)x\(size)")
                        .href("/img/apple/icons/\(size).png")
                }
            }
            Body {
                Main {
                    Div {
                        H1(context.title)
                        P(context.message)
                    }
                }
            }
        }
    }
}

Inside the style CSS file we can use a media query to detect the preferred color scheme, just like we did it for the .themeColor meta tag using SwiftHtml.

body {
    background: #fff;
    color: #000;
}
@media (prefers-color-scheme: dark) {
    body {
        background: #000;
        color: #fff;
    }
}

That's it, now the status bar should use the same color as your main background. Try to switch between dark and light mode and make sure everything works, there is a cool PWA demo project here with different colors for each mode if you want to double check the code. ✅

Splash screen support

Hint: it's ridiculous. Splash screens on iOS are problematic. Even native apps tend to cache the wrong splash screen or won't render PNG files properly, now if it comes to PWAs this isn't necessary better. I was able to provide splash screen images for my app, but it took me quite a while and switching between dark and light mode is totally broken (as far as I know it). 😅

In order to cover every single device screen size, you have to add lots of linked splash images to your markup. It's so ugly I even had to create a bunch of extension methods to my index template.

extension IndexTemplate {
    
    @TagBuilder
    func splashTags() -> [Tag] {
        splash(320, 568, 2, .landscape)
        splash(320, 568, 2, .portrait)
        splash(414, 896, 3, .landscape)
        splash(414, 896, 2, .landscape)
        splash(375, 812, 3, .portrait)
        splash(414, 896, 2, .portrait)
        splash(375, 812, 3, .landscape)
        splash(414, 736, 3, .portrait)
        splash(414, 736, 3, .landscape)
        splash(375, 667, 2, .landscape)
        splash(375, 667, 2, .portrait)
        splash(1024, 1366, 2, .landscape)
        splash(1024, 1366, 2, .portrait)
        splash(834, 1194, 2, .landscape)
        splash(834, 1194, 2, .portrait)
        splash(834, 1112, 2, .landscape)
        splash(414, 896, 3, .portrait)
        splash(834, 1112, 2, .portrait)
        splash(768, 1024, 2, .portrait)
        splash(768, 1024, 2, .landscape)
    }
    
    @TagBuilder
    func splash(_ width: Int,
                _ height: Int,
                _ ratio: Int,
                _ orientation: MediaQuery.Orientation) -> Tag {
        splashTag(.light, width, height, ratio, orientation)
        splashTag(.dark, width, height, ratio, orientation)
    }
        
    func splashTag(_ mode: MediaQuery.ColorScheme,
                   _ width: Int,
                   _ height: Int,
                   _ ratio: Int,
                   _ orientation: MediaQuery.Orientation) -> Tag {
        Link(rel: .appleTouchStartupImage)
            .media([
                .prefersColorScheme(mode),
                .deviceWidth(px: width),
                .deviceHeight(px: height),
                .webkitDevicePixelRatio(ratio),
                .orientation(orientation),
            ])
            .href("/img/apple/splash/\(calc(width, height, ratio, orientation))\(mode == .light ? "" : "_dark").png")
    }
    
    func calc(_ width: Int,
              _ height: Int,
              _ ratio: Int,
              _ orientation: MediaQuery.Orientation) -> String {
        let w = String(width * ratio)
        let h = String(height * ratio)
        switch orientation {
        case .portrait:
            return w + "x" + h
        case .landscape:
            return h + "x" + w
        }
    }
}

Now I can simply add the splashTags() call into the head section, but I'm not sure if the result is something I can totally agree with. Here, take a look at the end of this tutorial about splash screens, the code required to support iOS splash screens is very long and I haven't even told you about the 40 different image files that you'll need. People are literally using PWA asset generators to reduce the time needed to generate these kind of pictures, because it's quite out of control. 💩

Safe area & the notch

A special topic I'd like to talk about is the safe area support and the notch. I can highly recommend to read this article on CSS-Tricks about The Notch and CSS first, but the main trick is that we can use four environmental variables in CSS to set proper margin and padding values.

First we have to change the viewport meta tag and extend our page beyond the safe area. This can be done by using the viewport-fit cover value. Inside the body of the template we're going to add a header and a footer section, those areas will have custom background colors and fill the screen.

struct IndexTemplate: TemplateRepresentable {
    
    let context: IndexContext
    
    init(_ context: IndexContext) {
        self.context = context
    }
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(context.title)
                
                Meta()
                    .charset("utf-8")
                Meta()
                    .name(.viewport)
                    .content("width=device-width, initial-scale=1, viewport-fit=cover")
                    //.content("width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no")
                
                Meta()
                    .name(.appleMobileWebAppCapable)
                    .content("yes")
                Meta()
                    .name(.appleMobileWebAppTitle)
                    .content("Hello PWA")
                
                Meta()
                    .name(.colorScheme)
                    .content("light dark")
                Meta()
                    .name(.themeColor)
                    .content("#fff")
                    .media(.prefersColorScheme(.light))
                Meta()
                    .name(.themeColor)
                    .content("#000")
                    .media(.prefersColorScheme(.dark))
                
                Link(rel: .stylesheet)
                    .href("/css/style.css")
                
                Link(rel: .appleTouchIcon)
                    .href("/img/apple/icons/192.png")
                for size in [57, 72, 76, 114, 120, 144, 152, 180] {
                    Link(rel: .appleTouchIcon)
                        .sizes("\(size)x\(size)")
                        .href("/img/apple/icons/\(size).png")
                }
                
                splashTags()
            }
            Body {
                Header {
                    Div {
                        P("Header area")
                    }
                    .class("safe-area")
                }
                
                Main {
                    Div {
                        Div {
                            H1(context.title)
                            for _ in 0...42 {
                                P(context.message)
                            }
                            A("Refresh page")
                                .href("/")
                        }
                        .class("wrapper")
                    }
                    .class("safe-area")
                }

                Footer {
                    Div {
                        P("Footer area")
                    }
                    .class("safe-area")
                }
            }
        }
    }
}

Except the background color we don't want other content to flow outside the safe area, so we can define a new CSS class and place some margins on it based on the environment. Also we can safely use the calc CSS function if we want to add some extra value to the environment.

* {
    margin: 0;
    padding: 0;
}
body {
    background: #fff;
    color: #000;
}
header, footer {
    padding: 1rem;
}
header {
    background: #eee;
}
footer {
    background: #eee;
    padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.safe-area {
    margin: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
}
.wrapper {
    padding: 1rem;
}
@media (prefers-color-scheme: dark) {
    body {
        background: #000;
        color: #fff;
    }
    header {
        background: #222;
    }
    footer {
        background: #222;
    }
}

It looks nice, but what if we'd like to use custom styles for the PWA version only?

Detecting standalone mode

If you want to use the display mode media query in your CSS file we have to add a manifest file to our PWA. Yep, that's right, I've mentioned before that Apple prefers to use meta tags and links, but if you want to use a CSS media query to check if the app runs in a standalone mode you'll have to create a web manifest.json file with the following contents.

{
  "display": "standalone"
}

Next you have to provide a link to the manifest file inside the template file.

struct IndexTemplate: TemplateRepresentable {
    
    // ...
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                // ...
                
                splashTags()
                
                Link(rel: .manifest)
                    .href("/manifest.json")
            }
            Body {
                // ...
            }
        }
    }
}

In the CSS file now you can use the display-mode selector to check if the app is running in a standalone mode, you can even combine these selectors and detect standalone mode and dark mode using a single query. Media queries are pretty useful. 😍

/* ... */

@media (display-mode: standalone) {
    header, footer {
        background: #fff;
    }
    header {
        position: sticky;
        top: 0;
        border-bottom: 1px solid #eee;
    }
}
@media (display-mode: standalone) and (prefers-color-scheme: dark) {
    header, footer {
        background: #000;
    }
    header {
        border-bottom: 1px solid #333;
    }
}

You can turn the header into a sticky section by using the position: sticky attribute. I usually prefer to follow the iOS style when the website is presented to the end-user as a standalone app and I keep the original theme colors for the web only.

Don't forget to rebuild the backend server, before you test your app. Since we've made some meta changes you might have to delete the PWA bookmark and install it again to make things work. ⚠️

As you can see building good-looking progressive web apps for iOS is quite tricky, especially if it comes to the metadata madness that Apple created. Anyway, I hope this tutorial will help you to build better PWAs for the iOS platform. This is just the tip of the iceberg, we haven't talked about JavaScript at all, but maybe I'll come back with that topic in a new tutorial later on.

Share this article
Thank you. 🙏

Get the Practical Server Side Swift book

Swift on the server is an amazing new opportunity to build fast, safe and scalable backend apps. Write your very first web-based application by using your favorite programming language. Learn how to build a modular blog engine using the latest version of the Vapor 4 framework. This book will help you to design and create modern APIs that'll allow you to share code between the server side and iOS. Start becoming a full-stack Swift developer.

Available on Gumroad
Picture of Tibor Bödecs

Tibor Bödecs

CEO @ Binary Birds

Server side Swift enthusiast, book author, content creator.