Загрузочные представления в SwiftUI

Базовое представление

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

struct LoadingView: View {

var body: some View {
ZStack {
Rectangle()
.fill(.black)
.opacity(0.75)
.ignoresSafeArea()

VStack(spacing: 20) {
ProgressView()
Text("Loading...")
}
.background {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.frame(width: 200, height: 200)
}
.offset(y: -70)
}
}
}

struct ContentView: View {

@State var showLoading = false

var body: some View {

ZStack {

VStack {
Button {
showLoading = true
// Симуляция процесса...
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
showLoading = false
}
} label: {
Text("Show Loading")
}
.buttonStyle(.borderedProminent)
}
.padding()

if showLoading { LoadingView() }
}
.animation(.default, value: showLoading)
}
}

Этот код вполне понятен, только обратите внимание на строку 36, где я добавил задержку сокрытия загрузочного представления для симуляции завершения процесса. Естественно, в реальном приложении вся логика будет размещена внутри ViewModel, но в целях упрощения я поместил ее в само представление.

Вот результат:

Неплохо, но есть в этом подходе одна проблема. Каждый раз, когда вам нужно использовать LoadingView, придется реализовывать логику показа/скрытия и добавлять представление ZStack либо нечто аналогичное, что приведет к постоянному переписыванию одного и того же кода  —  не самое грамотное решение.

Как сделать по уму

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

Это будет простое решение, состоящее из следующих шагов:

  • Создание @StateObject со свойством, отслеживающим присутствие загрузки.
  • Добавление его в структуру App проекта и внедрение в качестве environmentObject основного представления (обычно ContentView).
  • Обертывание основного представления в ZStack и добавление логики показа/скрытия.
  • Добавление environmentObject и подключение его в каждом представлении, требующем отображения загрузки.

Взглянем на код:

class LoaderManager: ObservableObject {

@Published var showLoader = false
}

Для отслеживания состояния загрузки я создал ObservableObject со свойством @Published.

@main
struct LoaderDemoApp: App {

@StateObject var loaderManager = LoaderManager()

var body: some Scene {

WindowGroup {

ZStack {
ContentView().environmentObject(loaderManager)

if loaderManager.showLoader { LoadingView() }
}
.animation(.default, value: loaderManager.showLoader)
}
}
}

Этот объект я добавил в точку входа в приложение. Поскольку мы с самого начала внедряем менеджер загрузки как environmentObject, он будет передаваться по нисходящей в каждое дочернее представление иерархии. Любые изменения в свойстве showLoader менеджера будут приводить к обновлению самого объекта. Как сказано в документации Apple:

При любом изменении наблюдаемого объекта объект среды делает текущее представление недействительным. Если вы объявите свойство как объект среды, обязательно установите соответствующий объект модели в вышестоящее представление путем вызова его модификатора environmentObject(_:).

Последним шагом будет обновление исходного представления:

struct ContentView: View {

@EnvironmentObject var loaderManager: LoaderManager

var body: some View {

ZStack {

VStack {
Button {
loaderManager.showLoader = true
// Симуляция процесса...
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
loaderManager.showLoader = false
}
} label: {
Text("Show Loading")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
}

Просто добавьте переменную @EnvironmentObject и свяжите ее с логикой.

Как видите, теперь код стал короче, чище, понятнее и пригоден для повторного использования.

Доработка  

Показанное выше решение работает, и это прекрасно… Но что, если нам потребуется изменить текст загрузки? Что, если нам нужно будет полностью изменить UI самого загрузчика в определенном разделе?

С помощью небольших модификаций можно решить и эту задачу.

Мы изменим нашего LoaderManager, удалив логическое свойство и добавив перечисление, описывающее само состояние загрузки:

class LoaderManager: ObservableObject {

enum LoadingStateType: Equatable {
case hidden
case standard(message: String = "Loading...")
case glassed
}

@Published var loadingState: LoadingStateType = .hidden
}

При отсутствии загрузочного представления состояние будет .hidden. Для стандартного случая я добавил связанное значение с предустановленной строкой в виде: “Loading…”. Я также добавил еще один тип загрузочного представления, просто чтобы показать этот вариант. Ниже я покажу простой код обоих представлений.

Теперь изменим точку входа в приложение:

@main
struct LoaderDemoApp: App {

@StateObject var loaderManager = LoaderManager()

var body: some Scene {
WindowGroup {

ZStack {
ContentView()
.environmentObject(loaderManager)

switch loaderManager.loadingState {
case .hidden:
EmptyView()
case .standard(let message):
LoadingView(message: message)
case .glassed:
LoadingViewGlassed()
}
}
.animation(.default, value: loaderManager.loadingState)
}
}
}

Логической проверки больше нет, и теперь на строке 13 у нас появился блок switch со всеми кейсами. Обратите внимание на строку 15, где видно, что для кейса .hidden мы будем использовать EmptyView(). Другие кейсы .standard и .glassed будут иметь другие представления. Вот их код:

struct LoadingView: View {

@State var message: String

var body: some View {
ZStack {
Rectangle()
.fill(.black)
.opacity(0.75)
.ignoresSafeArea()

VStack(spacing: 20) {
ProgressView()
Text(message)
}
.background {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.frame(width: 200, height: 200)
}
.offset(y: -70)
}
}
}

struct LoadingViewGlassed: View {

var body: some View {
ZStack {
Rectangle()
.fill(Material.ultraThin)
.ignoresSafeArea()

VStack(spacing: 20) {
ProgressView()
Text("Loading...")
}
.background {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.frame(width: 200, height: 200)
}
.offset(y: -70)

}
}
}

Исходный LoadingView() был изменен на строке 3 для получения строки сообщения, указанной в представлении Text на строке 14.

LoadingViewGlassed() использует классное заполнение Material.ultraThin.

Наконец, мы можем изменить наше представление для отображения и обработки этих вариантов:

struct ContentView: View {

@EnvironmentObject var loaderManager: LoaderManager

var body: some View {
ZStack {
VStack (spacing: 20) {
Spacer()
Button {
loaderManager.loadingState = .standard()
hideLoadingWithDelay()
} label: {
Text("Show Loading")
}
.buttonStyle(.borderedProminent)

Button {
loaderManager.loadingState = .standard(message: "Downloading images...")
hideLoadingWithDelay()
} label: {
Text("Show Loading With Message")
}
.buttonStyle(.borderedProminent)

Button {
loaderManager.loadingState = .glassed
hideLoadingWithDelay()
} label: {
Text("Show Glassed Loading")
}
.buttonStyle(.borderedProminent)
}
}
}

func hideLoadingWithDelay() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
loaderManager.loadingState = .hidden
}
}
}

Обратите внимание на использование объекта loaderManager внутри действий трех кнопок на строках 10, 18 и 26. Также заметьте процесс сокрытия загрузчика на строке 38.

И вот итоговый результат:

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

Читайте нас в TelegramVK и Дзен


Перевод статьи Alessandro Manilii: Loading Views in SwiftUI.

Предыдущая статья5 важных моментов из JavaScript, которые помогут избегать ошибок
Следующая статьяПереход с Pandas на Polars: 7 простых шагов