Previous page: Fetching Data with Queries
Let's start building a real app. We're going to show a to-do list using a table view, with each row being a single to-do item in the list.
SwiftUI lets you build small, focused views that you can compose together to create your UI, so let's start really small and build the view for showing a single to-do item in the list.
import SwiftUI
struct ToDoItem: View {
let text: String
let complete: Bool
var body: some View {
HStack {
Image(systemName: complete ? "checkmark.square" : "square")
Text(verbatim: text)
}
}
}
This is a pretty simple view, but it's already showing one of the challenges we face when building UIs with many small views. The text
and complete
data this view needs are available on the Todo
type in our GraphQL schema, but how do we get them into this view? If we use a @Query, then every single to-do item in the list will make a separate network request to the server to load its data, so we probably don't want to do that.
We could just make sure to include these fields in a @Query on a component higher-up in the tree and then pass that data down into the ToDoItem
view. But this makes it harder to re-use the view elsewhere in the app, since those fields will need to be included in those queries as well. Even if this is the only screen where we use this view, this approach has problems. Other views that render a ToDoItem
shouldn't have to care exactly what data it needs, and if we combine fields from many views into a single query, it's unclear which views are using a given piece of data.
Relay and Relay.swift are designed to help with this problem by making it easy to compose not just views but also the data they require. To do this, we use GraphQL fragments, which let us define a named selection of fields on a GraphQL type. Let's define one for ToDoItem
:
import SwiftUI
import RelaySwiftUI
private let todoFragment = graphql("""
fragment ToDoItem_todo on Todo {
text
complete
}
""")
This fragment expresses exactly the data we need to render this view. Run npx relay-compiler
to generate a new file __generated__/ToDoItem_todo.graphql.swift
that includes some additional types we can use to work with this fragment. Be sure to add the new file to the project.
Now we can update our view to take in data using the fragment instead of individual parameters:
import SwiftUI
import RelaySwiftUI
private let todoFragment = graphql("""
fragment ToDoItem_todo on Todo {
text
complete
}
""")
struct ToDoItem: View {
@Fragment<ToDoItem_todo> var todo
var body: some View {
if let todo = todo {
HStack {
Image(systemName: todo.complete ? "checkmark.square" : "square")
Text(verbatim: todo.text)
}
}
}
}
We're using the @Fragment property wrapper this time instead of @Query. @Fragment doesn't load new data over the network; instead, it lets us read data that's already been loaded by another fragment or query.
Now that we've wrapped up the data that ToDoItem
needs in a fragment, we can't pass in the values for text
and complete
directly anymore. So how do we create a ToDoItem
and tell it which to-do item to render? Let's see how to use this fragment view inside another view.
Fragments are composable just like SwiftUI views. Let's create a new view for a showing the to-do list for a user. This view will also use a fragment to express the data it needs, and it will use the ToDoItem
view we already defined to show each item.
import SwiftUI
import RelaySwiftUI
private let userFragment = graphql("""
fragment ToDoList_user on User {
todos(first: 100) {
edges {
node {
id
...ToDoItem_todo
}
}
}
}
""")
struct ToDoList: View {
@Fragment<ToDoList_user> var user
var body: some View {
if let user = user {
List(user.todos ?? []) { todo in
ToDoItem(todo: todo.asFragment())
}
}
}
}
This view asks for the first 100 todos for a user and shows them in a list. The only information it asks for from each item is the ID, which it needs to provide to the List
view for diffing. Otherwise, it delegates displaying the to-do item to the ToDoItem
view, and uses the ToDoItem_todo
fragment to load that data. If we forgot to include the ...ToDoItem_todo
in our fragment, we may not have the necessary data to create the ToDoItem
view, and thanks to the type system, we would catch that at build time.
You might be surprised to find that if you tried to access the text
or complete
fields on a to-do item from this view, you wouldn't be able to. The ToDoList
view only has access to the fields explicitly listed in the ToDoList_user
fragment. This prevents ToDoList
from accidentally depending on data it didn't ask for. But if we can't access those fields, how are we able to pass the to-do item on to the ToDoItem
view?
Because we included the ...ToDoItem_todo
in our fragment, the generated Todo_node
type has the asFragment()
method, which converts it to a @Fragment
value that can be passed directly to ToDoItem
's default initializer. The fragment it creates doesn't actually include the data that ToDoItem
needs: it just has a pointer to where in Relay's store it can find that data. When the todo
property is used in ToDoItem
, it will look up that information itself.
By using asFragment()
, we can easily pass data between components using @Fragments while ensuring each individual component is explicitly asking for exactly the data it needs.