In this tutorial, we'll be covering how one might go about architecting a Safari/Chrome -inspired browser using UIKit. The goal of this tutorial is to go from the starter iOS application to a browser UI with collection views displaying a grid of tab snapshots and ensuring that whenever a tab is clicked, a zoom animation converts that snapshot into a full web view.
- Basic Project Setup
- Programatically Displaying an (Empty) Collection View & Organizing Project
- Displaying A Tab Cell in the Collection View
- Implementing a Beautiful, Safari-Inspired Collection View Grid
- Adding Toolbar Buttons to Add and Close All Tabs
- Filtering Tabs
- Closing Tabs via a Button
- Deleting Tabs via a Gesture
To make this tutorial easier to follow, we'll be leveraging totally programmatic UI. This chapter includes two steps:
- Setting up an Xcode project to use programattic UI.
- In my case, I'll also be comitting tutorial-related files (such as the project README, documentation screenshots, etc.)
By the end of this chapter, you'll be able to run a storyboardless starter application.
These steps should be familiar to anyone who's developed for iOS before.
- Open Xcode.
- Select "File > New > Project..." (alternatively, ⌘⇧N).
- Configure the project with the following key settings
(Figure 1.1):
- Product name: Compass
- Interface: Storyboard
- Language: Swift
- Do initialize a Git repository for this project.
Configuring a storybaordless iOS application is a very common task. Hence, there are many, many tutorials on this. If you're reading this in the future, I'd suggest you look on YouTube for the most recent instructions on how to complete this. The steps below are what I did and it worked for me at the time of publishing.
- Right click on the Main.storyboard file and select "Delete" from the context menu. (Figure 1.2)
- In the confirmation dialog, select "Move to trash".
- Open the "Info.plist" file.
- Delete the key entitled "Storyboard Name". (Figure 1.3)
- In the sidebar, click on the "Compass" Xcode project.
- Delete the build setting entitled "UIKit Main Storyboard File Base Name". (Figure 1.4)
Next, we must edit the "SceneDelegate.swift" file. In particular, replace the boilerplate
implementation of the func scene(...) method with the following:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
window.makeKeyAndVisible()
window.rootViewController = ViewController()
self.window = window
}At this point, you should be able to run your app, and see a black screen. (Figure 1.5)
Looking at a browser's UI, we can break it into several elements. We begin with a collection view where each cell corresponds to a tab. When we click on a particular cell, the corresponding webview is pushed onto a stack. Additionally, note that clicking a link sometimes creates a new tab, and that new tab is 'activated' via a swiping page animation. Additionally, in Safari at least, tabs don't start out by just loading a home page, they display a view controller with your "Shared with You" links, "Favorites". Hence, it seems we'll need the following:
- A
UINavigationControllerto push and pop other view controllers. - A
UICollectionViewControllerto display tab cells. - A
UIPageViewControllerto display the webview and transition to new webviews when they're created. - A custom
UIViewControllerto display a webview or home screen whenever appropriate.
In this chapter, we'll focus solely on creating the first two. We'll do this in keeping with MVVM architecture.
- Ensure the
ViewControllerinherits fromUINavigationController. - Rename the
ViewControllerclass to something more descriptive e.g.RootNavigationVC. (Figure 2.1). - Create a new folder named "Controllers" and drag the "RootNavigationVC.swift" file into it.
- Inside the "Controllers" folder, create a new Swift file called "TabCollectionVC.swift".
- In that file, paste the following, which will simply make it easy to see:
final class TabCollectionVC: UICollectionViewController {
override func viewDidLoad() {
collectionView.backgroundColor = .lightGray
}
}The TabCollectionVC should be the top-most controller of the RootNavigationVC. Hence,
we should have the RootNavigationVC create an instance of the TabCollectionVC and show
it. Note that to initialize a UICollectionViewController, the
UICollectionViewFlowLayout can't be nil. Thus, we implement RootNavigationVC as
follows:
final class RootNavigationVC: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
let layout = UICollectionViewFlowLayout()
let tabCollectionVC = TabCollectionVC(collectionViewLayout: layout)
self.viewControllers = [tabCollectionVC]
}
}If you were to run the application at this point, you should simply see a gray screen. (Figure 2.2)
Since this app will grow quite large, it'll be good to use MVVM architecture to organize
our code. We can get a head start on this by doing it now. Specifically, let's add some
boilerplate for the "TabCollectionVM" class. This class will be responsible for (at
minimum):
- owning and modifying the
tabsarray which theTabCollectionVCdisplays. - storing key data for the
tabsarray in persistent storage when the app closes. - restoring the
tabsarray from persistent storage when the app opens. - notifying the
TabCollectionVCof view-relevant tab-related events. To be concrete, we plan to implement a "Close All" button for the collection view. But this button should be dimmed and untappable if the tabs array is already empty. Hence, theTabCollectionVMwould need to notify theTabCollectionVCif/once the array becomes empty.
- Create a new file in the "Controllers" folder named "TabCollectionVM.swift".
- Provide a (dummy) implementation of this class for now.
final class TabCollectionVM {
// TODO: Replace this dummy property with an actual array of `Tab` objects.
var tabs = [1, 2, 3]
}- Modify the
TabCollectionVCso that it instantiates aTabCollectionVM.
final class TabCollectionVC: UICollectionViewController {
var vm = TabCollectionVM()
// ...
}- In the "TabCollectionVC.swift" file edit the
viewDidLoadclass to register a reuse identifier for the defaultUICollectionViewCellclass. For the reuse identifier, it's conventional to just use the class name. By the end of this chapter, we'll design a custom cell subclass but for now we just need to focus the structure of the code right first:
override func viewDidLoad() {
// ...
collectionView.register(
UICollectionViewCell.self,
forCellWithReuseIdentifier: String(describing: UICollectionViewCell.self)
)
}- Next, we need to implement the "
numberOfItemsInSection" method. We can do this as follows:
extension TabCollectionVC {
override func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return vm.tabs.count
}
}- Finally, we need to implement the "
cellForItemAt" method. I'm going to configure the default collection view cell so that it displays the number from thetabsarray:
extension TabCollectionVC {
override func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
// TODO: Replace with a custom view (`TabCell`) and configure the view in this method.
let tabSnapshotCell = collectionView.dequeueReusableCell(
withReuseIdentifier: String(describing: UICollectionViewCell.self),
for: indexPath
)
tabSnapshotCell.contentView.backgroundColor = .systemBlue
// For now `relevantTab`, this is just an integer since the `tabs` array is numeric.
let relevantTab = vm.tabs[indexPath.item]
let label = UILabel()
label.text = String(relevantTab)
label.frame = tabSnapshotCell.contentView.bounds
tabSnapshotCell.contentView.addSubview(label)
return tabSnapshotCell
}
}At this point, you should be able to run the application in the simulator and see three
blue cells each displaying the number from the tabs array.
Eventually, we want to fix the tabs array, so that it doesn't just hold integers. We
need it to hold an array of Tab objects, so let's define that class. For now, the Tab
class will be pretty basic but it'll grow in complexity and functionality as the tutorial
goes on. For now, we'll just make each tab Identifiable and give it a title.
- Create a "Models" folder.
- In the "Models" folder, create a "Tab.swift" file.
- Implement the
Tabclass as follows:
final class Tab: Identifiable {
var id: String
var title: String
init() {
self.id = UUID().uuidString
self.title = Tab.DEFAULT_TITLE
}
}
// MARK: - Constants
extension Tab {
private static let DEFAULT_TITLE = "New Tab"
}The exact way you design this view is up to you! For now, we'll just make an image view, with a grayish background color and a corner radius. Below that image view will be a UILabel.
- Create a "Views" folder.
- In the "Views" folder, create a "TabCell.swift" file.
- Implement the
TabCellclass as follows:
final class TabCell: UICollectionViewCell {
// MARK: - UI Components
private lazy var snapshot: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.cornerRadius = TabCell.STANDARD_CORNER_RADIUS
imageView.layer.masksToBounds = true
imageView.backgroundColor = TabCell.BACKGROUND_COLOR
imageView.isUserInteractionEnabled = true
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.numberOfLines = 2
return label
}()
var title: String? {
get { titleLabel.text }
set { titleLabel.text = newValue }
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupViews() {
contentView.addSubview(snapshot)
contentView.addSubview(titleLabel)
contentView.layer.cornerRadius = TabCell.STANDARD_CORNER_RADIUS
contentView.layer.masksToBounds = true
}
private func setupConstraints() {
NSLayoutConstraint.activate([
snapshot.topAnchor.constraint(equalTo: contentView.topAnchor),
snapshot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
snapshot.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
snapshot.bottomAnchor.constraint(equalTo: titleLabel.topAnchor, constant: -8),
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 40)
])
}
override func layoutSubviews() {
super.layoutSubviews()
layer.shadowColor = TabCell.SHADOW_COLOR
layer.shadowRadius = TabCell.STANDARD_CORNER_RADIUS
layer.shadowOpacity = TabCell.SHADOW_OPACITY
layer.shadowPath = UIBezierPath(
roundedRect: bounds,
cornerRadius: contentView.layer.cornerRadius
).cgPath
}
}
// MARK: - Constants
extension TabCell {
static let STANDARD_CORNER_RADIUS: CGFloat = 16
/// The background color for the TabCell which is visible when a snapshot does
/// not exist.
static let BACKGROUND_COLOR = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
/// The background color for the TabCell which is visible when a snapshot does
/// not exist.
static let BACKGROUND_COLOR = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
/// The color of the shadow which appears behind the TabCell
static let SHADOW_COLOR = UIColor.gray.cgColor
/// The opacity of the shadow which appears behind the TabCell
static let SHADOW_OPACITY: Float = 0.1
}
- Edit the
tabsarray to use theTabclass:
- var tabs = [1, 2, 3]
+ var tabs = [Tab()]- Replace the dummy code which registered a reuse identifier for the default
UICollectionViewCellwith code that registers a reuse identifier for our custom view. As before, use the class name as the reuse identifier:
collectionView.register(
- UICollectionViewCell.self,
- forCellWithReuseIdentifier: String(describing: UICollectionViewCell.self)
+ TabCell.self,
+ forCellWithReuseIdentifier: String(describing: TabCell.self)
)- Edit the "
cellForItemAt" function as follows:
override func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let tabSnapshotCell = collectionView.dequeueReusableCell(
withReuseIdentifier: String(describing: TabCell.self),
for: indexPath
) as! TabCell
let relevantTab = vm.tabs[indexPath.item]
// TODO: Don't hard code the frame. Use `sizeForItemAt` method.
tabSnapshotCell.frame = CGRect(x: 0, y: 0, width: 100, height: 200)
tabSnapshotCell.title = relevantTab.title
return tabSnapshotCell
}At this point, you should be able to run the application and see a TabCell displayed.
But note that we've hard coded the frame of the TabCell. So if you were to push more
Tabs to the tabs array, they'd all overlap in the collection view.
(Figure 3.3) We'll address that in the next
chapter.
In Chapter 3, we temporarily hard-coded the TabCell's frame just to make it visible. Now, we'll create a proper grid layout that mirrors Safari's tab view. Let's first analyze Safari's key design patterns:
Unlike many grid layouts, Safari's tab cells don't maintain a fixed aspect ratio. Instead, they adapt to the device's orientation:
- In portrait mode, cells are taller and more square-like
- In landscape mode, cells are wider and more rectangular
The grid's structure responds to both orientation and content:
- In portrait mode, there are 2 cells per row
- In landscape mode, there are 3 cells per row
- Special case: When displaying fewer cells than a full row (1-2 cells), each cell expands to fill more space, creating a balanced layout
The following figure illustrates these layout patterns:
The key to implementing both behaviors lies in a single UICollectionViewController method:
collectionView(_:layout:sizeForItemAt:). This method not only controls our dynamic
aspect ratios, but also—perhaps surprisingly—handles our adaptive grid layout.
By carefully calculating the cell sizes, we can let UICollectionView's natural flow behavior handle the row wrapping. That is, if we, size cells to occupy half the available width when in portrait and one-third the available width in landscape, the collection view will automatically wrap to a new row when there's insufficient space, creating our desired 2-column and 3-column grids without additional configuration.
As discussed previously, portrait vs landscape orientation is essential to this UI. To begin to model this, we'll use an enum.
- In the "Models" folder, create a file called "Orientation.swift".
- Implement the enum as follows:
enum Orientation {
case landscape
case portrait
}- Create a folder called "Extensions".
- In the "Extensions" folder, create a file called "UICollectionView+Extensions.swift".
- In the "UICollectionView+Extensions.swift" file, define a computed property which returns the orientation the collection view is by comparing its height and width.
extension UICollectionView {
var orientation: Orientation {
return bounds.width > bounds.height ? .landscape : .portrait
}
}Since the flow layout will need to change when the screen rotates, we'll need to listen
for rotation events and handle them by invalidating the layout. Do so by overriding the
viewWillTransition(to:with:) method like so:
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
if let layout = self.collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.invalidateLayout()
self.collectionView.layoutIfNeeded()
}
})
}- Define the following constant:
extension TabCollectionVC {
private static let INSET_PADDING: CGFloat = 15
}- Implement the
UICollectionViewController's "insetForSectionAt" method like so:
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetForSectionAt index: NSInteger
) -> UIEdgeInsets {
UIEdgeInsets(
top: TabCollectionVC.INSET_PADDING,
left: collectionView.safeAreaInsets.left + TabCollectionVC.INSET_PADDING,
bottom: TabCollectionVC.INSET_PADDING,
right: collectionView.safeAreaInsets.right + TabCollectionVC.INSET_PADDING
)
}As discussed previously, the number of items we want to display in each row depends on
both the orientation and the number of items in the tabs array. The following
implementation captures the desired behavior and can be used later.
private func itemsPerRow(
forOrientation orientation: Orientation,
andTabCount tabCount: Int
) -> CGFloat {
if (tabCount == 1 || tabCount == 2) { return CGFloat(tabCount) }
switch orientation {
case .portrait:
return 2
case .landscape:
return 3
}
}As mentioned at the outset of this chapeter, our main goal is to implement the
"sizeForItemAt" method. Let's break the remaining work into distinct phases. In the
first phase, we'll write a preliminary version of the "sizeForItemAt" function we need
that accounts for the width, but not the height. Since the width is what determines the
number of items in each row, this first iteration of the function will acheive the
"Adaptive Grid Layout" effect. We won't account for the height. We'll just set the height
equal to the width so that each cell has the bounds of a square.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let orientation = collectionView.orientation
let safeAreaWidth = collectionView.safeAreaLayoutGuide.layoutFrame.width
let rowItemCount = itemsPerRow(
forOrientation: orientation,
andTabCount: collectionView.numberOfItems(inSection: 0)
)
let interItemSpacing = TabCollectionVC.INSET_PADDING * CGFloat(2 * rowItemCount)
let itemWidth = (safeAreaWidth - interItemSpacing) / rowItemCount
// TODO: Actually compute height to achieve "dynamic aspect ratios" in the next phase.
let itemHeight = itemWidth
return CGSize(width: itemWidth, height: itemHeight)
}At this point, you should be able to run the project in the simulator and see that when the device is in portrait mode, each grid row has two cells, and in landscape mode, each grid row has three cells.
One other thing to note is that there was a bit of a sizing issue when we first run the app. That's because we haven't yet removed the code to hard-code the cell size. We'll do that once we've completely phase 2.
Now that we have calculated the right width of each TabCell, it's pretty trivial to
calculate a height which will give us the "Dynamic Aspect Ratios" effect. Doing so gives
us the second and complete iteration of the "sizeForItemAt" Method.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let orientation = collectionView.orientation
let safeAreaWidth = collectionView.safeAreaLayoutGuide.layoutFrame.width
let rowItemCount = itemsPerRow(
forOrientation: orientation,
andTabCount: collectionView.numberOfItems(inSection: 0)
)
let interItemSpacing = TabCollectionVC.INSET_PADDING * CGFloat(2 * rowItemCount)
let itemWidth = (safeAreaWidth - interItemSpacing) / rowItemCount
let deviceAspectRatio = collectionView.bounds.height / collectionView.bounds.width
let squareAspectRatio = 1.0
let squarifiedAspectRatio = (squareAspectRatio + deviceAspectRatio) / 2
let aspectRatio = orientation == .portrait ? squarifiedAspectRatio : deviceAspectRatio
let itemHeight = itemWidth * aspectRatio
return CGSize(width: itemWidth, height: itemHeight)
}Recall our observation that "In portrait mode, cells are taller and more square-like". One cute trick leveraged in the above implementation to acheive a more square-like look was to average the device's aspect ratio with a square's aspect ratio (i.e. 1.0).
At this point, the app should resemble the following:
Remove the line tabSnapshotCell.frame = CGRect(x: 0, y: 0, width: 100, height: 200) from
the collectionView(_,numberOfItemsInSection) method. This should address the glitch
observed on first application launch.
Currently, the itemsPerRow function doesn't actually use the tabCount parameter passed
into it. This means we are not handling the "special cases" described at the outset of
this chapter. For instance, when there is only one item in the tabs array, that tab
should be bigger than it otherwise would be but it's not.
To address this, we can improve upon the itemsPerRow function as follows:
private func itemsPerRow(for orientation: Orientation, and tabCount: Int) -> CGFloat {
if (tabCount == 1) { return 1.33 }
if (tabCount == 2) { return 2 }
switch orientation {
case .portrait:
return 2
case .landscape:
return 3
}
}This allows us to handle the edge case where there's only one item in tabs more
gracefully in the UI as demonstrated below:
In this chapter, we’ll add a toolbar with buttons to let users manage their tabs — specifically, a "+" button to open a new tab and a "Close All" button to remove all existing tabs.
These buttons should only appear when the TabCollectionVC is the top view controller in
the navigation stack. Later on, when we push detail views (like a web page), we’ll show a
different set of toolbar buttons for in-tab navigation (e.g., forward/back). For now,
we’re focusing on actions relevant to the tab grid view.
We’ll stick to the MVVM architecture introduced earlier. That means:
- The view model (
TabCollectionVM) will own and modify the tabs array. - The view controller (
TabCollectionVC) will handle only UI-related logic.
For example, if there are no open tabs, the “Close All” button should be disabled. The view model will detect that state and notify the view controller to update the UI accordingly.
- Modify the
viewDidLoadmethod for theRootNavigationVCclass so that its toolbar is not hidden like so:
final class RootNavigationVC: UINavigationController {
override func viewDidLoad() {
// ...
setToolbarHidden(false, animated: false)
}
}- Add the following constants to the
TabCollectionVC:
private static let CLOSE_ALL_TABS_BUTTON_TEXT = "Close All"
private static let CREATE_NEW_TAB_BUTTON_IMAGE = UIImage(systemName: "plus")- Modify the
TabCollectionVCso that you create several newUIBarButtonItems. For now, we'll leave theactionasnilas we'll implement those actions later on in this chapter. You can acheive this as follows:
final class TabCollectionVC: UICollectionViewController {
// ...
var createNewTabButton: UIBarButtonItem!
var closeAllTabsButton: UIBarButtonItem!
override func viewDidLoad() {
// ...
createNewTabButton = UIBarButtonItem(
image: TabCollectionVC.CREATE_NEW_TAB_BUTTON_IMAGE,
style: .plain,
target: self,
action: nil
)
closeAllTabsButton = UIBarButtonItem(
title: TabCollectionVC.CLOSE_ALL_TABS_BUTTON_TEXT,
style: .plain,
target: self,
action: nil
)
}
}
// ...- Create a spacer, which we'll use later to spread the buttons to opposite sides of the toolbar.
final class TabCollectionVC: UICollectionViewController {
// ...
override func viewDidLoad() {
// ...
let spacer = UIBarButtonItem(
barButtonSystemItem: .flexibleSpace,
target: nil,
action: nil
)
}
}
// ...- Now, we set the toolbar items for the
TabCollectionVC.
final class TabCollectionVC: UICollectionViewController {
// ...
override func viewDidLoad() {
// ...
toolbarItems = [
closeAllTabsButton,
spacer,
createNewTabButton
]
}
}
// ...At this point, when you run the app, you should see the following:
Aesthetically, this is exactly what we want. But the buttons don't do anything yet so the remainder of this chapter will focus on implementing that.
One thing to note at the outset is that we need to both modify the tabs array but that
isn't sufficient to update the collection view with an additional view. Rather, we have to
do the additional step of calling the UICollectionView's insertItems(at:) method. In
terms of our MVVM architecture, then, the TabCollectionVM will modify the tabs array
and the TabCollectionVC will call insertItems(at:) to update the view. Since
insertItems(at:) takes an IndexPath as a parameter, we'll have the TabCollectionVM's
method return that (and of course it'll be returning just the length of the tabs array
since we'll be appending the new tab to the end of the tabs array). Note that the
insertItems(at:) method is the preferred way of getting the collection view to update,
rather than reloadData(), when we know exactly where the update ocurred since
reloadData() is not as performant.
- Introduce a new function called
createNewTabButtonPressed. We'll implement it in a bit but for now just define the method signature:
extension TabCollectionVC {
@objc func createNewTabButtonPressed() {
// TODO: Have `TabCollectionVM` update the `tabs` array.
// TODO: Update the `collectionView` using `insertItem(at:)
}
}- Add
createNewTabButtonPressedas theactionfor thecreateNewTabButton:
createNewTabButton = UIBarButtonItem(
// ...
action: #selector(createNewTabButtonPressed)
)- Define an
appendNewTabToTabsArrayfunction in theTabCollectionVMlike so:
extension TabCollectionVM {
func appendNewTabToTabsArray() -> IndexPath {
let newTab = Tab()
tabs.append(newTab)
let newTabIndexPath = IndexPath(item: tabs.count - 1, section: 0)
return newTabIndexPath
}
}- Call the
appendNewTabToTabsArraymethod withincreateNewTabButtonPressed:
extension TabCollectionVC {
@objc func createNewTabButtonPressed() {
let newTabIndexPath = vm.appendNewTabToTabsArray()
// TODO: Update the `collectionView` using `insertItem(at:)
}
}- Update the Collection View at the
IndexPathwhere theTabwas added
extension TabCollectionVC {
@objc func createNewTabButtonPressed() {
let newTabIndexPath = vm.appendNewTabToTabsArray()
collectionView.insertItems(at: [newTabIndexPath])
}
}Now, when you run the application, you should see the "+" button is working as below:
To imlement the "Close All" button, we'll follow pretty much the same steps. In this case,
though, we'll use the UICollectionView's reloadData method since all the tabs will be
gone.
- Introduce a new function called
closeAllTabsButtonPressed. We'll implement it in a bit but for now just define the method signature:
extension TabCollectionVC {
@objc func closeAllTabsButtonPressed() {
// TODO: Have `TabCollectionVM` update the `tabs` array.
// TODO: Update the `collectionView` using `reloadData()
}
}- Add
closeAllTabsButtonPressedas theactionfor thecloseAllTabsButton:
closeAllTabsButton = UIBarButtonItem(
// ...
action: #selector(closeAllTabsButtonPressed)
)- Define a
closeAllTabsfunction in theTabCollectionVMlike so:
extension TabCollectionVM {
func closeAllTabs() {
tabs = [Tab]()
}
}- Call the
closeAllTabsmethod withincloseAllTabsButtonPressed:
extension TabCollectionVC {
@objc func closeAllTabsButtonPressed() {
vm.closeAllTabs()
// TODO: Update the `collectionView` using `reloadData()
}
}- Update the Collection View based on the changed data
extension TabCollectionVC {
@objc func closeAllTabsButtonPressed() {
vm.closeAllTabs()
collectionView.reloadData()
}
}Now, when you run the application, you should see the "Close All" button is working as below:
When no tabs exist, the "Close All" button is useless. Hence, we should disable that
button. To do things like this, it's convenient to leverage the didSet method. Again,
we'll be leveraging MVVM. In order for the TabCollectionVM to notify the
TabCollectionVC of view-relevant changes to the tabs array, the TabCollectionVM will
need a reference to the TabCollectionVC.
- Edit the
TabCollectionVMclass so that it requires a reference to its view controller:
final class TabCollectionVM {
let vc: TabCollectionVC
// ...
init(vc: TabCollectionVC) {
self.vc = vc
}
}- Edit the code where
TabCollectionVCinstantiates aTabCollectionVMso that it gives the view model a reference to itself. The earliest we can possibly set thevmis within theTabCollectionVC's initializer so let's just do it there (as opposed to within some other method likeviewDidLoad) to avoid any possibility of a crash. To do this, we'll override the designated initializer.
final class TabCollectionVC: UICollectionViewController {
- var vm: TabCollectionVM()
+ var vm: TabCollectionVM!
// ...
}final class TabCollectionVC: UICollectionViewController {
// ...
override init(collectionViewLayout: UICollectionViewLayout) {
super.init(collectionViewLayout: collectionViewLayout)
vm = TabCollectionVM(vc: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}- Implement view-update methods in the
TabCollectionVC:
extension TabCollectionVC {
func tabsLengthIsZero() {
closeAllTabsButton.isEnabled = false
}
func tabsLengthIsPositive() {
closeAllTabsButton.isEnabled = true
}
}- Invoke view-update methods in the
TabCollectionVM:
final class TabCollectionVM {
// ...
var tabs: [Tab] = [] {
didSet {
relayIfTabLengthIsPositiveOrZero()
}
}
func relayIfTabLengthIsPositiveOrZero() {
if tabs.count == 0 {
vc.tabsLengthIsZero()
} else {
vc.tabsLengthIsPositive()
}
}
// ...
}At this point, you should be able to see it basically working except that, when the app is launched, the state is not correct.
This is because, tabs is set to be an empty array when the TabCollectionVM is
initialized. This is because the didSet observer is not called when a property is first
initialized. There are probably more elegant ways to address this edge case, but I'm
choosing to address this by having the view controller emit an event to the view model
whenever its viewWillAppear method is called. This way the view model can communicate
any information which is relevant to the initial state of the views.
- Implement a function called
tabCollectionVCWillAppearin theTabCollectionVMwhich will handle this event by relaying information about the tab state.
extension TabCollectionVM {
func tabCollectionVCWillAppear() {
relayIfTabLengthIsPositiveOrZero()
}
}- Override the
viewDidLoadMethod to invoke the VM's event handler
final class TabCollectionVC: UICollectionViewController {
// ...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
vm.tabCollectionVCWillAppear()
}
// ...
}This addresses the glitch observed previously, so that the "Close All" button works perfectly!
In this chapter, we'll be mimicking the "Search Tabs" behavior which Safari has, which allows users to quickly filter their tabs by their titles. This behavior is shown below.
- Add the following constant to the
TabCollectionVC:
private static let FILTER_TABS_SEARCH_BAR_PLACEHOLDER = "Search Tabs"- Modify the
TabCollectionVCso that you create aUISearchBarand set it as thetitleViewfor.
final class TabCollectionVC: UICollectionViewController {
// ...
var filterTabsSearchBar: UISearchBar!
override func viewDidLoad() {
// ...
filterTabsSearchBar = UISearchBar()
filterTabsSearchBar.placeholder = TabCollectionVC.FILTER_TABS_SEARCH_BAR_PLACEHOLDER
navigationItem.titleView = filterTabsSearchBar
}
}But the UI doesn't do anything. To get the UI to update, we'll need the search bar to
notify us about text changes to its query. To be notified of this, we need to be the
UISearchBar's delegate. In keeping with MVVM, we'll want to have the
TabCollectionVM, not the TabCollectionVC, be the UISearchBarDelegate.
- Set the
vmas thefilterTabsSearchBar'sdelegate. Note that this will produce compiler errors for now, which we'll address later.
final class TabCollectionVC: UICollectionViewController {
// ...
override func viewDidLoad() {
// ...
filterTabsSearchBar.delegate = vm
}
// ...
}- To address the compiler warnings, we need to make
TabCollectionVMconform toUISearchBarDelegate. I don't understand why but, in order to do this, the compiler's making us haveTabCollectionVMinherit fromNSObjectlike so:
final class TabCollectionVM: NSObject {
// ...
}
extension TabCollectionVM: UISearchBarDelegate {
// TODO: Implement to respond to filter query changes
}- Define a
filterQueryvariable like so:
final class TabCollectionVM: NSObject {
private var filterQuery = ""
// ...
}- Mark the
tabsarray asprivate.
final class TabCollectionVM: NSObject {
private var tabs: [Tab] = [] {
// ...
}
// ...
}- Define a
filteredTabscomputed property as follows:
final class TabCollectionVM: NSObject {
var filteredTabs: [Tab] {
if filterQuery.isEmpty {
return tabs
} else {
return tabs.filter { $0.title.lowercased().contains(filterQuery.lowercased()) }
}
}
// ...
}- Update the
TabCollectionVCso it refers tofilteredTabseverywhere it used to refer totabs.
- Implement the
searchBar(_, textDidChange)method like so
extension TabCollectionVM: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
filterQuery = searchText
// TODO: Notify `TabCollectionVC` so it reloads the collection view visually
}
}Recall that it isn't enough to change the collectionView's underlying data. There's an additional step to notify the collection view that it needs to perform a visual update. To do this, add the following extensions:
extension TabCollectionVM: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
filterQuery = searchText
vc.filterQueryTextDidChange()
}
}extension TabCollectionVC {
func filterQueryTextDidChange() {
collectionView.reloadData()
}
}At this point, we can see that filtering is working properly.
This is working somewhat, but one thing to note is that it's impossible to dismiss the keyboard once you've activated it. To address this, we should add a "Cancel" button. We can implement this as follows:
extension TabCollectionVM: UISearchBarDelegate {
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
searchBar.showsCancelButton = true
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchBar.showsCancelButton = false
}
// ...
}To implement the cancel button, we have another delegate method to implement
searchBarCancelButtonClicked(_):
extension TabCollectionVM: UISearchBarDelegate {
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
endSearchingAndReload()
}
private func endSearchingAndReload() {
// If the query string was already empty, no need to reload the collection view.
if filterQuery != "" {
filterQuery = ""
vc.filterQueryTextDidChange()
}
vc.filterQuerySearchBarCancelButtonClicked()
}
// ...
}extension TabCollectionVC {
func filterQuerySearchBarCancelButtonClicked() {
filterTabsSearchBar.text = ""
filterTabsSearchBar.resignFirstResponder()
}
// ...
}At this point, we can see the cancel button works to clear the filterQuery (the
underlying data model), clearing the query from the actual view, and resigning the first
responder.
In this chapter, we will add a close button to enable users to delete a given tab. When a
user taps a particular tab's close button, we want that tab to fly off screen and then be
deleted. It's fine to put the code to code within the TabCell but it is generally a
bad practice to put business logic inside views. For this reason, we will be defining a
"TabCellDelegate" and setting the TabCollectionVC as the delegate for every cell. The
TabCollectionVC will handle efficiently updating its collectionView but will rely on
its view model to actually delete all traces of the corresponding Tab from persistent
storage (and any other business-logic). This chapter, therefore, gives the first example
of the "delegate-protocol pattern" in this tutorial.
- Add the following constants to the
TabCell:
private static let CLOSE_BUTTON_SIZE: CGFloat = 20
private static let CLOSE_BUTTON_INSET: CGFloat = 8
private static let CLOSE_BUTTON_IMAGE = UIImage(systemName: "xmark.circle.fill")
private static let CLOSE_BUTTON_COLOR = UIColor(red: 1.0, green: 0, blue: 0, alpha: 0.75)- Add the following UI component to the
TabCell:
private lazy var closeButton: UIButton = {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(TabCell.CLOSE_BUTTON_IMAGE, for: .normal)
button.tintColor = TabCell.CLOSE_BUTTON_COLOR
return button
}()- Modify
setupViews()so that it adds thecloseButtonto the view hierarchy:
private func setupViews() {
contentView.addSubview(snapshot)
contentView.addSubview(titleLabel)
+ contentView.addSubview(closeButton)
contentView.layer.cornerRadius = TabCell.STANDARD_CORNER_RADIUS
contentView.layer.masksToBounds = true
}- Modify
setupConstraints()so that it sizes and positions thecloseButtonin the top right corner of theTabCell:
NSLayoutConstraint.activate([
// ...
closeButton.topAnchor.constraint(
equalTo: contentView.topAnchor, constant: TabCell.CLOSE_BUTTON_INSET),
closeButton.trailingAnchor.constraint(
equalTo: contentView.trailingAnchor,
constant: -TabCell.CLOSE_BUTTON_INSET),
closeButton.widthAnchor.constraint(
equalToConstant: TabCell.CLOSE_BUTTON_SIZE),
closeButton.heightAnchor.constraint(
equalToConstant: TabCell.CLOSE_BUTTON_SIZE)
])At this point, each TabCell should look like the following:
-
Create a "Protocols" folder.
-
In the "Protocols" folder, create a "TabCellDelegate.swift" file and define the
TabCellDelegateprotocol as follows:
protocol TabCellDelegate {
func delete(cell: TabCell)
}- Allow a
TabCellto hold a reference to aTabCellDelegate. Use theweakkeyword to avoid a reference cycle.
final class TabCell: UICollectionViewCell {
// ...
weak var delegate: TabCellDelegate?
}- Make
TabCollectionVCconform toTabCellDelegate.
extension TabCollectionVC: TabCellDelegate {
func delete(cell: TabCell) {
// TODO: First, request the `TabCollectionVM` delete the corresponding `Tab`.
// TODO: Second, update the collection view to remove the provided `TabCell`.
}
}- Have the
TabCollectionVCset itself as eachTabCell's delegate when it creates each cell in the "cellForItemAt" method.
override func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let tabSnapshotCell = collectionView.dequeueReusableCell(
withReuseIdentifier: String(describing: TabCell.self),
for: indexPath
) as! TabCell
let relevantTab = vm.filteredTabs[indexPath.item]
tabSnapshotCell.title = relevantTab.title
+ tabSnapshotCell.delegate = self
return tabSnapshotCell
}- Partially implement the
delete(cell: TabCell)method defined above as follows:
extension TabCollectionVC: TabCellDelegate {
func delete(cell: TabCell) {
guard let filteredTabsIndexPath = collectionView.indexPath(for: cell) else {
fatalError("Cannot find indexPath for Tab whose deletion was requested.")
}
// TODO: Request the `TabCollectionVM` delete the corresponding `Tab`.
collectionView.deleteItems(at: [filteredTabsIndexPath])
}
}- Finish implementing the
delete(cell: TabCell)by first defining and then invoking aTabCollectionVMmethod. Note that theindexPathwhich is passed into thedeleteTabmethod refers tofilteredTabs. ButfilteredTabsis a computed property which we cannot, therefore, modify directly. Of course, what we need to do is simply find the index intabswhich holds the sameTabas the index passed in which refers tofilteredTabs. If you don't do this, then deleting a tab while also filtering tabs will likely result in some crash. The easiest way to do this translation fromfilteredTabstotabsis to makeTabconform toEquatable.
extension Tab: Equatable {
static func == (lhs: Tab, rhs: Tab) -> Bool {
lhs.id == rhs.id
}
}extension TabCollectionVM {
func deleteTabFromTabsArray(
atIndexPathForFilteredTabs filteredTabsArrayIndexPath: IndexPath
) {
let tabToDelete = filteredTabs[filteredTabsArrayIndexPath.item]
let indexToDelete = tabs.firstIndex(of: tabToDelete)!
tabs.remove(at: indexToDelete)
}
}extension TabCollectionVC: TabCellDelegate {
func delete(cell: TabCell) {
guard let filteredTabsIndexPath = collectionView.indexPath(for: cell) else {
fatalError("Cannot find indexPath for Tab whose deletion was requested.")
}
+ vm.deleteTabFromTabsArray(atIndexPathForFilteredTabs: filteredTabsIndexPath)
collectionView.deleteItems(at: [filteredTabsIndexPath])
}
}- Configure the button so that, when tapped, runs a
closeButtonTapped()function:
private lazy var closeButton: UIButton = {
// ...
button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
return button
}()
@objc func closeButtonTapped() {
// TODO: Implement by running an animation and asking delegate to delete the cell.
}- Implement the
closeButtonTapped()function defined above, ignoring the animation for the time being.
@objc func closeButtonTapped() {
// TODO: Run an animation where the cell flies off the screen.
delegate?.delete(cell: self)
}At this point, you should see deletion behavior similar to the following GIF:
In this step, we'll be adding liveliness to Compass by having each TabCell first fly off
screen prior to actually getting deleted.
- Add the following constant to the
TabCellclass:
extension TabCell {
private static let FLY_LEFT_SPEED = -5000.0
}- Define a method like so and note that setting
self.alphais essential otherwise you'll get a strange animation where theTabCellcan be visibly seen flying back to its original location before getting deleted.
extension TabCell {
func flyLeft(
then completion: (() -> Void)
) {
let distanceToGo = self.center.x + self.frame.width / 2
let speed = TabCell.FLY_LEFT_SPEED
let duration = distanceToGo/speed
UIView.animate(withDuration: duration, animations: {
self.transform = self.transform.translatedBy(x: -distanceToGo, y: 0)
}, completion: { (finished) in
if (finished) {
self.alpha = 0
completion()
}
})
}
}- Modify the
closeButtonTappedmethod so that it runsflyLeftprior to it having thedelegatedelete theTabCell/Tablike so:
@objc func closeButtonTapped() {
flyLeft(
then: {
self.delegate?.delete(cell: self)
}
)
}At this point, you should be able to press the close button, see the TabCell fly off
screen, and then see the remaning tabs move and resize so as to deal with the deletion as
demonstrated in the following GIF:
At present, when we close a tab via the button, the animation where it flies off to the left is visually fun, but it could be a bit confusing to the user. This is because the presence of that animation suggests to the user that flicking the tab to the left via a gesture would also serve to delete the tab. But that's not the case currenlty and this chapter will focus on adding that feature.
Here are a few key considerations with respect to this feature:
- We should only allow the user to swipe on a single tab at a time.
- Users can scroll the collection view by dragging a tab up or down. This should remain the case. And once a user begins scrolling the collection view vertically, tabs shouldn't be able to get dragged horizontally simultaneously during that same gesture, and vice-versa. This matches the behavior in Safari.
- In determining whether the gesture was intentional or not, we should consider both the speed and total displacement of the gesture. If the drag gesture was both too slow and not very far, we should consider it accidental and have the tab "fly back" to its original position before the user started dragging.
- We should only allow swiping left, not swiping right. If the user attempts to drag a tab to the right, we should severely dampen the tab's movement (relative to the user's finger) to indicate this isn't allowed. And no matter how fast or far the tab is dragged to the right, it should always fly back and never be deleted.
Update the TabCell's initializer so that it creates a UIPanGestureRecognizer for
itself, and make the handler for this function just print some details about the gesture.
final class TabCell: UICollectionViewCell {
// ...
override init(frame: CGRect) {
// ...
let swipeLeftGestureRecognizer = UIPanGestureRecognizer(
target: self,
action: #selector(printSomeDetails)
)
self.addGestureRecognizer(swipeLeftGestureRecognizer)
}
// ...
@objc func printSomeDetails(sender: UIPanGestureRecognizer) {
let translationX = sender.translation(in: self).x
let translationY = sender.translation(in: self).y
print("Finger traveled (\(translationX), \(translationY)) from it's starting point.")
let speedX = sender.velocity(in: self).x
let speedY = sender.velocity(in: self).y
print("Finger moving at velocity (\(speedX), \(speedY))")
}
}At this point, you should be able to see various logs in the console like the following.
Also, at this point you should be able to observe our first problem. Whereas previously, we were able to use the tab as a surface to drag on in order to scroll the collection view, now that is not working. We can still drag the space between tabs in order to scroll the collection view but as per consideration 2, that's not what we want. The issue is demonstrated in the below demo:
The reason for this issue is that, by default, the UIPanGestureRecognizer will not
recognize gestures simultaneously with other gestures. In the next step, we'll fix this
issue.
We can change this behavior by assigning a delegate for the UIPanGestureRecognizer and
using the UIGestureRecognizerDelegate's
gesturerecognizer(_:shouldrecognizesimultaneouslywith:)
method. Per UIKit's documentation, this method "Asks the delegate if two gesture
recognizers should be allowed to recognize gestures simultaneously." Of course, to allow
the cell to also participate in the collection view scrolling gesture, we'd want to answer
in the affirmative to that ask.
- Set the
TabCellas thedelegatefor theUIPanGestureRecognizer
final class TabCell: UICollectionViewCell {
// ...
override init(frame: CGRect) {
// ...
swipeLeftGestureRecognizer.delegate = self
}
// ...
}- Make
TabCellconform toUIGestureRecognizerDelegate. - Implement
gesturerecognizer(_:shouldrecognizesimultaneouslywith:)as follows:
extension TabCell: UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return true
}
}At this point, the ability to scroll up and down the collection view by dragging the tab veritically has been restored.
Previously, we have just using printSomeDetails() to simply print how far the finger has
moved, relative to it's starting position. Let's instead write a function which will
actually animate the TabCell so that it slides horizontally during this gesture. To
address consideration 4, we'll want to dampen the animation by some "directional dampening
factor" if the user is dragging the tab to the right.
- Rename the
printSomeDetails(sender:)function tohandleGestureProgress(sender:). - Define a new animation called
slideHorizontally(byAmount:withScaling:)as shown. This animation will cause the tab to grow slightly during the animation to give the illusion that it's floating above the other tabs. Furthermore, it will cause theTabCellto move to the user's finger. TheSLIDE_HORIZONTALLY_DURATIONdetermines both the speed at which the tab grows and how quickly it moves to match the position of the user's finger.
extension TabCell {
func slideHorizontally(byAmount translationAmount: CGFloat, withScaling scale: CGFloat) {
UIView.animate(
withDuration: UIView.SLIDE_HORIZONTALLY_ANIMATION_DURATION,
animations: {
self.transform = .identity
.translatedBy(x: translationAmount, y: 0)
.scaledBy(x: scale, y: scale)
}
)
}
private static let SLIDE_HORIZONTALLY_ANIMATION_DURATION = 0.2
}- Update the
handleGestureProgress(sender:)function like so:
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
let translationX = sender.translation(in: self).x
slideHorizontally(
byAmount: translationX,
withScaling: TabCell.SLIDE_HORIZONTALLY_ANIMATION_DRAGGING_SCALE
)
}
}You should have something like the following demo at this point.
In the gif, you can see we've made some progress. Specifically, we're able to drag the tab
from its starting location to a location to the left or right. And the distance the tab
travels from its "true location" (.identity) always matches the distance the finger has
moved during the most recent gesture. But, currently, when the gesture finishes there's
no possibility for the tab to either fly off to the left (and be deleted) or fly back to
its starting position. The tab is just left wherever we put it. This is clearly an issue
since, for example, after we leave a tab somewhere (let's say we displaced it to the left
by -50 and left it there), then start a new gesture where we drag our finger to the
right by just +5, the TabCell will then traverse a distance of 55 even though the
finger barely moved. The key to fixing this is to make the tab more decisive. It either
needs to fly back to .identity at the end of the animation or fly off to the left and
be deleted. Another issue with the current implementation is we haven't added any
dampening to the rightward movement of the tab.
Eventually, we want the TabCell to be able to either fly off the screen to the left and
be deleted or return to its starting position. But just to make some incremental progress,
let's start by always having the TabCell return to its starting position at the end of
the gesture. To do this, it's good to know that UIGestureRecognizers (such as
UIPanGestureRecognizer) expose a property called state that will let us know whether
the gesture is being updated (i.e. the finger is moving) as well as when the gesture is
over.
- Add the following animation, which will move the cell to it's "true position" within 0.2 seconds.
extension TabCell {
func flyBack() {
UIView.animate(withDuration: TabCell.FLY_BACK_ANIMATION_DURATION, animations: {
self.transform = .identity
})
}
private static let FLY_BACK_ANIMATION_DURATION = 0.2
}- Update
handleGestureProgress(sender:)to run theflyBackanimation when the gesture is over like so:
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
let translationX = sender.translation(in: self).x
switch sender.state {
case .changed:
slideHorizontally(
byAmount: translationX,
withScaling: TabCell.SLIDE_HORIZONTALLY_ANIMATION_DRAGGING_SCALE
)
case .ended, .cancelled:
flyBack()
default:
break
}
}
}At this point, you should see that the TabCell always returns to its starting position
when the animation ends. This behavior is demonstrated in the following gif.
Currently, the tab always flies back to its starting position. But, as per consideration 3, we need to determine if the swiping left gesture is intentional and, if so, have the tab fly off to the left and be deleted from the collection view.
- Update
handleGestureProgress(sender:)like so:
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
let translationX = sender.translation(in: self).x
let translationLeft = -translationX
let speedX = sender.velocity(in: self).x
let speedLeft = -speedX
switch sender.state {
case .changed:
slideHorizontally(
byAmount: translationX,
withScaling: TabCell.SLIDE_HORIZONTALLY_ANIMATION_DRAGGING_SCALE
)
case .ended, .cancelled:
if (
translationLeft < TabCell.MINIMUM_LEFT_DISPLACEMENT_FOR_DELETION &&
speedLeft < TabCell.MINIMUM_LEFT_SPEED_FOR_DELETION
) {
flyBack()
break
} else {
// The tab was dragged sufficiently fast and/or sufficiently far and
// should, therefore, fly off to the left and be deleted from the
// collection view.
flyLeft(
then: {
self.delegate?.delete(cell: self)
}
)
}
default:
break
}
}
private static let MINIMUM_LEFT_DISPLACEMENT_FOR_DELETION = 125.0
private static let MINIMUM_LEFT_SPEED_FOR_DELETION = 1000.0
}At this point, you should be able to swipe left on a tab to delete it, as demonstrated in the following gif.
- Update the handler when
sender.stateis changed like so:
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
// ...
switch sender.state {
case .changed:
let movingLeft = translationX < 0
let undampenedOffset = translationX
let dampenedOffset = translationX / TabCell.DIRECTIONAL_DAMPENING_FACTOR
let directionallyDampenedOffset = movingLeft ? undampenedOffset : dampenedOffset
slideHorizontally(
byAmount: directionallyDampenedOffset,
withScaling: TabCell.SLIDE_HORIZONTALLY_ANIMATION_DRAGGING_SCALE
)
// ...
}
}
private static let DIRECTIONAL_DAMPENING_FACTOR = 5.0
}At this point, you should see that rightward movement is dampened as demonstrated below:
Currently, it's possible for the TabCell to be dragged behind another TabCell. This
conflicts with the goal of scaling the TabCell during the dragging animation.
Specifically, we want to make it feel as though the tab is being picked up while the user
is dragging it. To improve this illusion, we can set the CALayer's zPosition property.
extension TabCell {
@objc func swipeToDelete(sender: UIPanGestureRecognizer) {
// Moves cell above other cells z-axis-wise.
self.layer.zPosition = 1
// ...
case .ended, .cancelled:
self.layer.zPosition = 0
}
}- Introduce the following class attribute
extension TabCell {
private var isSwiping = false
static var someCellIsSwiping: Bool = false
}- Create a file called "UIPanGestureRecognizer+Extensions.swift" and add the following helper method to to cancel a gesture.
extension UIPanGestureRecognizer {
func cancel() {
self.isEnabled = false
self.isEnabled = true
}
}- Cancel the gesture if it tries to begin while
someCellIsSwipingis alreadytrue.
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
// ...
switch sender.state {
case .began:
if TabCell.someCellIsSwiping {
sender.cancel()
}
// ...
}
}
- Set
TabCell.someCellIsSwipingandisSwipingbased on the swiping progress. Only allow the single tab cell who's currently swiping to set the class variableTabCell.someCellIsSwiping.
extension TabCell {
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
// ...
switch sender.state {
case .began:
if TabCell.someCellIsSwiping {
sender.cancel()
} else {
TabCell.someCellIsSwiping = true
self.isSwiping = true
}
// ...
case .ended, .cancelled:
self.layer.zPosition = 0
if (self.isSwiping == true) {
TabCell.someCellIsSwiping = false
self.isSwiping = false
}
}
}
}- Add a method to the
TabCellDelegateprotocol:
protocol TabCellDelegate: AnyObject {
// ...
func someTabCellIsBeingSwiped(isSwiping: Bool)
}- Implement that method as follows:
extension TabCollectionVC: TabCellDelegate {
func delete(cell: TabCell) {
guard let indexPath = collectionView?.indexPath(for: cell) else {
fatalError("Cannot find indexPath for Tab whose deletion was requested.")
}
vm.deleteTab(at: indexPath)
collectionView?.deleteItems(at: [indexPath])
}
func someTabCellIsBeingSwiped(isSwiping: Bool) {
collectionView.isScrollEnabled = !isSwiping
}
}- Call the method in the
handleGestureProgress(sender:)method whenever a cell begins or ends swiping.
At this point, you should notice that (as desired) it's impossible to scroll the collection view once a cell begins swiping. But also notice that it's impossible to not be swiping a cell even if you intended to scroll vertically. To fix this, we should cancel the gesture if the direction is largely vertical, which we can do as follows.
- Add the following to the
UIPanGestureRecognizerextension.
extension UIPanGestureRecognizer {
// ...
enum GestureDirection {
case horizontal
case vertical
}
var gestureDirection: GestureDirection {
let speedX = abs(self.velocity(in: self.view).x)
let speedY = abs(self.velocity(in: self.view).y)
return abs(speedY) > abs(speedX) ? .vertical : .horizontal
}
}- Update the
.beganblock within thehandleGestureProgress(sender:)as follows:
extension TabCell {
// ...
@objc func handleGestureProgress(sender: UIPanGestureRecognizer) {
// ...
case .began:
if (TabCell.someCellIsSwiping || sender.gestureDirection == .vertical) {
sender.cancel()
// ...
}
}
At this point, you should see that we can swipe left to delete a cell and scroll up and down, and which we do will depend on the general direction of the gesture at its start.


























