Designing Apps with Scroll Views _ Part I: Zooming and Scrolling on Photos
Based on Apple WWDC 2010 Designing Apps with Scroll Views
Introduction
This is the first part of a set of tutorials related to using scroll views in our app based on Apple WWDC 2010 Designing Apps with Scroll Views which you can watch it from here (HD|SD).
Here we want to make an app similar to theiOS photos app with the help of UIScrollView
useful features. You can study the next two parts of this tutorial from the links below:
Start From Scratch!
Open Xcode, create a new project, choose Single View App under the iOS tab bar, type PhotoScroller as Product Name and create aproject.
In the Project navigator under the ViewController.swift, Create a new file of Cocoa Touch Class, name it ImageScrollView and set it a subclass of UIScrollView
. Delete all commented lines between class curly braces and finally, you should see something like this:
This is a simple UIScrollView
subclass. Let’s change it and make it kind of scroll views that shows our images. Add this lines of code into the class curly braces:
We added a UIImageView instance that named zoomView and it’s going to show our image in scroll view.
Next, we added a method that:
1. Clears zoomView
, then reinitialized it with the new image given to method as an input argument.
2. Adds zoomView to Scroll view as its subview.
Now its time to see what shows us scroll view!
Note: We do not set any frame for zoomView. In such situations, UIImageView automatically set its frame based on image size.
Before we test our app, we need some high-quality images to scroll on it!
Download this photo and put it blew the ImageScrollView.swift in Xcode as you can see in the picture (Do not add the photo into Assets library):
Now go to ViewController.swift. make an instance of ImageScrollView
at the top of ViewController
class like this:
var imageScrollView: ImageScrollView!
And add this bunch of codes in to viewDidLoad()
method after super.viewDidLoad()
:
The codes are simple:
- We initialized
imageScrollView
and added it to ViewControllersview
. Note that we set the imageScrollViewframe
as viewControllers viewbounds
. It’s important that always you set scroller view frames even if you used constraints to fix it in the parent view. That’s because scroll view must know in which size it shows its content. - We created an image object. Notice we do not use of
UIImage(named:)
initializer. That’s becauseUIImage(named:)
caches image in memory even If you only use it one time and never use it again, insteadUIImage(contentsOfFile:)
caches image in memory until you need it and as soon as you leave image, it purges image from memory too, and if you need to image be reloaded later, the image object loads that data again from the specified path. Since we use a high-quality photo here, then it is better to useUIImage(contentsOfFile:)
. It’s not a good idea to hold a high-quality photo in memory all the time and that’s why I said do not add the photo to assets library.
Note: For more information about UIImage initialization check out Apple Developer Documentation.
3. We asked imageScrollView
to display our photo in the scroll view.
Build and run the app. You should see something like this on your phone screen:
A huge blue sky! It was probably not what you expected to see.
Try to scroll photo or pinch it. Did nothing happen?
Don’t worry, we fix it soon.
Let’s see what’s the problem. The problem is that the scroll view does not know what we want! All information we give it till now is the photo we wanted to show and the frame we wanted to see the scroll view in that dimension.
See? In fact, It has done its job very well! So if we want more, we should give it more information. Let’s do it!
Make it scrolls
One of the information, we have to give it to the scroll view is that How big is the size of our photo. For this purpose, go to ImageScrollView.swift and add this method implementation after display(_ image: UIImage) {…}
:
In this simple method, We give the size of the image to scroll view as its content size.
add self.configureFor(image.size)
at the end of display(_ image: UIImage)
method after self.addSubview(zoomView)
line.
Build and run the app. Try to scroll photo, It should scroll now.
Does it scroll? Good!
Well, let’s just do some configuration on the scroll view. Add these codes at the top of ImageScrollView
class just after var zoomView: UIImageView!
:
In the above codes, we set some changes on scroll view when it initializes;
The first two lines hide horizontal and vertical scroll indicators.
The third line cause to scroll view decelerate faster its speed to stop (test the app with an without this line, to see differences).
Pinch it
What about pinching and zooming?
For this case, the scroll view needs to know two things:
The first is the view which we like to apply zoom on it and the second is the range of zoom scale
For the first one, ImageScrollView
must adopt UIScrollViewDelegate
protocol and implement its viewForZoomng(in:)
method.
Add UIScrollViewDelegate
to ImageScrollView
like blew:
And add this method after configureFor(_ imageSize: CGSize) {…}
method:
This method asks the delegate (which here is ImageScrollView
) for the view to scale when zooming is about to occur in the scroll view.
One more thing to do has remained, and that is we should set the scroll view as the delegate of itself. For this purpose, just add self.delegate = self
into the init(frame: CGRect)
initializer after self.decelerationRate = UIScrollViewDecelerationRateFast
line.
With this line, scrollView’s delegate will be set when it initializes.
But, how to set a range of zoom scale?
For this purpose, scroll view has two properties which are minimumZoomScale
and maximumZoomScale
. minimumZoomScale
determines how many you could zoom out the content of scroll view and maximumZoomScale
determines how many you could zoom in the content of scroll view, relative to the current scale of its content. The default value of both of these properties is 1.0
.
Add blew codes in to configureFor(_ imageSize: CGSize)
method after self.contentSize = imageSize
:
Now build and run the project. You should be able to pinch and zoom now.
Note: If you testing app on the simulator, for pinch you must hold option ( ⌥ ) key on the keyboard.
If you played enough with the pinching and zoom in/out photo let’s answer to a question:
What’s the appropriate value for these properties?
For minimumZoomScale
, it’s usually better to be fit on the screen, when the photo gets zoom out.
But maximumZoomScale
is a little complicated. When we set maximumZoomScale
to 1.0
, it allows the photo to be scaled to its actual size, but this is not a good idea for photos like the one we used in this tutorial. As you saw When this photo is in its actual size, We could only see a very small part of the photo, so in such cases, It’s better to have a maximumZoomScale
value, less than 1.0
. But this is not always true. There are photos with smaller size out there and they need their own maximumZoomScale
and we should consider them as well.
Let’s dive in codes, and see the calculations needed for each of these values.
Add this method after configureFor(_ imageSize: CGSize) {…}
method:
For minimumZoomScale
we calculated two value named xScale
and yScale
, which are respectively the scroll view bound’s width to image width ratio and scroll view bound’s height to image height ratio. The minimum of these values causes the photo to be fit in the screen, so minimumZoomScale = minScale
.
For maximumZoomScale
, we set several conditions with respect to minimumZoomScale
to be sure we have the right amount of maximumZoomScale
for photos of different sizes.
Replace self.setMaxMinZoomScaleForCurrentBounds()
with the last two lines in configureFor(_ imageSize: CGSize)
method. Your configureFor(_ imageSize: CGSize)
now must be like this:
Build and run the app. Now if you zoom out the photo with pinching, It should be fit inside the screen.
Fit Photo To Screen at First Launch
How we have a fit screen photo when we first launch the app?
Scroll view has another property named zoomScale
.
This property is responsible for the scale of the photo at any moment and its default value is 1.0
. That’s why you see the photo at the actual size when you run the app. So if you want to see it fit you should set zoomScale
to minimumZoomScale
.
Add self.zoomScale = self.minimumZoomScale
at the end of configureFor(_ imageSize: CGSize)
after self.setMaxMinZoomScaleForCurrentBounds()
.
Put the Photo in the Middle
There is one drawback here which is that the photo is up at the top which isn’t really what we want. We want as the image gets zoomed out to be smaller than the screen is centered in the screen rather than hugging the upper left corner.
How do that?
To do this we overriding the layoutSubviews()
method. The advantage of the layoutSubviews()
is that it’s called at every frame of both zooming and scrolling. So if we want to keep a view centered, this is a perfect place to do it.
Add these method after setMaxMinZoomScaleForCurrentBounds() {…}
method:
In this method, we calculate and check if each Side of the photo is smaller than the screen, change the position of photo and put at the middle of the screen.
Now override add this method after required init?(coder aDecoder: NSCoder) {…}
initializer:
This method overrides layoutSubviews()
and calls our centerImage()
method, each time layoutSubviews()
being called.
Build and run and you will see the photo is now in the middle.
If you noticed, when you zooming out the photo and then you leave it, The photo goes to the left first and then comes to the middle. It’s not good.
To solve this problem, we need to implement another of UIScrollViewDelegate
methods and call centerImage()
method into it too. Add this method after viewForZooming(in scrollView: UIScrollView) -> UIView? {…}
method:
Problem solved!
Note: Have you noticed that bouncing mode when you zoom out the photo more than its minimumZoomScale and then you leave it? This is because of a property of
UIScrollView
namedbounces
, which is a Boolean value that controls whether the scroll view bounces past the edge of content and back again. If the value of this property istrue
, the scroll view bounces when it encounters a boundary of the content. Bouncing visually indicates that scrolling has reached an edge of the content. If the value isfalse
, scrolling stops immediately at the content boundary without bouncing. The default value of this property istrue
. Try to addself.bounces = false
in tooverride init(frame: CGRect) {
, and see what happens.
Turn to Landscape!
If you turn your phone to landscape, you will see photo falling to down left of screen.
That’s because we’ve not set up imageScrollView
layout yet.
Go to ViewController.swift file and add This method after viewDidLoad() {…}
method:
In this method, we fixed all edges of imageScrollView to the viewControllers view
. So if you turn phone to landscape imageScrollView
will be turn with viewControllers view
too.
Note: If you’re not familiar with using layout constraints programmatically, it’s very similar to using layout constraints in story board. Pay attention to the above codes. Be sure you will learn very soon.
just be careful each time you set layout programmatically be sure to settranslatesAutoresizingMaskIntoConstraints
of the view tofalse
.
Because at the default this property is ontrue
and it means system automatically creates a set of constraints that duplicate the behavior specified by the view’s autoresizing mask ( Some static, frame-based layout within Auto Layout). Therefore, you cannot add additional constraints to modify this size or position without introducing conflicts. If you want to use Auto Layout to dynamically calculate the size and position of your view, you must set this property tofalse
.
Add self.layoutImageScrollView()
into the viewDidLoad()
method after self.view.addSubview(self.imageScrollView)
line:
Build and run the app. The photo does no longer fall!
Now there is another problem. As you see the photo is not fit to screen anymore. That’s because we did not tell to the imageScrollView
we turned phone to landscape so reset your scale range please!
But this is not all problems. Try to zoom in photo in portrait and then turn iPhone to landscape, If you look carefully, you’ll notice zooming location on the photo is changed. Our plan for this problem is we save the position and zoom scale of photo before changes of rotation be applied on screen and then ask to the imageScrollView
to restore to our saved scale and position of zooming!
So let’s do it. Go to ImageScrollView.swift file and add these methods at the end of ImageScrollView
class after scrollViewDidZoom(_ scrollView: UIScrollView) {…}
method:
Don’t be afraid! Most of the codes are clear and are commented. But I try to explain some Some obscure parts:
- In
pointToCenterAfterRotation()
method we calculate the center position of the screen, this would be the center position ofImageScrollView
to. Meaning ofconvert(boundsCenter, to: zoomView)
is we askImageScrollerView
class to tell us what is its center point on screen in the zoomView coordinate? CGFloat.ulpOfOne
InscaleToRestoreAfterRotation()
, is like epsilon in the math. It means : The positive difference between 1.0 and the next greater representable number.
We want to be sure we aren’t out of zoomScale range and if we are, we just return0.0
aszoomScale
and system automatically restoreszoomScale
to theminimumZoomScale
.- contentOffset in
maximumContentOffset()
andminimumContentOffset()
is the amount of scrollView content that is out of screen. We calculate the maximum and minimum of contentOffset to be sure the center point that we saved before rotation, is not out of range.
I think there is no dark side in restoreCenterPoint(to oldCenter: CGPoint, oldScale: CGFloat)
method. So let’s do the rest of job to fix our scroll view problem.
Go to ViewController.swift file and add these methods after viewDidLoad() {…}
method and before layoutImageScrollView() {…}
method:
In the above codes, viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
is a method that UIKit calls this method before changing the size of a presented view controller’s view and this method notifies the container that the size of its view is about to change.
The size
parameter giving us the new size
for the container’s view.
So we get the new size
and calculating new frame for imageScrollView
saving imageScrollView
last center point and last zoom Scale, give to imageScrollView
new frame ask it to reset its zoom scale range and restore its center point and zoom scale in new frame and that’s all!
Build and run the app.
Enjoy of turning your phone to landscape!
One more thing!
The scroll view now works perfect. But do you want to add a feature that makes your user more happy?
That is tapping, that enables user to zoom with twice tapping on screen.
Let’s add it. Go to ImageScrollView.swift file and add these lines of code at top of the ImageScrollView
subclass, after var zoomView: UIImageView!
:
This is a lazy instance of UITapGestureRecognizer
. The reason of that we make it lazy is it’s not being initialized until user taps on screen and as user taped it being initialized and cached in memory and after that, it’s available in memory without need to initialize again. Xcode now gives you an error about that it does not know any action method with handleZoomingTap(_:)
name. We fix that now, add these lines of code at the end of ImageScrollView
subclass, after restoreCenterPoint(oldCenter: CGPoint, oldScale: CGFloat)
method:
When user taps on photo, zoomingTap
gesture recognizer calls handleZoomingTap(_ sender: UITapGestureRecognizer)
method.
In this method we get the location of users tap on zoomView
. zoomingTap
itself gives us this location because we will attach it to zoomView
.
UIScrollView
has a method that zooms to a specific area of the content that we specified. But this method gets a rectangle with coordinate of content view (which here is zoomView
) as zoom area. So we need convert the point that zoomingTap gives us to this area. We do this in zoomRect(for scale: CGFloat, withCenter center: CGPoint)
method. But how?
We set maximumZoomScale
as final zoom scale when user taps on photo.
For a rect area we need for value: x
, y
, width
and height
.
The width
and height
are the part of photo that fits in screen at final zoom scale. We obtain these values by subtracting screen width/height on final zoom Scale (Don’t use these codes in Xcode):
zoomRect.width = phoneScreen.width / finalzoomScalezoomRect.height = phoneScreen.height / finalZoomScale
See blew pictures for a better understanding:
To know that how we calculate value of x
and y
see blew picture:
Finally add these two lines into the configureFor(_ imageSize: CGSize)
method after self.zoomScale = self.minimumZoomScale
:
configureFor(_ imageSize: CGSize)
method should be like this:
First line attaches zoomingTap
gesture recognizer to zoomView
and second lines enables zoomView
to accept our taps.
Build and run the app and enjoy of tapping!
You can download the completed sample code of this tutorial from here.
Please clap this tutorial and share it to your friends if you enjoyed it.
You can study the next two parts of this tutorial from the links below: