Autosizing UICollectionView carousel during rotation

In my previous post I demonstrated how to use AutoLayout and make UIImageView resize to full screen when in landscape mode. While happy with the outcome - the solution was not exactly what I wanted in the 1.2 version of Gun Vault. I wanted to allow to user to view all images from the item details screen using a scrollable carousel-style UICollectionView. I still wanted to have the carousel resize to full screen when in landscape mode as well prepare for future iPad oriented optimizations. It turned out that making a UICollectionView behave the same way as a UIImageView is much more difficult so I hope this article saves you time if you are trying to accomplish something similar so let’s get going.

Rotation support

Our view controller will contains a UICollectionView and few sample images

MainController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import UIKit
class MainController: UIViewController {
var images: [UIImage] = {
let image1: UIImage = UIImage(named: "image1")!
let image2: UIImage = UIImage(named: "image2")!
let image3: UIImage = UIImage(named: "image3")!
let image4: UIImage = UIImage(named: "image4")!
var result: [UIImage] = []
result.append(image1)
result.append(image2)
result.append(image3)
result.append(image4)
return result
}()
var collectionView: UICollectionView = {
let frame = CGRect(x: 0, y: 0, width: 0, height: 0)
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
let result = UICollectionView(frame: frame, collectionViewLayout: layout)
result.backgroundColor = UIColor.darkGray
result.isPagingEnabled = true
result.translatesAutoresizingMaskIntoConstraints = false
return result
}()
var commonConstraints: [NSLayoutConstraint] = []
var landscapeConstraints: [NSLayoutConstraint] = []
var portraitConstraints: [NSLayoutConstraint] = []
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.reuseIdentifer)
self.collectionView.delegate = self
self.collectionView.dataSource = self
self.automaticallyAdjustsScrollViewInsets = false
self.view.addSubview(collectionView)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// same as having the code block in viewDidLoad
if commonConstraints.isEmpty {
var collectionViewHeightMultiplierWhenInPortrait: CGFloat = 0.33
if self.traitCollection.userInterfaceIdiom == .pad { collectionViewHeightMultiplierWhenInPortrait = 0.5 }
// define collection view common, portrait and landscape constraints
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .top, relatedBy: .equal, toItem: self.topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailing, multiplier: 1.0, constant: 0))
portraitConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: collectionViewHeightMultiplierWhenInPortrait, constant: 0))
landscapeConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: 1.0, constant: 0))
// activate common constriants
NSLayoutConstraint.activate(commonConstraints)
}
if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
NSLayoutConstraint.deactivate(landscapeConstraints)
NSLayoutConstraint.deactivate(portraitConstraints)
if traitCollection.verticalSizeClass == .regular {
self.navigationController?.setNavigationBarHidden(false, animated: true)
NSLayoutConstraint.activate(portraitConstraints)
} else {
self.navigationController?.setNavigationBarHidden(true, animated: true)
NSLayoutConstraint.activate(landscapeConstraints)
}
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
}
extension MainController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.images.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageCell.reuseIdentifer, for: indexPath) as! ImageCell
let image: UIImage = images[indexPath.row]
cell.imageView.image = image
return cell
}
}
extension MainController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return collectionView.frame.size
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0.0
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 0.0
}
}

Let’s go over the code.

In viewDidLoad() we simply build our UI - we add a UICollectionView as a subview to the MainController’s view and turn on paging. Line 53 is important because othwerise we’ll get a bunch of errors around insets.

In traitCollectionDidChange() we do most of the heavy lifting around AutoLayout. While the instructions look familiar, you might be used to seeing them in viewDidLoad() instead. The traitCollectionDidChange() method is actually equally if not better suited for setting up all AutoLayout stuff with the added benefit of keeping your viewDidLoad() slimmer. Similar to viewDidLoad() it also gets called on initial load but unlike viewDidLoad() it gets called again when there are changes to the UI - in example when the device is rotated or when on iPad a view is resized. As usual - there are some gotchas when it doesn’t happen but it is perfect for our use case. We’ll use it to set up some common constraints that will remain the same no matter what the orientation is as well as use it as a trigger to activate/deactivate portraint or landscape constraints only - show/hide the navbar, resize the collection view to full screen and back and so on.

The only other thing we need is a UICollectionViewCell with a UIImageView inside.

ImageCell.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import UIKit
class ImageCell: UICollectionViewCell {
static let reuseIdentifer: String = String(describing: type(of: self))
var imageView: UIImageView!
override init(frame: CGRect) {
super.init(frame: frame)
imageView = UIImageView()
contentView.addSubview(imageView)
backgroundColor = UIColor.yellow
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0).isActive = true
imageView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 1.0).isActive = true
imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0).isActive = true
imageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

If we run the example and rotate the device everything will look as expected as long as we stay on the first image. As soon as we go to the next and rotate again we’ll uncover our first bug.

Second image bug

Fixing up offset

