Customizing UIWebView for PDFs in Swift

UIWebViews are a handy way to show web content in an iOS app. They are especially useful for showing PDFs that are stored remotely. Apple largely treats UIWebViews like a black box: Documentation warns that they should not be subclassed. So what if you want to customize how they show PDFs? Recently, I went after two customizations to change the default UIWebView behavior.

  1. Scale the PDF so the entire thing fills the view–whether or not it is a landscape or portrait PDF.
  2. Get rid of the default drop shadow around the PDF.

They both required some tinkering, so I’m sharing them in case others need to do something similar.

Customizing How the PDF is Scaled

Default behavior

There’s a property on the UIWebView called scalesPageToFit. If you set it to true, the PDF will start off scaled so that its width fills the width of the view. You can set it to true in your code or in the UIBuilder. And right out of the box, you get pinch and zoom. It’s great!

Desired behavior

Well, it’s great until you have a tall and skinny PDF and you want the whole PDF to show up when the user gets to that view. In other UIViews, you can set the contentMode property to AspectToFit to do this. However, the UIWebView actually has a couple of layers in the view hierarchy between it and the PDF we’re scaling.

Solution

After a lot of poking around in the subviews of my UIWebView, UIBuilder, and StackOverflow, I ended up writing a little function to scale the PDF so that the whole PDF is visible if it’s too tall and skinny to fit with the scalePageToFit default zoom. Here it is:


func updateZoomToAspectToFit(webView: UIWebView) {
        let contentSize: CGSize = webView.scrollView.contentSize //PDF size
        let viewSize:CGSize = webView.bounds.size
        let extraLength = contentSize.height - viewSize.height
        let extraWidth = contentSize.width - viewSize.width
        let shouldScaleToFitHeight = extraLength > extraWidth
        let zoomRangeFactor:CGFloat = 4
        if shouldScaleToFitHeight {
            let ratio:CGFloat = (viewSize.height / contentSize.height)
            webView.scrollView.minimumZoomScale = ratio / zoomRangeFactor
            webView.scrollView.maximumZoomScale = ratio * zoomRangeFactor
            webView.scrollView.zoomScale = ratio
        }
    }

All this does is check if the view is more overly-tall than overly-wide compared to the view’s content size. If it’s overly-wide, we don’t have to do anything because the scalePageToFit property magically takes care of that situation. If it’s overly-tall, we find the ratio of the view’s height to the content (a.k.a. the PDF I care about). That ratio is then assigned to the zoomScale. That zoomRangeFactor is a magic number that you can play around with. It needs to be greater than one to allow the user to pinch and zoom.

For this to work, updateZoomToAspectToFit needs to be called from the webViewDidFinishLoad(webView: UIWebView) callback. For webViewDidFinishLoad to be called, the UIWebView needs to be its own delegate: myWebView.delegate = self

Hiding the Drop Shadow

Default behavior

I was surprised to see that there was a drop shadow around the PDF in the UIWebView out of the box.

Desired behavior

The designer I was working with was not only surprised, but displeased. So my mission was to get rid of the drop shadow. I tinkered in UIBuilder to no avail. I dissected the view hierarchy in debug mode. StackOverflow suggestions were full of code that goes below Apple’s documentation and could break with the tiniest update.

Solution

Long story short, I found that I could legally (according to Apple’s documentation) traverse the subviews of the UIWebView and set the shadowOpacity attribute on all of them:


func removeShadow(webView: UIWebView) {
    for subview:UIView in webView.scrollView.subviews {
        subview.layer.shadowOpacity = 0
        for subsubview in subview.subviews {
            subsubview.layer.shadowOpacity = 0
        }
    }
}

Like updateZoomToAspectToFit, in order for this to work, removeShadow must be called from the webViewDidFinishLoad(webView: UIWebView) callback. That’s because the subviews won’t be available until after the PDF has loaded. For webViewDidFinishLoad to be called, the UIWebView needs to be its own delegate: myWebView.delegate = self.

The Bigger Picture

Figuring these two customizations improved my fluency with iOS’s UIKit. I enjoyed the challenge of figuring out how to go just underneath the hood but not hack it so badly that it becomes fragile and susceptible to silently break in updates. This is what I love about making software at Atomic–we can take the time to do things right, and share when we figure out how to accomplish something.