A simple image viewer
This week I needed to implement full screen image viewing/zooming, Photos.app style. It didn’t need to show a gallery or even function as a carousel. I just wanted it to support double tap to zoom, pinch to zoom, and swipe to dismiss. There are doubtless innumerable libraries that provide this behavior and more since it’s so commonplace. Honestly, I’m surprised Apple doesn’t offer a proper UIKit class for it yet. But they still provide you something that gets you most of the way there: UIScrollView.
Scroll views naturally support panning as well as zooming. And since this is the foundation of what’s needed for a full screen image viewer, I started there to see how far I could get.
Spoiler: I got all of the way there.
Declaration and init
A view controller like this doesn’t make sense in Storyboard, so we configure it for programmatic initialization.
This establishes our public API and our primary view components, save for the scroll view which is declared as a lazy var further down to hide its configuration details away from where our core functionality will reside:
The oddity here is probably the disabling of contentInsetAdjustmentBehavior
, which keeps UIKit from inserting its standard content insets for safe areas. You may want this, but I didn’t, and disabling it also prevented some extra arithmetic in calculations we’ll be performing. It wasn’t complicated, just noisy. The rest of these configurations are what you’d probably expect given our functionality.
Layout
Most of our busy work takes place in viewDidLoad:
Here we ready our imageView
and scrollView
by adding them to the view hierarchy with constraints. The scrollView
gets pinned to all edges of our view, while the imageView
gets pinned to the content edges of the scrollView
, so to allow it to extend beyond our view’s bounds when resized. Finally we add the double-tap gesture recognizer, which is just a standard UITapGestureRecognizer with numberOfTapsRequired = 2
configured in a lazy var.
Just before the view appears, we set the zoom configurations for the scrollView
based on the image size:
We want the displayed image to never zoom out any further than the size of our view, because that would be pointless. But we also want it to never zoom in any further than the height of the view. This allows the image to always bounce on the vertical scroll axis, which is how we’re going to enable dismissal. If we let the user zoom in further, we would create a condition where they would be unable to dismiss the image via the expected gesture, or we would need to make some concession on the vertical scroll experience where sometimes scrolling without a bounce does dismiss (e.g. via a high enough velocity).
That wouldn’t necessarily be a bad thing, but it would be more complicated, and I didn’t deem it necessary for my use case. My users aren’t really going to need to zoom in further than the height of their screen, and if they want to, they can still achieve that by holding a pinch gesture in place. When they let go, the zoom will bounce back and they’ll be able to dismiss.
Note: These zoom scales assume that the image’s height will never be the height of the view, which would prevent zooming. They also assume that the view is never in landscape orientation. Both of these were fine for my situation, because I knew both will always be true for the foreseeable future.
This is the advantage of writing your own solutions; you have only the exact amount of code that you need and no more. And if you ever do need more, you can add to it, because you own it.
Gestures
Handling double-taps is easy enough. If we’re not yet zoomed in all the way, zoom in. Otherwise, zoom out.
Things get a bit more complex inside of our swipe-to-dismiss logic. We want to meet three conditions before we dismiss:
- The scroll view is bouncing at its content edge
- The bounce is in the direction with the highest velocity
- The velocity is significant
The first one is a given; it’s the desired functionality. Why isn’t detecting bounce by itself sufficient?
Well, technically it is. But I don’t think it would feel polished. The tiniest bounce could trigger a dismiss, including in a direction the user wasn’t intentionally trying to scroll. Remember, the image will always bounce vertically. So if a user is panning to the left or right, they’ll trigger a dismiss if their swipe isn’t perfectly horizontal. We also don’t want to dismiss if there is no velocity, which is the case when the user drags, stops, then releases. We only want to dismiss when the user is already at a scroll edge and swipes primarily in the bouncing direction with ending velocity (inertia).
I’ve probably made this sound really complicated, but the resulting logic is pretty much three lines:
Note: This is one point where we would need to factor in the safe area insets had we not removed them.
This detects all three of our required conditions and is self contained within the scroll view’s delegate method; there is no outside state or helper functions to maintain.
Centering
If you run this now, you’ll find that it works, but the image is always at the top of the screen. Turns out that there isn’t a built-in way to keep UIScrollView content centered when zoomed out. It’s not too bad to math yourself though.
Now the scroll content will always offset from the top relative to the current zoom level. This looked a little lower than center to me, so I subtract the visualCenteringOffset
value to keep the image where my eyes wanted it to be. We make sure the inset is never negative, which is possible due to the admittedly blunt visual centering solution. There’s a more accurate formula to apply here, but I moved on.
Now when we run it, everything feels pretty dang good. Not as polished as Photos.app, but maybe like, 80% of the way there? And easily with less than a third of the code and complexity you’d get from using a library. There is no state or external configuration in this controller aside from the image itself. You simply won’t find that in any library that does this for you.
No transitions?
Right now the most notable bit of missing polish is the lack of custom transitions. We’ve got a simple cross fade which doesn’t look bad, but the expected transition for this interaction animates the image from its position in the source view controller to its final zoom/position in the viewer controller, then reverses out from the swipe gesture when dismissing. This isn’t difficult to implement with UIViewControllerAnimatedTransitioning
, but it also wasn’t a priority during implementation relative to the time investment. This is certainly the point where you could end up winning in the short term by adding a dependency.
If image viewing were a primary aspect of your app’s experience however, I think the extra time from doing it yourself right now would be justifiable. This wasn’t my situation, so I’ll probably revisit it during some downtime in the future, perhaps when the feature containing the functionality is expanded. For now, this is more than sufficient, and easy to revisit and extend. It is the right amount of code.