How to turn your Medium post(s) into a SwiftUI app that works on both iOS and macOS

Rob Sturgeon
Jun 6, 2020 · 17 min read
Photo by Free-Photos on Pixabay

I recently turned one of my posts into a SwiftUI app, and the process is very easy. My post was documentation for SwiftUI itself, so it was separated into segments for each of the Views and classes. In this tutorial, I will be referring to these as chapters, but what I’m really talking about is the different sections that will be displayed in the app. Obviously you can’t make an app out of just a single post without any interactivity or segmentation, so if your posts are all one long piece you might want to include multiple Medium posts with one post per chapter.

You actually don’t need to have a Medium post to make this app. It will display HTML from anywhere, I just give some specific instructions about extracting the HTML from a Medium post here so that you can follow in my footsteps. What we’re essentially creating here is a SwiftUI HTML reader, which isn’t really the same as a web app. After all, we have the ability to do whatever we want with SwiftUI, and HTML doesn’t (and shouldn’t) need to be a central component of our app’s functionality.

This is just a quick way to get our content from here to there.

This is the Master-Detail layout we will be making displayed on an iPad

If you want to get used to the Master-Detail layout in SwiftUI before starting this tutorial, you might want to take a look at the template that Xcode provides. To do this, create a new project with the Master-Detail App template. I don’t recommend starting the tutorial with this though. It provides a way to add the current time to a list of data, but much of the default functionality would need to be removed to start the tutorial. It does show you what the interface will look like though, with a List that is full screen on iPhone and in a side panel on iPad and macOS.

Getting started

Getting the HTML

Obviously don’t open your HTML in a browser, as this will display it and not allow you to edit it.

On opening the file, you’ll be greeted by a head tag inside which lies a small JavaScript function and a lot of metadata for search engines. Though the body tag eventually starts, it still has to give information about the Medium header, so think there’s an easy way to find the start of your article. Press cmd + F to bring up Find (in most text editors) and type <p including the space after it. As all tags automatically added to your post contain both an id and a class, searching for <p> will not return any results.

Searching for <p without a space at the end will probably get you to the path that is used in drawing the Medium header.

If your post starts with an image and you want to include it, you’ll be searching for the <img tag instead.

Now that you know where your post starts, you should probably get rid of everything above it that you don’t need. Delete everything above the element you searched for, including the opening body tag, the entire head tag and the <!doctype html> tag right at the start of the file. We won’t be needing any of this, as we’ll be adding it again in code at a later time. Now that you have deleted everything above the start of your post, it’s time to find everything below it. Search for the closing </article> tag, as there should only be one. Mine had two closing </div> tags before it too, so I deleted these and everything below that.

My documentation post was a 45 minute read, so it makes sense that my post’s full HTML came out as 191,427 characters. What I found more surprising was everything that was below. After a few div tags containing Medium’s responses section and related stories, I found 652,174 characters of extra JavaScript at the end. I’m not a web developer, so I’m not going to venture a guess at what this all does.

If you have any ideas about what all that extra JavaScript, let me know below!

Now you should have only the HTML of your post, so save it and open it in a browser. What do you see? If you have any images, you’ll notice that they’re probably massive and pixellated at the same time. We won’t be displaying images the same way as Medium, so these will need to be changed. If you don’t have any images, skip to the Codable structs section below.

Here’s what we will need our images to look like, along with what they look like in the original HTML for comparison:

Because we will be adding our images to the assets of the app, we will not need to use the internet to fetch them each time. As we are accessing them from the main bundle, we also won’t need any sort of complicated path. Width and height will be handled by our custom CSS, which will be created later. If you really care about having a caption under your image, you can decide how you want to display that. I’m using padding for p, h1 and h2 in my CSS, so you probably don’t need to put your chapter’s whole HTML in a div. I’ve managed to avoid using any div tags in my HTML.

You will also not need to add a closing body tag at the end of the HTML, as this will also be added later.

Codable structs

Create a new file called DataModel, and add two Codable structs.

For the initial version of my app, my Codable structs took the following format:

Create a file called JSONMaker, and create a struct inside it of the same name. This is where we’ll store the data for our Xcode project, but it won’t be compiled into the app. That’s what the JSON is for! Create a struct called JSONMaker inside this file, along with a function called createJSON(). To make things easier I’ve called createJSON() in the initialiser for the struct, meaning all we need to do is create an instance of the struct and we’ll get our JSON string.

Although I currently only have the title and HTML for each Chapter, it’s easy to see how I could add more properties here. Similarly, the Book object, which simply provides the array of Chapter objects, could contain more useful information. But I’m keeping it simple and only providing the absolute necessities for this app to function.

I’ve provided the chapters array below createJSON(), only including the Welcome chapter for brevity.

