С момента представления SwiftUI на WWDC2019 его популярность резко возросла. В течение последних двух лет отдел разработки Apple внес в этот UI-фреймворк много улучшений. В настоящее время разработчики все более активно начинают осваивать данный инструмент и даже переносят на него свои проекты с UIKit.
Что мы будем создавать?
В этом уроке мы создадим простой список с бесконечной прокруткой и пагинацией. С помощью GitHub API мы получим список пользователей, который будет содержать всех пользователей GitHub с момента основания этого ресурса. В качестве заключительного продукта мы создадим приложение, которое сможет:
- Получать список пользователей на основе заданного ограничения страницы.
- Бесконечно прокручивать список SwiftUI за счет пагинации.
- Обрабатывать ошибки запросов и перезагрузку.
- Использовать фреймворк Combine для запросов к API и
ViewModel
.
1. Создание проекта SwiftUI
2. Создание декодируемой модели пользователя
Создайте файл Swift под названием User.swift
. Из объекта JSON мы декодируем только id
, name
и avatarUrl
. Как показывает результат выше, в этом проекте для простоты примера мы будем отображать только имя пользователя. В дальнейшем вы сможете расширить его дополнительным отображением аватара и деталей профиля.
import Foundation
struct User: Decodable {
let id: Int
let name: String
let avatarUrl: String
enum CodingKeys: String, CodingKey {
case id
case name = "login"
case avatarUrl = "avatar_url"
}
}
/*
Sample User JSON object
{
"login": "mojombo",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/mojombo",
"html_url": "https://github.com/mojombo",
"followers_url": "https://api.github.com/users/mojombo/followers",
"following_url": "https://api.github.com/users/mojombo/following{/other_user}",
"gists_url": "https://api.github.com/users/mojombo/gists{/gist_id}",
"starred_url": "https://api.github.com/users/mojombo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/mojombo/subscriptions",
"organizations_url": "https://api.github.com/users/mojombo/orgs",
"repos_url": "https://api.github.com/users/mojombo/repos",
"events_url": "https://api.github.com/users/mojombo/events{/privacy}",
"received_events_url": "https://api.github.com/users/mojombo/received_events",
"type": "User",
"site_admin": false
}
*/
3. Добавление представления строк
Поскольку мы используем List
, сначала нужно создать представление строк в виде элементов списка. Добавьте новый “SwiftUI View” файл под названием UserRow.swift
. В него вставьте приведенный ниже код. Мы задействуем простой Hstack
с Image
и Text
.
Обратите внимание, что это View
UserRow
требует присутствия User
в качестве необходимой модели, то есть оно не может быть инициализировано без передачи модели в это представление.
import SwiftUI
struct UserRow: View {
let user: User
var body: some View {
HStack (spacing: 12) {
Image("imagePlaceholder")
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
Text(user.name)
}
.padding(4)
}
}
struct UserRow_Previews: PreviewProvider {
static var previews: some View {
let mockUser = User(id: 1, name: "John Doe", avatarUrl: "")
UserRow(user: mockUser)
}
}
4. Основное представление (ContentView)
ContentView
будет основным представлением по умолчанию. Основное представление определяется в файле InfiniteListSwiftUIApp
(ProjectNameApp).
Отлично. Далее включим NavigationView
и List
. Затем вставим некоторые статические пользовательские данные.
import SwiftUI
struct ContentView: View {
var users = [
User(id: 100, name: "Bob", avatarUrl: ""),
User(id: 200, name: "Alice", avatarUrl: "")
]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.id) { user in
UserRow(user: user)
}
}
.navigationTitle("GitHub Users")
.navigationBarTitleDisplayMode(.automatic)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Если теперь запустить проект, то приложение будет выглядеть примерно так:
5. Создание службы API
На этом шаге мы создадим службу API для получения пользовательских данных через GitHub API. Как показано на изображении ниже, для включения пагинации нужно отправить параметры since
и per_page
.
Добавьте следующий код в новый файл APIService.swift
. Так как мы используем SwiftUI, то проект автоматически поддерживает iOS13+. В URLSession
мы задействуем фреймворк Combine.
import Foundation
import Combine
class APIService {
static let shared = APIService()
// 1
func getUsers(perPage: Int = 30, sinceId: Int? = nil) -> AnyPublisher<[User], Error> {
// 2
var components = URLComponents(string: "https://api.github.com/users")!
components.queryItems = [
URLQueryItem(name: "per_page", value: "\(perPage)"),
URLQueryItem(name: "since", value: (sinceId != nil) ? "\(sinceId!)" : nil)
]
// 3
let request = URLRequest(url: components.url!, timeoutInterval: 10)
// 4
return URLSession.shared.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
- Создаем функцию, которая получает
perPage
иsinceId
, а возвращает типAnyPublisher
с[User]
. - Выстраиваем URL с заданными параметрами.
- Создаем URL-запрос с таймаутом в десять секунд.
- Отправляем запрос с помощью
dataTaskPublisher
. Далее отображаем результат в.data
(результат вернетData
иResponse
). В завершении декодируем эти данные в[User]
. Все просто и понятно, не так ли?
6. Создание ViewModel
Добавьте новый файл Swift UserViewModel
в качестве ObservableObject
и в него внесите код ниже. Создание подкласса ObservableObject
дает возможность использовать модель представления внутри представлений SwiftUI и активировать автоперезагрузку в случае возникновения важных изменений.
//
// UserViewModel.swift
// InfinitListSwiftUI
//
// Created by Hafiz on 28/06/2021.
//
import Foundation
import Combine
class UserViewModel: ObservableObject {
// 1
@Published var users: [User] = []
@Published var isRequestFailed = false
// 2
private let pageLimit = 25
private var currentLastId: Int? = nil
private var cancellable: AnyCancellable?
func getUsers() {
// 3
cancellable = APIService.shared.getUsers(perPage: pageLimit, sinceId: currentLastId)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
// 4
self.isRequestFailed = true
print(error)
case .finished:
print("finished")
}
} receiveValue: { users in
// 5
self.users.append(contentsOf: users)
self.currentLastId = users.last?.id
}
}
}
- Создаем переменные
users
иisRequested
с оберткойPublished
, чтобы позволить любому представлению SwiftUI получать обновление. - Добавляем константу
pageLimit
, чтобы указать количество объектов пользователей, которые будут возвращены API. Далее добавляемcurrentLastId
для сохранения ID последнего пользователя в массивеusers
, который мы получили. Он будет использован для запроса параметра пагинации, о чем говорится в предыдущем шаге. - Вызываем
getUsers()
из нашего служебного класса. - В случае провала запроса мы будем отмечать переменную
isRequestFailed
какtrue
, чтобы она активировала представление подписчика и совершала необходимую перезагрузку. - Если же запрос пройдет успешно, мы получим возвращенное значение (с типом
[User]
) и присоединим его к переменнойusers
. Опять же, при изменении переменнаяusers
будет сразу же подталкивать представление на перезагрузку его содержимого. Далее сохраняемcurrentLastId
для следующего получения.
7. Создание LoaderView
Теперь нам нужно создать LoaderView
, который будет расположен в нижней части List
. В итоге при своем появлении он будет вызывать функцию получения данных. Даже при пустом списке пользователей LoaserView
все равно будет появляться сверху и показывать сообщение загрузки.
import SwiftUI
struct LoaderView: View {
let isFailed: Bool
var body: some View {
Text(isFailed ? "Failed. Tap to retry." : "Loading..")
.foregroundColor(isFailed ? .red : .green)
.padding()
}
}
struct LoaderView_Previews: PreviewProvider {
static var previews: some View {
LoaderView(isFailed: .constant(false))
}
}
8. Обновление ContentView
Это будет последний шаг, причем достаточно простой, потому что мы разделили логику во ViewModel
.
import Combine
import SwiftUI
struct ContentView: View {
// 1
@ObservedObject private var userViewModel = UserViewModel()
var body: some View {
NavigationView {
List {
// 2
ForEach(userViewModel.users, id: \.id) { user in
UserRow(user: user)
}
// 3
LoaderView(isFailed: userViewModel.isRequestFailed)
.onAppear(perform: fetchData)
.onTapGesture(perform: onTapLoadView)
}
.navigationTitle("GitHub Users")
.navigationBarTitleDisplayMode(.automatic)
}
}
// 4
private func fetchData() {
userViewModel.getUsers()
}
// 5
private func onTapLoadView() {
if userViewModel.isRequestFailed {
userViewModel.isRequestFailed = false
fetchData()
}
}
}
- Удаляем предыдущую статическую переменную
users
и вместо нее используем данныеusers
изViewModel
. Создаем экземплярuserViewModel
сObservedObject
в качестве подписчика на изменения вUserViewModel
. Он будет делать представление недействительным при получении обновления. - Все, как и раньше, только теперь мы используем массив
users
изViewModel
. - Добавляем созданный ранее
LoaderView
как часть списка в его нижней позиции. Кроме того, в нем есть функцияonAppear()
, которая будет вызыватьfetchData
. ДобавляемonTapGesture()
, чтобы активировать повторное получение данных при провале запроса. - Вызываем
getUsers
воViewModel
. - Обрабатываем действие “Tap to retry”.
9. Тестирование проваленных запросов
Я рекомендую использовать Network Link Conditioner для моделирования проваленных сетевых запросов, как показано на следующем изображении. Таким образом, когда мы проматываем вниз, то можем достичь LoaderView
, и после нескольких секунд запрос провалится. Подробности можете прочесть в моей предыдущей статье (англ.).
Проект завершен
Поздравляю! Мы завершили все шаги, и код отлично работает. Когда я реализовывал этот код SwiftUI с помощью фреймворка Combine, то не ожидал, что он получится настолько чистым.
Весь исходный код доступен для скачивания в моем репозитории GitHub. Можете попробовать реализовать его в своих проектах и, возможно, доработать логику и UI. За дополнительными деталями о фреймворке Combine и протоколе ObservableObject
рекомендую обратиться к видео (англ.) с конференции WWDC и статье (англ.) с сайта HackingWithSwift.
Благодарю за чтение! Успехов в коде!
Ссылки
- https://developer.apple.com/documentation/combine/fail/receive(on:options:)
- https://developer.apple.com/documentation/swiftui/list
- https://developer.apple.com/documentation/combine/observableobject
- https://developer.apple.com/documentation/combine/published
- https://developer.apple.com/videos/play/wwdc2019/226
- https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-observedobject-to-manage-state-from-external-objects
- https://docs.github.com/en/rest/reference/users#list-users
Читайте также:
- Диспетчер загрузки на Swift
- Двоичный поиск в Swift и расширение возможностей коллекций
- Создание кастомного навигационного представления в SwiftUI
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Mohd Hafiz: Build an Infinite List With SwiftUI and Combine