How to turn your Medium post(s) into a SwiftUI app that works on both iOS and macOS
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.
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.
To get started, create a new Single View App project in Xcode, making sure you choose SwiftUI as the user interface. This will give you everything you need for a SwiftUI app on iOS, but if you want macOS support you’ll need to click the name of your app at the top of the Project Inspector in the left panel. This will give you access to all of your project’s settings, including what platform it supports. Tick MacOS, and click enable.
Getting the HTML
You’re going to need the HTML from your Medium story. Open your story in Safari, but don’t go into Edit Mode. Instead, go to File > Save As, and make sure that Format is set to Page Source and not Web Archive. Save the HTML somewhere on your Mac, and next you’ll need to edit that file in a text editor. There are a variety of HTML code editors out there, but I chose Visual Studio Code which is free. It doesn’t really matter what you choose to edit with, but I found that Xcode itself can have problems opening and editing large HTML files.
Obviously don’t open your HTML in a browser, as this will display it and not allow you to edit it.
<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.
<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
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
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.
In Swift the Codable protocol allows us to easily create value types that store data which we can easily convert to and from JSON. Why would we want to do that? The JSON of your data might be useful if you expect to store the text of your post on a server, so that the app can always fetch the latest version. I decided it would be easier to have my data in a JSON file, which is why I took this approach.
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
\", 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
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
Now that we’re no longer creating the JSON in the initialiser of our DataModel, we’ll need to load it from the file. Since the JSON should now be in a file called
Chapters.json that is in the Project Inspector and added to the app target, we simply need to find the URL of that file to access it. This is pretty easy, and it shouldn’t matter whether you decide to put the file in a folder or not, as long as you don’t give another file the same name! Once we have the URL, decoding the JSON is a lot like encoding it. I’ve provided specific catches that should help you to work out what's wrong if your JSON has any decoding issues, but otherwise, you should be good to go.
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
Let’s create a Swift file called
WebView. This is where we’ll display our chapters, and using a
WKWebView allows us to also link to external sites. Instead of allowing the
WebView to visit any site, we’ll make sure that websites open in Safari. I’ll explain why. For my app, I used a lot of Github Gists, which are the shortcode samples included in this tutorial. When you tap on the name of the gist or tap on ‘Open raw’, you are sent to Github’s website to view the content full size.
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
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
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
I don’t recommend trying to use the image source URLs you got when you took the HTML from your post. For one thing, the scaling will be wrong, but I also don’t want to condone anyone making apps that repeatedly make server requests to Medium. Instead, you’ll be using the images themselves, and loading them from the bundle that gets installed with your app. If you have all your original images, you’re one step ahead of where I was. I had no idea where all my original images were, so I just went to my post (without being in Edit Mode) and left-clicked them. This brings them up in a larger viewer, and now it’s easy to right-click or control-click and say Save Image As.
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
Since the MasterView references it, it makes sense that we make the DetailView first. Remember when I said that we are not yet making our font size accessible? Now we are! By referencing the
sizeCategory environment property, even without using it directly, we are prompting SwiftUI to give us up to date information about what font size has been selected in the Accessibility settings. By using
UIFontMetrics we are able to scale our starting font size to a size that is appropriate.
Many thanks to Hacking With Swift’s tutorial on Dynamic Type that showed me how to respect accessibility settings even inside a
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.
Creating the MasterView that shows the list of chapters
The MasterView is arguably even less complicated. ForEach allows you to dynamically display data from your DataModel class. If our Chapter Codable struct conformed to Hashable, we might be able to iterate through it without using an index. However, I’ve decided to use an index as I find it quite a useful way to guarantee that each row of the
List passes the index it used to find its own title to the
DetailView, which then uses that same index to access the same array to get the corresponding content.
Displaying the Master-Detail Interface in ContentView
The final piece of the puzzle brings all of these elements together and completes the app. Notice how both the MasterView and DetailView are displayed, as this allows us to set the initial state of the app when it first opens. Oddly when this app opens on an iPad in portrait orientation, the MasterView is completely hidden unless you swipe in from the left. I’ve warned my users about this in my first chapter, as they may be confused as to why there is no visible menu.
You may need to do the same, or change the navigation view style as follows:
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.
I learnt the way this Master-Detail app works from Xcode’s Master-Detail App template, which I mentioned at the start of the post. I suggest you check out some of the other project templates, as they contain surprisingly complete examples of how your apps can be created. If you’d rather not use HTML and CSS, you could make a version of this app that uses attributed strings instead. Hacking With Swift’s tutorial on NSTextAttachment tells you how you can actually put images into an attributed string, although I haven’t tried it. For my particular case, I needed to be able to display my Github Gists, so I couldn’t take this approach for my post.
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.