By this part of the course, you've already built a couple of different SwiftUI screens using a bunch of SwiftUI views. You've used Text
s, Image
s, Rectangle
s, VStack
s and HStack
s, but there's one type of screen we haven't yet built: A list.
Lists (AKA table views) are at the core of almost all iOS apps. The most popular apps like Instagram, Twitter or Reddit all show a big list of stuff on their main screen. In this part of this SwiftUI course, you'll learn how to show a list of items in SwiftUI.
You'll learn this by building a contacts screen. This screen will show all of the users your current user can chat with. By the end of this part, you'll have a screen that looks like this:
Before you can build that screen, though, you first need a way to navigate to it. Let's get started!
You can always find the finished project code on GitHub.
Navigating to the contacts screen
You'll start by creating a new SwiftUI view file for your contacts screen and naming it ContactsView.swift. Change the struct to the following:
import SwiftUI
struct ContactsView: View {
var body: some View {
Text("Contacts")
}
}
So far it doesn't show much — you'll take care of that later. For now, let's add a way to navigate to this view. Open LoginView.swift and change the navigation link's destination to show the newly created contacts view, instead of EmptyView
:
NavigationLink(destination: ContactsView(), isActive: $showContacts) {
EmptyView()
}
If you run the app now and click the login button, you'll navigate to the view. Now we can fill it up with contacts! Well... in a bit.
I am creating this course in collaboration with CometChat, a modern chat platform to help you add chat to you Swift app. During the next few weeks, we’ll be releasing installments of our free SwiftUI course here on dev.to! In the course, you’ll dive deep into SwiftUI by building a real-world production-scale chat app, learning SwiftUI in a practical way, on a scale larger than a simple example app. Follow me to get notified of future parts of this course! You can also follow @CometChat on Twitter see the course of CometChat’s blog.
What's in a List?
First, we'll lay some groundwork on SwiftUI lists. In SwiftUI, Lists are created by using a view called, shockingly, List
. Much like Group
or VStack
, a List
is a sort of wrapper around a bunch of child views. It hugs all of its children in a tight embrace and positions them on the screen, one below the other, with separators, paddings, a scroll view and everything else you'd expect from a list.
SwiftUI's List
is backed by a UITableView
on iOS, which means that the same cell reuse behavior you're used to is there on SwiftUI, too. Apple is an environmentally conscious company, after all, so cells are recycled.
Instead of loading all of your rows at once, SwiftUI will only take up memory for the rows that are currently visible. As you scroll, the rows that disappear are not destroyed, but instead, go into a pool of deactivated rows. When the list needs a new row, instead of allocating one, it will take one from the pool.
When the list takes a row from the pool, it will apply any necessary changes to its views (text values, images, etc.) to match the item at the current index. This gives the appearance of having many rows, while in reality only a couple are ever loaded at once.
All this is to say that List
s are very fast and memory-efficient, regardless of how many rows there are in the list.
In UIKit, you'd probably first create a table view and then think about placing items in that table. The component-based nature of SwiftUI means you'll have to reverse your thinking: Start from smaller views and work your way up.
That's why you'll first create the view for each row in your list and only then begin assembling the contacts screen.
Note: On websites like StackOverflow, you'll often see people suggesting you replace
List
with aScrollView
and aForEach
view to achieve a desired look. In some cases, this works well. However,ScrollView
will load all the items at once. If you have a lot of items, a plain scroll view will be much less efficient than aList
!
Making a SiwftUI List row view
Before we create our items, we need a model struct to house information about each contact. Create a new plain Swift file called Contact.swift. Change the contents to the following:
import Foundation
struct Contact: Identifiable, Equatable {
let name: String
let avatar: URL?
let id: String
var isOnline: Bool
}
You'll notice Contact
conforms to Identifiable
. To easily show an array of items in a list, those items need to each have a unique identifier. Remember, rows are reused, so the list needs a way of uniquely identifying each item. You conform to Identifiable
by giving the struct an id
property that is unique.
To create the row view, create a new SwiftUI View
file and name it ContactRow.swift.
This is what you'll end up with:
This view has a few things going on. It shows the avatar with a little online badge, the contact's name, as well as their last message.
As you just learned, it's better to start from smaller views and build up to larger views. That's why you'll start by creating a separate view for the avatar and the online badge.
First, you'll need to add a few placeholder avatars to your app. Download the avatar placeholder image and add it to Assets.xcassets. Name the image avatar_placeholder0
.
With that in place, add the view struct to the file:
private struct AvatarView: View {
// 1
let url: URL?
let isOnline: Bool
var body: some View {
// 2
ZStack {
// 3
Image("avatar_placeholder0")
.resizable()
.frame(width: 37, height: 37)
// 4
Circle()
.frame(width: 10, height: 10)
.foregroundColor(isOnline ? .green : .gray)
.padding([.leading, .top], 25)
}
}
}
With this code, you create a new SwiftUI view for the avatar. You'll use AvatarView
inside the view for the list's row. Here's what's going on in the struct:
- The view has two properties, one for the avatar URL and one that tells it whether that contact is online. For now, you'll use placeholder avatars. Later in the course, you'll learn how to fetch them from the internet.
- Inside of
body
, you'll position the image and the online indicator in aZStack
. You've already learned aboutVStack
andHStack
.ZStack
is similar to those, but in 3D! Instead of arranging items below or to the right of each other, it arranges items on top of each other. This is useful for when you want two views to overlap. In this example, you want the online indicator to be on top of the image. - In the
ZStack
, you first create an image view with the placeholder and give it a fixed size of 37 by 37 points. - Next, you create a circular view of 10 by 10 points and color it green or gray depending on if the user is online or not. You also position the circle to be in the bottom-right corner of the image.
You'll need to modify the preview to see the view you just created. Scroll down to the PreviewProvider
struct and change its content to the following:
struct AvatarView_Previews: PreviewProvider {
static var previews: some View {
Group {
AvatarView(url: nil, isOnline: true)
.previewLayout(.fixed(width: 100, height: 100))
}
}
}
This shows the avatar view in all its glory, with a fixed size, inside a group.
You'll use the group later to show your contact row as well. Speaking of which, let's get on creating that view.
Add another View
struct to the same file, underneath AvatarView
:
struct ContactRow: View {
struct ContactItem: Identifiable {
let contact: Contact
let lastMessage: String
let unread: Bool
var id: String { contact.userID }
}
let item: ContactItem
}
This is the view that will be shown in the list. Instead of adding a bunch of properties to the struct, you create a nested struct to house all of the data necessary for displaying a contact.
Again, you use Identifiable
to help the list reuse items. In this case, passing along the contact's ID will suffice.
For this to be a complete view, you'll also need to add body
to the struct:
var body: some View {
VStack {
Spacer()
}
.background(item.unread ?
Color(red: 236 / 255, green: 240 / 255, blue: 254 / 255) :
nil)
.frame(maxWidth: .infinity)
.frame(height: 67)
}
To center the contents of the view, you'll place the image and the texts inside a vertical stack. For now, the stack contains only a spacer. You also give the stack a light blue background color if the last message is unread, set its width to stretch out as far as it can and make sure its height is a fixed 67 points.
Next, modify the preview so it shows the contact view underneath the avatar view:
Group {
AvatarView(url: nil, isOnline: true)
.previewLayout(.fixed(width: 100, height: 100))
ContactRow(item: ContactRow.ContactItem(
contact: Contact(name: "Some Name", avatar: nil, id: "0", isOnline: true),
lastMessage: "Last message is a pretty big message",
unread: true))
.previewLayout(.fixed(width: 300, height: 67))
ContactRow(item: ContactRow.ContactItem(
contact: Contact(name: "Other Name", avatar: nil, id: "1", isOnline: false),
lastMessage: "Last message is a pretty big message",
unread: false))
.previewLayout(.fixed(width: 300, height: 67))
}
To test all the variations of the contact view, you'll show one preview where the user is online and the message is unread, and one where the user if offline and the message was already read.
All of this code to display a completely empty view! Let's fix that.
Add the avatar view inside a horizontal stack to ContactRow
's body
:
var body: some View {
VStack {
Spacer()
HStack {
AvatarView(url: nil, isOnline: item.contact.isOnline)
.padding(.leading, 20)
Spacer()
}
Spacer()
}
...
}
You place the avatar in a horizontal stack so that you can add the two text views to the right of the avatar. You also add another spacer to the bottom of the VStack
so that everything is vertically centered in the row.
Next, add the two texts to the view between the avatar view and the spacer in the HStack
:
VStack {
...
HStack {
AvatarView(url: nil, isOnline: item.contact.isOnline)
.padding(.leading, 20)
// New code:
VStack(alignment: .leading) {
Text(item.contact.name)
.foregroundColor(.body)
.fontWeight(item.unread ? .medium : .regular)
.lineLimit(1)
Text(item.lastMessage)
.foregroundColor(.body)
.font(.system(size: 12))
.fontWeight(item.unread ? .medium : .regular)
.lineLimit(1)
.padding(.top, 2)
}
.padding(.leading, 10)
.padding(.trailing, 20)
// ---
Spacer()
}
...
}
Place the two texts inside a vertical stack since they're arranged one on top of the other. You'll also limit the number of lines of text to 1, to make sure the text doesn't wrap. Finally, you'll add some padding around the vertical stack so that there's a space between the texts and the image.
That's your contact item! It displays the name, the avatar, the last message as well as if the contact is online or not, all at a glance.
Using SwiftUI Lists
Now, it's time to head over to ContactsView.swift to add a bunch of contacts inside a list.
You'll start by creating a state property that will hold your contacts. For now, fill it up with a couple of fake contacts:
@State private var items: [ContactRow.ContactItem] = [
.init(
contact: Contact(name: "Some Name", avatar: nil, id: "0", isOnline: true),
lastMessage: "This is my last message that I sent you",
unread: true),
.init(
contact: Contact(name: "Other Name", avatar: nil, id: "1", isOnline: false),
lastMessage: "This is my last message that I sent you",
unread: false),
.init(
contact: Contact(name: "Third Name", avatar: nil, id: "2", isOnline: true),
lastMessage: "This is my last message that I sent you",
unread: false)
]
You can use this array to populate a list. Replace body
with the following:
var body: some View {
List(items) { item in
ContactRow(item: item)
}
}
To use a List
with an array, you can pass the array to the list, as well as a function that will create the view for each row, given the array element for that row. Since you already created your row view, you can just create that view with the contact.
Note: Remember that to show a dynamic array of items in a
List
, those items need to conform to theIdentifiable
protocol as described above.
In only three lines of code, you have a list of dynamic items from an array. Take that, UIKit!
Removing separators from a SwiftUI List
However, there is a slight problem with this screen. Instead of a nice-looking list, we have a space and a separator between each item. This happens because SwiftUI uses a plain UITableView
to show the list, which has built-in separators and insets.
To change this, you first need to tweak the way you created the list. I showed you a shortcut for creating a list from an array, but there's another way to do it using ForEach
. Change body
to the following:
var body: some View {
List {
ForEach(0..<items.count, id: \.self) { i in
ContactRow(item: self.items[i])
}
}
}
Here, List
receives a builder that builds an array of views. You'll build this array using ForEach
. ForEach
is a special type of view that replaces itself with a list of views, one below the other. In the above code,
ForEach(0..<items.count, id: \.self) { i in
ContactRow(item: self.items[i])
}
...is exactly the same as writing:
ContactRow(item: self.items[0])
ContactRow(item: self.items[1])
ContactRow(item: self.items[2])
Each of these three views will get treated as a separate row by the list. Again, because a List
needs to identify items, you'll pass the current index (self
) as the ID of that item.
Note: In UIKit it was somewhat difficult to combine different types of cells. It was especially hard to combine static and dynamic cells. In SwiftUI, since
List
receives an array of rows, it's very easy to combine static and dynamic content. You can first return a hard-coded row, then aForEach
of a couple of different dynamic rows, then another static one, and so on. AList
can hold any combination of static and dynamic rows of different types.
Now that you changed the way you build your list, you can apply a little hack to remove the separators.
Since List
is backed by a UITableView
, any global changes you make to table views will also apply to SwiftUI lists. UIKit provides an API to make global changes to all instances of a view called UIAppearance
. Each UIView
subclass has an appearance
method that returns something called an appearance proxy. Changing properties of the appearance proxy propagates those changes to all instances of the class, like changing a static property.
You can change the table view's appearance proxy to remove the separators, thus removing all separators from you List
.
Add the following initializer to the view struct:
init() {
UITableView.appearance().tableFooterView = UIView()
UITableView.appearance().separatorStyle = .none
}
Note: Appearance proxies are not limited to table views. Lots of other SwiftUI views are backed by UIKit views. You can also use this pattern to change the look of navigation bars, toggles, texts and other views.
Keep in mind though, this is a bit of a hack. For now, SwiftUI is backed by UIKit, but this is an implementation detail that you don't want to rely on. Nothing is stopping Apple from ditching
UITableView
and making their own thing in SwiftUI. I'm showing you this trick because, currently, there's no better way to remove separators.
When the struct loads, it will remove all separators from every table view in the app.
The list now looks much cleaner, but I think it looks better with separators.
Adding custom separators to a SwiftUI List
You might be pulling your hair out by now because you just removed them! Well, SwiftUI is flexible enough that, instead of using UITableView
's separators, it's better to make our own using plain SwiftUI views.
Go back the view for your item: ContactRow.swift. Inside body
, use a rectangle view at the bottom of the VStack
to add separators back in:
var body: some View {
VStack {
Spacer()
HStack {
...
}
Spacer()
// New code:
Rectangle()
.frame(height: 1)
.foregroundColor(Color(UIColor.separator))
}
...
}
The rectangle will stretch the width of the view by default, all you need to change is to set the height to 1 point and set the color to whichever separator color you'd like.
Head back to ContactView.swift to see your list:
It's already looking great, but there's one small issue I can see. There's still extra space around each item.
Removing space around SwiftUI List rows
SwiftUI adds space around each row in a List
by default. This space is called the inset of a list row. The inset is an instance of EdgeInsets
, a struct that holds values for how much the view is shifted from the leading, trailing, top and bottom edges.
You can remove the inset by calling listRowInsets
on the view inside the list, and giving it a value of EdgeInsets()
, where all the values are zero.
Add the following to body
:
var body: some View {
List {
ForEach(0..<items.count, id: \.self) { i in
ContactRow(item: self.items[i])
.listRowInsets(EdgeInsets())
}
}
}
Okay, now it's looking perfect.
Except... I'd still like to add one final touch. In the original design, there was a shadow underneath the last item, giving the whole screen a sense of depth that is currently missing.
Adding a shadow to a SwiftUI view
To add a shadow to the row you'll call, you guessed it: shadow
. This method takes the color of the shadow, its radius, as well as an x and y value that determines how much the shadow is offset from the view.
SwiftUI adds shadows to opaque parts of the view. That is parts that have some sort of color. Right now, the background of your rows is transparent. It appears white because the color underneath them is white. To show the shadow underneath a row, you need to first color the background in white, and then add the shadow.
Add the following to body
:
var body: some View {
List {
ForEach(0..<items.count) { i in
ContactRow(item: self.items[i])
.listRowInsets(EdgeInsets())
.background(Color.white)
.shadow(
color: i == self.items.count - 1 ?
Color(UIColor.black.withAlphaComponent(0.08)) :
Color.clear,
radius: 10, x: 0, y: 2)
}
}
}
You set the background color to white and apply a shadow to the view. You only want to add a shadow to the last row, though, so you make the shadow completely transparent if the row is not the last.
Okay, now I'm happy with the look of this screen. Let's run this in the simulator so see it in action.
Oh boy, it looks like you're not off the hook yet! There's one more issue to take care of. The original design shows a title in the navigation bar, which isn't there in our version. Thankfully, that's easy to add.
Adding a navigation bar title in SwiftUI
To add a navigation bar title, you need to call navigationBarTitle
on the root view of your body
. In your case, that's the list view:
var body: some View {
List {
...
}.navigationBarTitle("Contacts", displayMode: .inline)
}
By default, SwiftUI adds a large, bold navigation bar title with lots of space around it. You already saw this when you were building your login screen. In this case, you want a more subtle title that is part of the navigation bar. That's why you pass .inline
as the display mode, integrating the title with the bar.
Now it looks perfect. No, for real this time. You can take a break and pat yourself on the back, I won't be bothering you with design changes anymore.
Conclusion
You're slowly moving more and more towards a fully-fledged SwiftUI chat app! You built out a contacts screen that shows a list of users.
If you want to know more about SwiftUI lists, here are a few links:
- In UIKit, table views can hold sections of items. The same is true for SwiftUI
List
s. To organize rows into sections, place aSection
view inside a list, and rows inside the section. - There's a couple of different list styles that you can use off the shelf. There's a default list, a plain list, a grouped one (used in the Settings app in iOS), a carousel and one that looks like the lists in an iOS sidebar. You can find all of them here. To change the style of your list, call
listStyle
on the list.
While the contacts in your list might be hard-coded for now, everything is in place to load these users from the internet — in later parts of this SwiftUI course. So keep reading!
Top comments (2)
For those of you who are getting the error "Value of type 'Contact' has no member 'userID'" which causes the build to fail: change the ContactItem struct var id from "String { contact.userID }" to "String { contact.id }" .
Not sure why the example code has "userID" but the final project's ContactRow view uses "id" instead and even if you change the Contact file to "userID" the build will fail as it will no longer conform to the Identifiable protocol.
Thanks for that - I was wondering how to fix that.