Построение бесконечного списка с помощью SwiftUI и Combine

С момента представления SwiftUI на WWDC2019 его популярность резко возросла. В течение последних двух лет отдел разработки Apple внес в этот UI-фреймворк много улучшений. В настоящее время разработчики все более активно начинают осваивать данный инструмент и даже переносят на него свои проекты с UIKit. 

Что мы будем создавать?

В этом уроке мы создадим простой список с бесконечной прокруткой и пагинацией. С помощью GitHub API мы получим список пользователей, который будет содержать всех пользователей GitHub с момента основания этого ресурса. В качестве заключительного продукта мы создадим приложение, которое сможет:

  1. Получать список пользователей на основе заданного ограничения страницы.
  2. Бесконечно прокручивать список SwiftUI за счет пагинации.
  3. Обрабатывать ошибки запросов и перезагрузку.
  4. Использовать фреймворк 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.

Подробности о конечной точке /users из документации GitHub API

Добавьте следующий код в новый файл 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()
    }
}
  1. Создаем функцию, которая получает perPage и sinceId, а возвращает тип AnyPublisher с [User].
  2. Выстраиваем URL с заданными параметрами.
  3. Создаем URL-запрос с таймаутом в десять секунд.
  4. Отправляем запрос с помощью 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
            }
    }
}
  1. Создаем переменные users и isRequested с оберткой Published, чтобы позволить любому представлению SwiftUI получать обновление.
  2. Добавляем константу pageLimit, чтобы указать количество объектов пользователей, которые будут возвращены API. Далее добавляем currentLastId для сохранения ID последнего пользователя в массиве users, который мы получили. Он будет использован для запроса параметра пагинации, о чем говорится в предыдущем шаге.
  3. Вызываем getUsers() из нашего служебного класса.
  4. В случае провала запроса мы будем отмечать переменную isRequestFailed как true, чтобы она активировала представление подписчика и совершала необходимую перезагрузку.
  5. Если же запрос пройдет успешно, мы получим возвращенное значение (с типом [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()
        }
    }
}
  1. Удаляем предыдущую статическую переменную users и вместо нее используем данные users из ViewModel. Создаем экземпляр userViewModel с ObservedObject в качестве подписчика на изменения в UserViewModel. Он будет делать представление недействительным при получении обновления.
  2. Все, как и раньше, только теперь мы используем массив users из ViewModel.
  3. Добавляем созданный ранее LoaderView как часть списка в его нижней позиции. Кроме того, в нем есть функция onAppear(), которая будет вызывать fetchData. Добавляем onTapGesture(), чтобы активировать повторное получение данных при провале запроса.
  4. Вызываем getUsers во ViewModel.
  5. Обрабатываем действие “Tap to retry”.

9. Тестирование проваленных запросов

Я рекомендую использовать Network Link Conditioner для моделирования проваленных сетевых запросов, как показано на следующем изображении. Таким образом, когда мы проматываем вниз, то можем достичь LoaderView, и после нескольких секунд запрос провалится. Подробности можете прочесть в моей предыдущей статье (англ.).

При провале сетевого запроса показывается “Tap to retry”

Проект завершен

Поздравляю! Мы завершили все шаги, и код отлично работает. Когда я реализовывал этот код SwiftUI с помощью фреймворка Combine, то не ожидал, что он получится настолько чистым. 

Весь исходный код доступен для скачивания в моем репозитории GitHub. Можете попробовать реализовать его в своих проектах и, возможно, доработать логику и UI. За дополнительными деталями о фреймворке Combine и протоколе ObservableObject рекомендую обратиться к видео (англ.) с конференции WWDC и статье (англ.) с сайта HackingWithSwift.

Благодарю за чтение! Успехов в коде!

Ссылки

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Mohd Hafiz: Build an Infinite List With SwiftUI and Combine

Предыдущая статьяPython Django: контактная форма с автоматической отправкой Email
Следующая статьяRust: взгляд старого программиста