SwiftUI Tutorials

How to Create a SwiftUI Paging Tab View

The SwiftUI version of a Swift paging Collection View.

Crystal Knight
Geek Culture

--

A laptop with sticky notes reading “Code”, “Debug”, “Learn”, “Sleep”, “Repeat”
My daily process

I was working on a purely SwiftUI project that had a search bar, a map, and then a results section. The results section needed to be a collection of cards that the user could swipe through, with the focus snapping to the center item when the user stopped swiping (like photos). The center item would then update the pins in the map.

In Swift, this would be done easily with a Collection View, using “section.orthogonalScrollingBehavior = .groupPagingCentered

But when I searched for a way to do this in SwiftUI, I wasn’t finding an easy answer.

I tried using a LazyHGrid in a ScrollView, but that didn’t snap to the center item, nor could I extract the index of the center item to update the map.

So I created a custom view modifier and used it on an HStack. That solved the issue of snapping to the center, but it didn’t always work, and it was hard to get the cards to look right. Plus it still didn’t solve the index issue.

So I bit the bullet and converted Collection View to a UIViewRepresentable (there’s a good set of instructions for it here), which worked well for making it snap to item, but I still couldn’t reliably get the centered index.

Then last night, I had a “Eureka” moment. I was messing around with tab views, and I came across this post showing how you could make a Tab View with a paging style.

I wondered if TabView was only for full page projects, or if it would work inside another view. So I tried it, and voila! It works beautifully!

Tutorial Project

I created a sample project so you can test it out too. This tutorial assumes you are somewhat new to SwiftUI, but have at least a basic understanding of Swift. Just want to see the implementation of the Tab View? Skip to the bottom.

Search App with paging Tab View

Models and Data

First, create a new iOS App project in Xcode. Make sure you select “SwiftUI” for the interface, and “SwiftUI App” for the Life Cycle.

Create a new Swift file under the ContentView called “Models.swift”. This is where we will create models for Place and Vacation.

Now let’s make some mock data to search. You can add this to the model file, or create a “MockData.swift” file. I added random system pics for the images.

Initial Set Up

Now that we have our data set up, let’s start making the views.

Under the ContentView.swift file, create 4 new SwiftUIViews: “SearchBarView.swift”, “SearchMapView.swift”, “SearchResultsView.swift” and “ResultCardView.swift”. Change the default text in the body of each one to be the name of the file for now, so you can see the views during testing.

Open the ContentView, and add two state properties, one for the results, and one for the currently selected results index. Then in the body, create a VStack of the SearchBarView, SearchMapView and SearchResultsView.

It should look something like this:

Search Bar

The search bar view has a few components: the title, a text field, and a searching function.

Make a State property for the entered text we want to use for the search, and a Binding for the results. In the body, add a VStack with a Text headline and a TextField. The TextField will link to the text in the State property. Then you can either add a button to start the search, or have the TextField search “onCommit”. I think searching onCommit is more intuitive.

Before we create the search function, let’s add a little helper at the bottom of the file. This helper is good for finding all of the values in an Array that meet a certain criteria. I found this helper here.

extension Array where Element: Equatable {
func all(where predicate: (Element) -> Bool) -> [Element] {
return self.compactMap { predicate($0) ? $0 : nil }
}
}

Now we can use the all(where: ) method to filter our data dynamically. I chose to have it find the search term in either the Vacation.name or the Place.name.

Map

[Section Updated on 4/8/21 to properly update region.]

In order for the map to update dynamically, we need to create an ObservableObject model, and an ObservedObject property. Using Observable Objects prevents the view from modifying the state during a view update, which could have unintended results. First, let’s create the RegionModel:

Then add the ObservedObject to the map as a property. I gave it a default value for my country, but you can set yours wherever you like.

We’ll need bindings for the results and index as well.

Then we’ll need a function to recenter and zoom the map whenever the results or current selection changes.

When we create the map, we want the annotations to update based on the currently selected result. But we can’t count on the results always having a value. Before the user searches, or if the search comes empty, the map shouldn’t have any annotations. The annotationItems will accept an empty array, but not optional/nil items.

Result Card

Now we need to make the card that displays each result. For this tutorial, I decided to add random system pictures to the vacations, so the cards would have more visual appeal. Feel free to tweak this card design however you’d like. I’d love to see some of the creative things people come up with.

Search Results View

Now that the project is all set up, we can finally implement the TabView in our Search Results View. We’ll need to have the bindings for the results and selection properties. The actual Tab View is very easy to implement.

Let’s start by building the main part of the view.

Building a tab view is similar to creating a List in SwiftUI, in that you create it, and use a ForEach to iterate over an array. The magic is in the first line, where the TabView is bound to the selection. It will automatically update the selection, based on which result you are currently viewing.

TabView(selection: $selection) {}

In this case, we will be implementing the ForEach a little differently, because we want to pass the index of the currently displayed card up to the map, so it will update the pins. It’s tempting to use results.enumerated() to do this, but I caution against that. It’s possible to get an “index out of bounds” error particularly when the array changes (you do a new search). So we will zip the array instead, like this:

ForEach(Array(zip(results.indices, results)), id: \.0) { index, result in
ResultCardView(vacation: results[index]).tag(index)
}

The tag is super important, because that is how the TabView knows what number to update the selection to.

Now to give the TabView some style with

.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))

The completed TabView should look like this:

TabView(selection: $selection)  {
ForEach(Array(zip(results.indices, results)), id: \.0) { index, result in
ResultCardView(vacation: results[index]).tag(index)
}
} //: TabView
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))

And now the project is complete! Test it out by running it, and tell me what you think. Find the completed project here.

--

--

Crystal Knight
Geek Culture

iOS developer who loves learning, gaming, and helping people.