I made the mistake of writing my JSON myself first, but that makes it impossible for Swift to let you know when a string has errors relating to escape characters. You’ll notice as you copy each chapter into Swift that any speech marks in the original HTML cause the string to end early. I used Find & Replace to replace all occurrences of " with \", which will also apply it to the start and end of your strings, so be sure to change those back to normal speech marks.

Notice that I’m using """ to indicate the start and end of a multiline string. This makes it easier to lay your HTML out in a way that makes sense to you visually. It doesn’t really matter if line breaks make their way into the JSON data string, as line breaks are ignored by HTML. If you’d rather avoid this, or if you are fine with your HTML string being all on one line, a regular string would also be fine.

Now we need to create that JSON string I’ve been talking about! Go into DataModel and create a class that conforms to ObservableObject. This will make it easy to access our Swift file from our SwiftUI. Add an initialiser that creates an instance of the JSONMaker class. It doesn’t need a name, as we only want it to print the JSON string. This code will be removed once we have the JSON.

The last thing you’ll need to do is add your DataModel as an EnvironmentObject to your SceneDelegate file. This will make it easily accessible in your SwiftUI, as long as you declare it in each View that needs to reference it. This isn’t very complicated, as it just requires you to add a modifier to ContentView in the top function that SceneDelegate provides that passes in the shared static instance of DataModel.

Now simply add the following inside your ContentView struct:

@ObservedObject var data: DataModel

Build and run your app on iOS or your Mac, and if all goes well you should see the JSON string print to the console. This is a somewhat lazy way to do it, as I didn't go through the process of writing the JSON directly to a file. But this is good enough for our purposes. Since my JSON string was pretty long, I simply clicked inside the console, pressed cmd + A to select all, and copied it to a new file called Chapters.json. This included a few other debug messages from the console that I had to remove manually, but it was a lot easier than clicking and dragging to select the whole thing!

Now we have our data file, we’re ready to remove the JSONMaker file from the app. We can always add it again when we want to make a new JSON. With the JSONMaker file selected in the left panel, go to the File Inspector in the right panel and untick your app’s name under targets. You can now also remove let _ = JSONMaker from the initialiser of your DataModel class. If you ever make changes to your chapters, you’ll need to reverse this process to create a new JSON string.

Loading the JSON file

I was thinking of adding a print statement to this so that you can see the data was successfully loaded into the chapters array, but it will be obvious if you are calling loadJSON() and none of the assertionFailure() calls happened. This is all we need in the DataModel class, although it’s a good place to put any Swift data or functions that don’t directly relate to your interface. I originally put the function that makes the CSS string here, but I’ve decided to move it to the WebView instead.

Creating the WebView to display the HTML

If I allowed these links to open in my WebView, things get complicated. Github has a ‘Log in’ button, and you could start using Github as normal from inside my app. You could also follow links inside Github that take you anywhere on the internet, and now I have a browser in my app that isn’t displaying the content I want it to. In order to prevent this kind of navigation, we’re going to need a class that conforms to the WKNavigationDelegate protocol.

In UIKit, you often make your UIViewController subclass the delegate in this situation. But in SwiftUI there is no such class, so we need to create a class that still counts as an object. Since UIViewController inherits from NSObject among other things, it has what it takes to be a delegate. However, a custom class we make doesn’t inherit from this by default, so we have to specify it explicitly. I use another singleton shared instance here mostly so that I don’t accidentally create multiple instances of this delegate.

Note that the way I am restricting external links is by looking for HTTP or HTTPS in at the start of the URL.

If you insist on using images that are fetched from a web server, you will need to change this

A lot is going on under the NavigationDelegate class, and it’s all going on in the WebView struct. This struct conforms to UIViewRepresentable, which allows you to represent UIViews in SwiftUI. SwiftUI doesn’t currently have access to WKWebView, so this is the best we can do for now. In makeUIView we're creating the WebView and assigning the NavigationDelegate. We’re also making the WebView transparent, which will be useful when we make it work with Dark Mode. In updateUIView, we’re getting the HTML and CSS and displaying it in the WebView.

Note how we’re currently just displaying the entire HTML string, without separating it into chapters. This is to make it easier to preview the HTML and see how it came out. I provided an example PreviewProvider at the bottom, which ought to preview the WebView in the SwiftUI Canvas but it doesn’t work in my version of Xcode. If it works for you, that’s fine, otherwise, you may want to put that code inside the body: some View property of your ContentView struct. This will make it easy to view all of your HTML content and decide whether you like how it came out.

Notice how the CSS is constructed programmatically, taking two parameters. One provides a font size, which doesn’t currently take accessibility font sizes into account but it will soon! The other provides the colour scheme, which could be Light Mode or Dark Mode. This is to allow us to invert the colours of the WebView in Dark Mode, as this is the easiest way to handle this with a single CSS property. I previously inverted the entire WebView, which worked for some of my images but made others look strange.

When you apply a colour inversion filter to the entire body tag, you can’t override it in any of its children. This is because a filter is applied at the end, after all the other CSS has been applied. Instead, I decided to apply the filter to all of the individual children of the body tag, rather than the body tag itself. This allowed me to override it for only my images, and it’s easy to see how you could similarly override it for any of your other HTML elements. One of the main reasons why I used a filter was so that my Github Gists would be inverted in Dark Mode.

If I manually chose colours for my elements based on colour scheme, my gists would stay the same colour, and I like the way they look when inverted!

Adding the images

Now that you have all of your images, I recommend giving them lower case names with dashes. This is me trying to act like a web developer! If you want to know why I chose dashes instead of underscores, check out dashes-versus_underscores; The Definitive Guide. Although we aren’t dealing with actual HTTP(S) URLs like the ones that exist on the web, HTML still can’t deal with spaces in filenames. I also believe that you should play by the rules of the language you’re using, so I don’t want anyone to use camelCase or PascalCase for their image names!

Actually I don’t care, but that’s what I’m doing.

I created a folder in my project by right-clicking the yellow folder with the app’s name that is the second row of the Project inspector hierarchy and selecting New Group. I created a folder called Images, right-clicked it, chose Add files, and imported all of my images. Note that I’m not adding my images to the Assets.xcassets library. This is because this asset library behaves differently from the bundle directory, and you will not be able to access images that are there from HTML.

Did you see that the updateUIView method in WebView adds a baseURL parameter when loading the HTML string?

This tells our app to look in the local storage for files that have partial URLs. When I mentioned changing the HTML for the images, you might notice that I only included the filename. This is all you need even if, like me, your images are in a subdirectory called Images. Just make sure you don’t give several files the same name and you should be fine. Again you don’t need to provide a size for your images in HTML, because that will be handled by the CSS. Currently, I have mine set to reduce the width of the image to 60% of the WebView while maintaining its aspect ratio. You might want to use the full width, but I found that since my images were largely iPhone screenshots, this made the image too long and require a lot of scrolling.

Creating the DetailView that shows the WebView

Many thanks to Hacking With Swift’s tutorial on Dynamic Type that showed me how to respect accessibility settings even inside a WKWebView!

The reason I reference the sizeCategory is because I found that the size that seemed appropriate on the iPhone was too big on iPad and macOS. To deal with this, I have a different base font size for compact devices and another for larger ones. Both of these will still scale according to accessibility, but they’ll look more consistent when differing amounts of screen space are available.

I’m referencing the colourScheme so that I can use Dark Mode in my WebView. Otherwise, my HTML content would have a white background by default, as I mentioned in creating the WebView section above. The WebView is displayed inside a Group so that I can give it a navigationBarTitle, and I display this title inline. Displaying it inline makes more sense for the longer titles my chapters have. Otherwise, the titles will be displayed big and bold, and this causes truncation when the title is long.

Lastly, we have an index for the chapter that will be passed in when the chapter is selected from MasterView.

This is where we get all the device information that controls the CSS

Creating the MasterView that shows the list of chapters

Displaying the Master-Detail Interface in ContentView

You may need to do the same, or change the navigation view style as follows:

.navigationViewStyle(StackNavigationViewStyle())

This will eliminate the problem on iPad, but it will also force the MasterView to be full screen when the app starts, regardless of orientation or device. This completely eliminates the ability to have the MasterView and DetailView displayed on the screen at the same time, which I would argue is too high a price to pay to fix the portrait orientation iPad problem.

Who uses iPads in portrait orientation anyway?

Maybe they’ll guess that they need to swipe the MasterView in from the left, I have no idea.

The MasterView will be side by side with the DetailView, but only on iPadOS and MacOS

Next Steps

I only embedded the gists because I couldn’t be bothered to work out how to do colourful syntax highlighting. The other benefit of using HTML is obviously the ability to preserve hyperlinks, although I still recommend restricting links that HTTP or HTTPS in the scheme to opening in Safari only.

Mac O’Clock

The best stories for Apple owners and enthusiasts

Thanks to Zack Shapiro

Rob Sturgeon

Written by

An iOS developer who writes about gadgets, startups and cybersecurity. Swift programming tutorials and SwiftUI documentation too. robsturgeon.com

Mac O’Clock

The best stories for Apple owners and enthusiasts

Rob Sturgeon

Written by

An iOS developer who writes about gadgets, startups and cybersecurity. Swift programming tutorials and SwiftUI documentation too. robsturgeon.com

Mac O’Clock

The best stories for Apple owners and enthusiasts

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store