Welcome to the MAD2 Assignments. These tasks will guide you through essential aspects of SwiftUI, SwiftData, and Http requests. In the upcoming video, you'll see the type of app you'll be creating.
Videos can't be printed.
Description: Assignment Video
Link: assets/assignments/pokedeck.mp4
First, we need a directory were we can put our work.
cd ~/projects/mad2
)cd ~/projects/mad2/pokedeck
)git clone mad2/mad2-**semester**/**student-short** .
.
at the end of the command tells git, to clone the repository into the current directory without a new folderCMD + Shift + .
to toggle hidden files visibility):git status
to check, if new untracked files from the XCode project are listedBefore the start coding, we want git to ignore some files when committing
.DS_Store
In the first assignment we build our first views with SwiftUI and a model for our favorite pokemons.
View
where we can put all View-files.FavoriteDetailView
ScrollView, VStack, Divider, Text
Color.gray
.frame(width: 140, height: 140)
.clipShape(Circle())
.frame(maxWidth: .infinity)
At the moment the displayed data in our view are hardcoded. We want to change our view to display the data based on a property. Therefore, we need a model.
Model
, where we can put our Model-files.FavoritePokemon
FavoritePokemon
with the properties (id, name, greeting, nickname, height, weight, imageUrl). Choose matching types on your own.init
function to set the propertiesid
should be typed as UUID
(part of Foundation
). Everytime we create a new instance of the model, we want a random id (therefore the property should be set automaticaly and not be part of the constructor).Now we can use that model in our view.
pokemon
to our FavoriteDetailView
#Preview
with a Pokemon (e.g. Pikachu)https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png
FavoritePokemon
-model inside the #Preview
Our image is still a gray circle. We want to reuse the image functionality later, and therefore create a reusable view.
PokemonImageView
url
and size
. Size should have a default value (e.g. 140)#Preview
and pass values for the two properties (you can use the url from above)AsyncImage
AsyncImage
in the body (checkout the docs)AsyncImage
to the size property of the class
FavoriteDetailView
with our PokemonImageView
In the final state, our app has two list views that we can access through tabs. In this assignment, we create the basis and implement the navigation.
FavoritesListView
favoritePokemonList
which is a list of FavoritePokemon
-model#Preview
and pass a list containing at least one pokemonbody
to display a list according to the screenhotNavigationLink
around the image and label to navigate to the FavoriteDetailView
on tapNavigationStack
around our listNavigationStack, List, ForEach, Navigation, HStack
ForEach
requires items to implement the protocol Identifiable
TabView
in our app to navigate between the two main viewsSearchListView
(we just use it as a placeholder for now)ContentView
and remove everything and just keep the empty body
variableSearchListView, FavoriteListView
TabView
and .tabItem
for the tab navigation and styling
Next we build our first service, that helps us to get the pokemons from an API.
Api
where we can put all Api-related files.PokeApi
mock
and an init
function to set the variable. The default should be false
. We see in the next assignment, why we need that variable.PokeApi
PokemonListItem
(id, name, imageURL)PokemonDetail
(id, name, height, weight, imageURL)getPokemonList
PokemonListItem
as an optional Identifiable
. Add a comment in the code (it will be graded), why we have to implement that. Here is a link to the documentationPokemonListItem(id: 25, name: "Pikachu", imageUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png"),
PokemonListItem(id: 1, name: "Bulbasaur", imageUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"),
PokemonListItem(id: 4, name: "Charmander", imageUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png"),
PokemonListItem(id: 7, name: "Squirtle", imageUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/7.png")
getPokemonDetail
id
PokemonDetail
as an optionalPokemonDetail
instanceNow we can implement our SearchListView, that is currently a placeholder.
PokeApi
.PokeApi.PokemonListItem
. For now, you can initialize the list with the same static pokemons like the PokeApi
.#Preview
macro and update your ContentView
to pass an instance to the SearchListView
.NavigationStack, ScrollView, VStack, LazyVGrid
.LazyVGrid
use ForEach
to display the pokemons.Here is some code for the LazyVGrid
:
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))]) {
// inside the grid
}
Now, we want to use our PokeApi inside our view.
.onAppear
method on the NavigationView
to do something, when the view appears (Remember the lifecycles we talked about?).PokeApi.getPokemonList()
with await
we can use a Task.@State
decorator for our variable. Add a comment in your code, why this is necessary: Link to doc)We are almost done. We only need the detail view for the search.
SearchDetailView
TextField
with a bind to a variable Link to docpokemonId
as variable. Use the same logic like in our PokemonSearchView
to load a PokemonDetail
from our PokeApi
when the view appears and use that response to set the variables for our view.NavigationLink
in the SearchListView
that opens SearchDetailView
when we click on a pokemon in the grid.
This assignment is based on a co-programming session in the class. We implement a Http service together and explain it in detail step by step.
This is the code from the session:
import Foundation
class HttpApi {
private enum ApiError: Error {
case invalidUrl
case unexpectedStatusCode
case connectionFailed
case invalidJSON
}
private static let pokeAPIBaseURL = "https://pokeapi.co/api/v2"
private static let pokeAPIImageBaseURL = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon"
private struct PokemonListResponseItem: Decodable {
let name: String
let url: String
}
private struct PokemonListResponse: Decodable {
let results: [PokemonListResponseItem]
}
private struct PokemonDetailResponse: Decodable {
let id: Int
let name: String
let height: Double
let weight: Double
}
private static func get<T: Decodable>(url: String) async throws -> T {
guard let parsedUrl = URL(string: url) else {
throw ApiError.invalidUrl
}
let request = URLRequest(url: parsedUrl)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
print(error)
throw ApiError.connectionFailed
}
if (response as! HTTPURLResponse).statusCode > 299 {
throw ApiError.unexpectedStatusCode
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
print(error)
throw ApiError.invalidJSON
}
}
static func getPokemonList() async -> [PokeApi.PokemonListItem]? {
do {
let response = try await get(url: "\(pokeAPIBaseURL)/pokemon?limit=151") as PokemonListResponse
var pokemonList: [PokeApi.PokemonListItem] = []
for pokemon in response.results {
let id = getPokemonIdFromURL(url: pokemon.url);
pokemonList.append(
PokeApi.PokemonListItem(
id: id,
name: pokemon.name.capitalized,
imageUrl: getPokemonImageURL(id: id)
)
)
}
return pokemonList
} catch {
print(error)
return nil
}
}
private static func getPokemonIdFromURL(url: String) -> Int {
return Int(url.split(separator: "/").last!) ?? -1
}
private static func getPokemonImageURL(id: Int) -> String {
return "\(pokeAPIImageBaseURL)/\(id).png"
}
}
getPokemonDetail(id)
that calls the pokemon HTTP api and returns a PokeApi.PokemonDetail
PokeApi
service to either return the static example data or call the HttpApi
depending on the variable mock
#Preview
macros should initialize the PokeApi
with mocked data (Check with XCode preview).ContentView
should initialized with the PokeApi
with actual HTTP data (Check with the simulator).In this assignment we add a persistence layer to our app, to add pokemon to or delete pokemon from our favorites list.
FavoriteListeView
in our ContentView
fileFavoritePokemon
class a SwiftData
model. Thus, we have to add the @Model
decorator.FavoriteListView
to use @Query()
instead of initializing the favoritePokemonList
ModelConfiguration
, Container
and have to insert a pokemon in our context
Here is a code snippet (you just have to replace the MODEL
placeholder):
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: MODEL.self, configurations: config)
container.mainContext.insert(MODEL())
return FavoriteListView().modelContainer(container)
The preview should work now and display the pokemon that you insert into the mainContext
.
PokedeckApp
file to use our FavoritePokemon
model.Item.swift
file (if you haven't done that already)ContentView
TabView
to correctly initialize the FavoriteListView
#Preview
to also use a ModelContainer
. It's almost identically to the #Preview
of our FavoriteListView
.SearchDetailView
@Environment(\.modelContext) private var context
addPokemonToFavorites
FavoritePokemon
#Preview
of this view?modelContext
via the environmentForEach
has a .onDelete
method there you can apply some logic on the swipe to delete gesture.onDelete
.onDelete
passes to our function is an IndexSet
(a list of indexes)favoritePokemonList
modelContext
via the environmentScrollView
has a .toolbar
function, where we can add ToolbarItem
.Here is the code for a ToolbarItem:
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
deletePokemon()
}, label: {
Image(systemName: "trash").foregroundColor(.red)
})
}
FavoriteDetailView
. The toolbar is part of the NavigationController
and therefore we have to open the preview on level higher (e.g. FavoriteListView
)Now that the basic functionality works, we want to enhance the usability a little.
We add a search field to quickly find pokemon.
TextField
above your LazyVGrid
searchText
TextField
has a v.onChange(of: searchText)` method, to execute something on a changeHere is some styling for the TextField
:
.padding(8)
.background(.thinMaterial)
.cornerRadius(10.0)
allPokemonList
(for the result from the api) and filteredPokemonList
(for the current state based on the search input)pokemonList
variable that is filled with all Pokemon from the APIfilteredPokemonList
ForEach
renders the filtered listfilterPokemonList
that takes a string as an argument and return a [PokeApi.PokemonListItem]
. Return the full pokemon list first (we do we filtering later).onChange
of the TextField
and assign the result to your filteredPokemonList
onAppear
, after loading the pokemon from the API.searchText
is empty, return the full list else here is the code:allPokemonList.filter {$0.name.lowercased().contains(searchText.lowercased()) }
To prevent you users from accidently deleting a pokemon in the detail view, we can implement an Alert
that is displayed first and has to be confirmed.
FavoriteDetailView
showAlert
, default should be false
.ScrollView
has a method .alert
: DokumentationisPresented
to your state variable..destructive
and deletes the pokemon. A second button to cancel the alert is automatically present.Text
for the message view.Here is some code:
.alert("Alert Title", isPresented: $bindingVariable) {
// Buttons with actions
} message: {
// text to display
}
Button
of the toolbar to not delete the pokemon, but display the Alert
instead.TabView
, e.g. if the favorites are empty, at least a title is shown..navigationTitle
on your ScrollView
or List
. E.g. for the views SearchListView
, FavoritesListView
.Currently, if you add a pokemon or delete one from the detail view, the current view is still displayed. It is a nice enhancement, to automatically close the view and for example show the list of pokemons after the action.
dismiss
in order to dismiss the current view (on both detail views).// Env
@Environment(\.dismiss) private var dismiss
// Dismiss a view
dismiss()