The reason for our bug is very simple - we turned on paging but we forgot to instruct our collection view how to handle changes in page size so let’s go ahead and do that. We’ll add the following method to our MainController

MainController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
print("before")
let visiblePage = self.collectionView.contentOffset.x / self.collectionView.bounds.size.width
coordinator.animate(alongsideTransition: { (context) in
print("during")
let newOffset = CGPoint(x: visiblePage * self.collectionView.bounds.size.width, y: self.collectionView.contentOffset.y)
self.collectionView.contentOffset = newOffset
self.collectionView.collectionViewLayout.invalidateLayout()
}) { (context) in
print("after")
}
}

The code in the viewWillTransitionToSize() method will figure out the current page and will instruct the transition coordinator to adjust the collection view’s offset to the appropriate X coordinate as part of the animation during rotation. This method is relatively new so I added few print statements to show you what gets called when. If you run the app now you will see that our bug is fixed.

Second image bug fixed

Smoothing things out

While it is mission accomplished already as far as the main use case goes, those of us that are a bit more detail oriented have probably already noticed the weird artifacts happening during rotation. If you are not one of them - run the app again and pay closer attention - do you see the flickering now? I bet you do. Let’s go ahead and address it.

Rather than tinckering with the UICollectionView’s animation we’ll use a very simple trick using a hidden UIImageView with the same constraints as the UICollectionView.

  • Before rotation: take a snapshot of the currently displayed image and load it in the image view; hide the collection view and show the image view.
  • During rotation: rotate both the image view and the collection view
  • After rotation: hide the image view and show the collection view

Let’s add our image view to the controller as well as update the AutoLayout instructions.

MainController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
var tempImageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFill
result.clipsToBounds = true
result.translatesAutoresizingMaskIntoConstraints = false
result.isHidden = true
return result
}()
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.reuseIdentifer)
self.collectionView.delegate = self
self.collectionView.dataSource = self
self.automaticallyAdjustsScrollViewInsets = false
self.view.addSubview(collectionView)
self.view.addSubview(tempImageView)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// same as having the code block in viewDidLoad
if commonConstraints.isEmpty {
var collectionViewHeightMultiplierWhenInPortrait: CGFloat = 0.33
if self.traitCollection.userInterfaceIdiom == .pad { collectionViewHeightMultiplierWhenInPortrait = 0.5 }
// define collection view common, portrait and landscape constraints
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .top, relatedBy: .equal, toItem: self.topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailing, multiplier: 1.0, constant: 0))
portraitConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: collectionViewHeightMultiplierWhenInPortrait, constant: 0))
landscapeConstraints.append(NSLayoutConstraint(item: collectionView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: 1.0, constant: 0))
// define temp image view common, portrait and landscape constraints
commonConstraints.append(NSLayoutConstraint(item: tempImageView, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: tempImageView, attribute: .top, relatedBy: .equal, toItem: self.topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: 0))
commonConstraints.append(NSLayoutConstraint(item: tempImageView, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailing, multiplier: 1.0, constant: 0))
portraitConstraints.append(NSLayoutConstraint(item: tempImageView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: collectionViewHeightMultiplierWhenInPortrait, constant: 0))
landscapeConstraints.append(NSLayoutConstraint(item: tempImageView, attribute: .height, relatedBy: .equal, toItem: self.view, attribute: .height, multiplier: 1.0, constant: 0))
// activate common constriants
NSLayoutConstraint.activate(commonConstraints)
}
if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
NSLayoutConstraint.deactivate(landscapeConstraints)
NSLayoutConstraint.deactivate(portraitConstraints)
if traitCollection.verticalSizeClass == .regular {
self.navigationController?.setNavigationBarHidden(false, animated: true)
NSLayoutConstraint.activate(portraitConstraints)
} else {
self.navigationController?.setNavigationBarHidden(true, animated: true)
NSLayoutConstraint.activate(landscapeConstraints)
}
self.collectionView.collectionViewLayout.invalidateLayout()
}
}

And finally, let’s update our viewWillTransitionToSize() method to do the swapping trick

MainController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
print("before")
let visiblePage = self.collectionView.contentOffset.x / self.collectionView.bounds.size.width
self.tempImageView.image = self.images[Int(visiblePage)]
self.collectionView.isHidden = true
self.tempImageView.isHidden = false
coordinator.animate(alongsideTransition: { (context) in
print("during")
let newOffset = CGPoint(x: visiblePage * self.collectionView.bounds.size.width, y: self.collectionView.contentOffset.y)
self.collectionView.contentOffset = newOffset
self.collectionView.collectionViewLayout.invalidateLayout()
}) { (context) in
print("after")
self.tempImageView.isHidden = true
self.collectionView.isHidden = false
}
}

And that’s it. Now we have a much nicer UICollectionView with AutoSizing support for iPhone and iPad orientation changes.

Portrait view Landscape view

Download full code here

Share Comments