Не створюйте UI завантаження, успіху й помилки з нуля в кожному iOS-застосунку

Налаштовуваний стандартний UI, замінювані view станів і спільний шар показу для SwiftUI та UIKit.

Космонавт на Місяці: дзеркальне відображення iPhone і гірського краєвиду в золотистому візорі

Додаєш індикатор до кнопки Save, і здається, що UI завантаження майже готовий. Потім операції потрібні повідомлення про успіх, помилка з Retry, спосіб закрити результат і правила того, на що користувач може натискати під час її виконання.

Запит на вхід і збереження документа виконують різну роботу, але часто показують користувачу ту саму послідовність: завантаження, результат, а потім закриття або ще одну спробу. Якщо щоразу реалізовувати цей показ заново, його поведінка розпорошується по застосунку. А повторно використовувати фіксований набір view стає незручно, щойно новому дизайну потрібен інший індикатор чи інше компонування помилки.

Мені потрібен був спільний спосіб показувати ці стани: зі стандартними view, які можна одразу використати, і можливістю замінити кожен із них окремо. Код функціональності має передати результат і вирішити, що робить Retry. Шар показу має відобразити вибраний view, закрити його та керувати взаємодією з контентом під ним.

Саме таке завдання HUD у цій статті: тимчасово показувати завантаження й результат поверх поточного контенту застосунку. На прикладі збереження я покажу цей розподіл, а потім поясню рішення в реалізації, зокрема чому виніс HUD в окреме вікно.

Відокремлюємо операцію, її стан і view для його показу

Тут є два види повторного використання. Поведінка має залишатися узгодженою, коли кілька частин застосунку однаково повідомляють користувача про перебіг роботи. Вигляд має залишатися замінюваним, коли цим частинам або іншому застосунку потрібен інший дизайн.

Я розділив відповідальність там, де зміни виникають із різних причин:

Частина Відповідальність
Застосунок Виконує роботу, обробляє скасування й запізнілі результати та визначає, що робить Retry
Менеджер показу Зберігає поточний стан UI та застосовує правила закриття результату
Провайдер view Надає контент завантаження, успіху й помилки
Презентер Під’єднує HUD та керує його показом і взаємодією

Застосунок повідомляє про стан для показу; провайдер надає його контент, а презентер відображає його

Ці компоненти відповідають за показ. Сама операція застосунку виконується поза цим шаром.

Менеджер може використовувати той самий стан успіху незалежно від того, чи його контентом є галочка з текстом, чи view із дизайн-системи застосунку. Презентер може залишати HUD під’єднаним, поки цей контент змінюється. Жодному з них не потрібно знати, чи результат отримано після збереження файла, чи після входу в обліковий запис.

Це також задає практичний порядок налаштування. Починаємо зі стандартних view. Для невеликих відмінностей у дизайні змінюємо їхні налаштування. Коли компонуванню потрібна більша свобода, замінюємо контент одного стану, залишаючи решту стандартними. Заміна view помилки не повинна вимагати нової реалізації показу й закриття.

Застосунок визначає, коли починається робота, передає її результат і обирає дію для Retry. Менеджер застосовує правила закриття результату. Менеджер показу не є чергою операцій чи виконавцем задач. У цій моделі один менеджер зберігає один поточний стан; якщо задачі виконуються одночасно, застосунок має вирішити, яку з них цей стан представляє.

Важлива й область застосування. Модальний HUD доречний для операції, під час якої користувач має залишатися в поточному сценарії. Якщо решта екрана має залишатися доступною, вбудованого в контент індикатора прогресу часто достатньо.

Як я реалізував це в DMUnLoader

DMUnLoader я побудував навколо цих відповідальностей: менеджер, провайдер view і презентер, прив’язаний до сцени. Окреме вікно відповідає за розміщення та ввід; до цього механізму я повернуся після прикладів зі станами й налаштуванням. Бібліотека надає UI завантаження, успіху й помилки для SwiftUI та UIKit. Приклади нижче використовують DMUnLoader 1.1.0, iOS 17+ і Swift 6.2+.

Три офісні столи, що представляють стани завантаження, успіху й помилки

Менеджер завантаження надає чотири операції:

Виклик Що показує стандартний UI
showLoading(provider:) View завантаження, доки застосунок не змінить стан
showSuccess(_:provider:) View успіху з переданим результатом
showFailure(_:provider:onRetry:) View помилки з Close і, якщо передано відповідну дію, Retry
hide() HUD не відображається

Один менеджер зберігає один стан у кожен момент часу. Значення успішного результату має відповідати протоколу DMLoadableTypeSuccess; String уже йому відповідає. Стандартний view успіху використовує description цього значення. Стандартний view помилки використовує localizedDescription помилки, а також заголовок і елементи керування зі своїх налаштувань.

Ці виклики показують контент HUD, а не виконують навігацію на інший екран. Поки стан відображається, HUD перехоплює дотики до контенту застосунку під своїм вікном у тій самій сцені. Успіх і помилка зберігають цю модальну поведінку до закриття. Розміщення відносно інших вікон залежить від налаштованого рівня вікна.

Одне підключення в корені сцени

Додайте https://github.com/nikolay-dementiev/DMUnLoader у залежності пакетів Xcode й виберіть версію 1.1.0 або сумісний реліз. У цьому прикладі менеджер і провайдер підключаються в корені сцени. Дія Save керує роботою та явно оновлює HUD:

import DMUnLoader
import SwiftUI
import UIKit

@main
struct LoadingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoSceneRoot()
        }
    }
}

@MainActor
struct DemoSceneRoot: View {
    @StateObject private var manager = DMLoadingManagerMain(
        state: .none,
        settings: DMLoadingManagerDefaultSettings(
            hudWindowLevel: .normal + 1,
            backdrop: .dim()
        )
    )
    private let provider = DefaultDMLoadingViewProvider()

    var body: some View {
        DMRootLoadingView(manager: manager) { manager in
            Button("Save") {
                saveChanges(using: manager, provider: provider)
            }
        }
    }
}

@MainActor
private func saveChanges<
    Manager: DMLoadingManager,
    Provider: DMLoadingViewProvider
>(
    using manager: Manager,
    provider: Provider
) {
    manager.showLoading(provider: provider)
    Task {
        do {
            // Replace this delay with the operation you own.
            try await Task.sleep(for: .seconds(1))
            manager.showSuccess("Saved", provider: provider)
        } catch {
            let retry = DMButtonAction { [weak manager] in
                guard let manager else { return }
                saveChanges(using: manager, provider: provider)
            }
            manager.showFailure(error, provider: provider, onRetry: retry)
        }
    }
}

За замовчуванням менеджер приховує успіх і помилку через дві секунди або після натискання, зокрема й помилки з Retry. Нижче я зміню це правило, щоб помилка залишалася доступною, доки користувач не вибере дію.

Явно заданий рівень вікна розміщує цей HUD над вікнами застосунку з рівнем normal у його сцені. У розділі про вікно нижче пояснено цей вибір і його обмеження. За замовчуванням бібліотека використовує .normal, тож без цього налаштування вона не обіцяє такого самого порядку вікон.

Затримка зазвичай завершується успішно; замініть її операцією збереження, помилки якої хочете показувати. Retry знову викликає saveChanges, а ця функція явно повертає HUD до стану завантаження. Її дія захоплює менеджер слабко, оскільки менеджер утримує стан помилки та його дію Retry. API показу працює на main actor; застосунок і далі вирішує, як скасувати роботу чи відкинути запізнілий результат.

DMRootLoadingView(manager:content:) не потребує app delegate від SDK. Якщо зберігати його @StateObject усередині кореня сцени, кожен корінь отримає власний менеджер. Це важливо, коли застосунок має кілька вікон. Спільний менеджер на рівні застосунку означав би й спільний стан.

Є також інтеграції через делегати для SwiftUI та UIKit. Для UIKit потрібні маніфест сцени, конфігурація з DMSceneDelegateTypeUIKit<Helper> і допоміжний тип, який створює кореневий контролер та отримує його менеджер завантаження. Після цього екран UIKit використовує ті самі виклики станів і провайдери. Він може викликати saveChanges(using:provider:) з переданим йому менеджером; ця частина повідомлення про перебіг операції не залежить від body SwiftUI view. Ініціалізатор DMRootLoadingView(content:) без переданого менеджера створює менеджер сам і потребує зв’язку app/scene delegate, який надає SDK; ініціалізатор із переданим менеджером у прикладі вище цього не потребує. У README версії 1.1.0 є повні приклади для обох фреймворків. Використовуйте одну інтеграцію на сцену, не встановлюйте одночасно варіант із переданим менеджером і варіант через делегати.

Налаштовуємо стандартні view або замінюємо їх

Мені не потрібно писати три нові view, щоб змінити одну іконку. DefaultDMLoadingViewProvider приймає окремі налаштування для завантаження, успіху й помилки. Кожен тип налаштувань відкриває властивості, які використовує відповідний view: текст, кольори, зображення й компонування.

Наприклад, цей провайдер залишає всі три стандартні view та робить іконку успіху м’ятною:

import DMUnLoader
import SwiftUI

@MainActor
func makeMintProvider() -> DefaultDMLoadingViewProvider {
    DefaultDMLoadingViewProvider(
        successViewSettings: DMSuccessDefaultViewSettings(
            successImageProperties: SuccessImageProperties(
                foregroundColor: .mint
            )
        )
    )
}

У прикладі Save змініть оголошення провайдера на private let provider = makeMintProvider(). Виклики станів у saveChanges залишаються тими самими. View завантаження й помилки зберігають стандартний вигляд, оскільки їхні налаштування не змінювалися.

Налаштування стандартних view Приклади того, чим вони керують
DMProgressViewDefaultSettings Текст завантаження, властивості індикатора, колір і базовий розмір контейнера
DMSuccessDefaultViewSettings Зображення успіху, оформлення тексту й відстані між елементами
DMErrorDefaultViewSettings Заголовок помилки, зображення, кнопки й компонування

Повідомлення про успіх і далі надходить зі значення, переданого до showSuccess. Зміна SuccessTextProperties.text не замінює опис цього значення. Якщо потрібне інше розташування елементів чи інший індикатор, наступним кроком буде власний view.

Замінюємо view одного стану або всіх трьох

DMLoadingViewProvider має три вимоги протоколу, що створюють view. Для кожної є стандартна реалізація, тож заміна завантаження не змушує мене також писати успіх і помилку.

Щоб дати операції Save власний контент завантаження, цьому провайдеру достатньо реалізувати один метод:

import DMUnLoader
import SwiftUI

final class SavingViewProvider: DMLoadingViewProvider {
    @MainActor
    func getLoadingView() -> some View {
        VStack(spacing: 12) {
            ProgressView()
            Text("Saving your changes")
        }
    }
}

У кореневому view задайте private let provider = SavingViewProvider(). Успіх і помилка тепер використовують стандартні реалізації протоколу. Це новий провайдер замість попереднього, тож м’ятне забарвлення з минулого прикладу теж зникає. Функцію збереження та її дію Retry змінювати не потрібно.

Задаємо власний контент для кожного стану

Коли дизайн потребує також власного компонування успіху й помилки, провайдер може реалізувати всі три методи:

import DMUnLoader
import SwiftUI

final class BrandedScreens: DMLoadingViewProvider {
    @MainActor
    func getLoadingView() -> some View {
        VStack(spacing: 12) {
            ProgressView()
            Text("Saving your changes")
        }

    }

    @MainActor
    func getSuccessView(object: any DMLoadableTypeSuccess) -> some View {
        Label(object.description, systemImage: "checkmark.seal.fill")
            .foregroundStyle(.green)

    }

    @MainActor
    func getErrorView(
        error: any Error,
        onRetry: (any DMAction)?,
        onClose: any DMAction
    ) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("We could not save this change")
                .font(.headline)
            Text(error.localizedDescription)
            HStack {
                if let onRetry {
                    Button("Try again", action: onRetry.simpleAction)
                }
                Button("Close", action: onClose.simpleAction)
            }
        }

    }
}

Щоб його використати, змініть оголошення провайдера в кореневому view на private let provider = BrandedScreens(). Виклики showLoading, showSuccess і showFailure залишаються тими самими.

Тепер контент станів визначаю я. Можу використати компонент дизайн-системи, змінити його компонування або надати іншу анімацію завантаження. Стандартні реалізації видно в протоколі провайдера.

SDK і далі обгортає контент провайдера у спільну заокруглену картку HUD із власними відступами й фоном. Саме тому приклади надають контент без ще однієї зовнішньої картки. Провайдер замінює контент стану; підкладку та правила взаємодії налаштовуємо окремо на менеджері.

Методи провайдера будують view й можуть викликатися більше одного разу для стану. Запускайте роботу з дій застосунку, не всередині getLoadingView(). Окремий hosting controller також створює окреме дерево SwiftUI environment: environment objects рівня застосунку не з’являться автоматично у власних view HUD. Передавайте потрібні їм залежності самостійно.

Стандартні й власні панелі станів на одному офісному столі

Менеджер зберігає ті самі стани, а провайдер змінює контент для показу кожного з них.

Визначаємо, як результати зникають з екрана

Стандартну поведінку легко не помітити: успіх і помилка ховаються через дві секунди або після натискання. Для помилки з Retry діє те саме правило. Можливість повторити спробу може зникнути ще до того, як користувач до неї дістанеться.

Ця конфігурація менеджера залишає помилки з Retry видимими, доки користувач не натисне кнопку або застосунок не змінить стан:

import DMUnLoader
import UIKit

@MainActor
func makePersistentFailureManager() -> DMLoadingManagerMain {
    DMLoadingManagerMain(
        state: .none,
        settings: DMLoadingManagerDefaultSettings(
            hudDismissal: DMHUDDismissalRules(
                failureWithRetry: DMHUDDismissal(
                    autoHide: .never,
                    cardTapHides: false,
                    backdropTapHides: false
                )
            ),
            hudWindowLevel: .normal + 1,
            backdrop: .dim()
        )
    )
}

У DemoSceneRoot ініціалізуйте state object як @StateObject private var manager = makePersistentFailureManager(), щоб застосувати це правило до того самого сценарію Save.

Close і далі приховує помилку. Дія Retry у прикладі Save знову викликає saveChanges, яка явно показує завантаження перед повторним запуском операції.

Менеджер також відхиляє застарілі callbacks автоматичного приховування, тож таймер попереднього стану не може приховати наступний. Цей захист стосується таймера, а не роботи застосунку: старіший запит усе ще може перезаписати новіший результат, якщо застосунок не обробляє скасування або не перевіряє, якій операції належить результат. Тести автоматичного приховування перевіряють саме цю межу таймера.

Дії та підкладка є окремими компонентами

DMUnLoader залежить від двох інших моїх бібліотек, кожна з яких виконує вужче завдання:

  • DMAction надає дії, які викликаються через Retry та Close. Її власний API також підтримує композицію повторних спроб і резервних дій (fallback).
  • DMVariableBlurView надає підкладку з радіусом розмиття, який змінюється по площі view. DMUnLoader використовує її для стандартної підкладки зі змінним розмиттям. Замість неї менеджер може вибрати .dim(), .material() або .clear.

У репозиторіях за цими посиланнями є приклади й деталі композиції дій та змінного розмиття.

Стандартне змінне розмиття використовує приватний системний фільтр. Вибір іншої підкладки дозволяє не створювати цей ефект, але не прибирає посилання на приватний API залежності зі скомпонованого коду. Враховуйте документацію DMVariableBlurView, коли оцінюєте цей вибір.

Чому я виділив HUD окреме вікно

Спільному API станів усе ще потрібне місце для показу view. Саме тут я зіткнувся з конкретною проблемою: HUD був видимий поверх iOS-застосунку, але tab bar унизу й далі приймав натискання. Операція мала утримувати користувача в поточному сценарії, проте перемикатися між вкладками все ще було можливо.

Тому я став окремо перевіряти дві речі: де HUD малюється й де отримує ввід. Скриншот повного перекриття не підтверджує обидві.

View може перекривати ділянку, але не отримувати натискання в ній

Overlay належить view, який він модифікує. У UIKit вибраний контролер надає контент вкладки, а UITabBarController надає tab bar; блокування першого не блокує автоматично другий. У SwiftUI перевірте, чи HUD обгортає TabView, чи розміщений усередині однієї вкладки. Внутрішня ієрархія його реалізації не є публічним контрактом.

UIKit може малювати дочірній view поза межами батьківського, не спрямовуючи туди натискання: hit testing не заходить у піддерево батьківського view для точки поза його межами. Apple описує цей випадок у QA1812. Це одне з можливих пояснень, чому візуальне перекриття не збігається з областю обробки вводу. Без ієрархії початкового застосунку та прикладу, що відтворює проблему, це не діагноз мого випадку.

ignoresSafeArea змінює компонування, zIndex змінює порядок показу, а disabled впливає на взаємодію всередині своєї ієрархії view. allowsHitTesting(false) виключає view з hit testing; він не перетворює цей view на перешкоду для вводу. Результат кожного з цих підходів усе ще залежить від того, де під’єднаний HUD.

Overlay на правильному спільному предку може працювати. Якщо мені потрібно лише заблокувати контейнер вкладок, може бути достатньо розмістити перекриття навколо цього контейнера. Але я також хотів мати окрему область показу, яка могла б перекривати інші презентації застосунку за межами ієрархії view однієї функціональності.

Вікно сцени дає HUD окрему область показу

У статті Custom HUDs in SwiftUI Федеріко Дзанетелло відокремлює стан HUD від view окремих частин застосунку. У продовженні, How to layer multiple windows in SwiftUI, він показує, як sheet перекриває кореневий HUD, і переносить його в інше вікно. Ці статті 2021 року надихнули мене на цю частину реалізації.

Презентер додає UIWindow до наявної UIWindowScene. Контент застосунку й HUD залишаються в тій самій сцені.

Overlay рівня view належить контенту застосунку; HUD і контент застосунку мають окремі вікна в тій самій сцені

Презентер утримує вікно, задає йому UIHostingController та робить фон hosting view прозорим. Для показу HUD він використовує isHidden = false. Виклик makeKeyAndVisible() також запитував би статус key window; це окрема відповідальність, описана в документації Apple. Показ шару завантаження не потребує такого запиту.

Рівень вікна потрібно вибрати явно. Якщо вікна застосунку мають рівень .normal, HUD на рівні .normal + 1 розміститься над ними. Порядок вікон з однаковим рівнем не гарантований. Жодне із цих значень не означає розміщення над будь-яким власним вікном, клавіатурою чи системним UI. Це обмеження визначає windowLevel, а не вигляд картки.

Прозора перегородка на опорах додає передній шар у тому самому офісі

Ділянка, що здається порожньою, теж має блокувати ввід

Цей HUD використовує модальні правила вводу, поки стан видимий, і пропускає дотики наскрізь, коли неактивний. Спрощений варіант правил вікна SDK пов’язує цю поведінку зі станом показу:

import UIKit

@MainActor
final class ModalHUDWindow: UIWindow {
    var showsHUD = false

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard showsHUD else { return nil }
        return super.hitTest(point, with: event)
    }
}

Встановіть прапорець перед показом вікна, а потім синхронізуйте його зі станом показу. У неактивному стані повертається nil; коли HUD видимий, використовується звичайний hit testing UIKit.

Кореневому view в hosting controller також потрібна поверхня на все вікно, а не лише картка в центрі. Ось спрощений варіант такої поверхні для завантаження:

import SwiftUI

struct ModalLoadingSurface: View {
    var body: some View {
        ZStack {
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea()
                .onTapGesture { }

            ProgressView("Loading...")
                .padding(24)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Жест на підкладці перехоплює натискання під час завантаження. Для результатів його можна натомість пов’язати із закриттям. Цим спрощеним прикладам усе ще потрібні підключення до сцени, hosting controller, власник, який утримуватиме вікно, та синхронізація стану.

Прозора підкладка також може блокувати ввід. Вікно, кероване станом, і розміщена в ньому поверхня на всю площу разом забезпечують модальну поведінку.

Керування часом життя вікна в сцені та accessibility мають бути частиною тієї самої реалізації

Презентер вікна прив’язує вікно до сцени, яка насправді містить кореневий view, що запросив HUD. Інтеграція з переданим менеджером знаходить її через SceneHUDAnchor, а не бере першу підключену сцену. Використовуйте одну інтеграцію на сцену; дві можуть створити два вікна HUD.

hide() змінює стан менеджера на .none. Порожнє вікно може залишатися під’єднаним і пропускати дотики. Видалення кореневого view з переданим менеджером, очищення посилання на менеджер у делегаті сцени або від’єднання сцени прибирає відповідні елементи структури. При заміні менеджера презентер повторно використовує своє вікно та hosting controller. Це правила володіння об’єктами так само, як і правила показу.

Блокування дотиків потребує відповідної поведінки для accessibility. Адаптер accessibility приховує від допоміжних технологій інші вікна, що не є HUD, уже під’єднані до сцени й мають рівень HUD або нижчий. Згодом він відновлює їхній початковий стан. Вищі вікна та вікна, додані після початкового знімка стану, не входять до цієї області дії. Оголошення, відновлення фокуса, жест escape та обробка налаштувань зменшення ефектів є окремими частинами реалізації.

Під час вибору контенту також слід враховувати відомі проблеми з README: залежно від підкладки контраст білого контенту на стандартній напівпрозорій сірій картці може бути нижчим за 4.5:1, а стандартний текст завантаження може обрізатися за великих розмірів тексту accessibility. Власний контент потрібно окремо перевірити на контраст, розмір тексту й роботу з VoiceOver. Каталог рядків версії 1.1.0 містить стандартні англійські тексти; інші тексти застосунок надає через налаштування.

Інші підходи по-іншому розподіляють ті самі відповідальності

Для невеликого індикатора, прив’язаного до одного екрана, я спершу розглянув би локальний overlay на view. Презентер повідомлень із довільним дизайном і чергою має іншу область застосування. Тут вимога полягає в одному модальному стані завантаження або результату на сцену, з можливістю замінити контент стану. Ці рішення визначають, які API варто порівнювати. Нижче наведено порівняння за вихідним кодом, а не бенчмарк продуктивності чи рейтинг:

Підхід API стану й показу Як налаштовується UI
Overlay на SwiftUI view Стан і місце підключення визначає ієрархія view застосунку Довільний контент SwiftUI; застосунок задає правила вводу й закриття
DMUnLoader 1.1.0 Один менеджер зі станами завантаження, успіху й помилки; вікно, прив’язане до сцени; інтеграції зі SwiftUI та UIKit Налаштування кожного стандартного стану; заміна view будь-якого стану або всіх трьох через провайдер
AlertToast Прив’язка SwiftUI .toast із завантаженням, успіхом (.complete), помилкою та іншими вбудованими типами; overlay на view Вбудовані налаштування стилю й показу; замикання контенту повертає AlertToast
ProgressHUD 15.0.3 Модифікатор SwiftUI та спільний API показу й закриття з прогресом, результатами та іншими вбудованими типами; overlay на view Вбудовані налаштування анімації, іконок, кольорів і шрифтів
SwiftMessages 10.0.2 Показ повідомлень у view, контролері або налаштованій сцені вікна; підтримка UIKit і SwiftUI Власний UIView або контент SwiftUI у hosting controller, а також налаштування показу й закриття

Відповідні джерела: модифікатор AlertToast, реалізація ProgressHUD для SwiftUI, його публічний API і SwiftMessages. Порівняння використовує ProgressHUD 15.0.3, SwiftMessages 10.0.2 і AlertToast на коміті 76a1c17. Лінійка ProgressHUD 14.x для UIKit має іншу реалізацію, ніж лінійка 15.x для SwiftUI.

SwiftMessages також підтримує явний контекст .windowScene(scene, windowLevel:), тож використання іншого вікна не є винятковою можливістю DMUnLoader. Область застосування SwiftMessages охоплює черги повідомлень і кілька контекстів показу. API DMUnLoader зосереджений на одному стані завантаження або результату, трьох замінюваних view та дії Retry, яку надає застосунок. Якщо це відповідає сценарію роботи застосунку, саме за цими межами варто порівнювати рішення.

Перевіряємо весь шлях від завантаження до результату

У репозиторії є тести вікон у застосунку і тести взаємодії, які перевіряють володіння вікнами, обробку вводу під час показу HUD і після його приховування, прозорі підкладки та пізніше додане вікно застосунку з рівнем normal. У цих прикладах немає відтворення проблеми з tab bar чи sheet; такі презентації потрібно перевіряти у власному застосунку. Проєкт-приклад можна взяти за основу для підключення.

У застосунку я перевірив би весь сценарій показу станів як зі стандартним провайдером, так і з власними view станів. Перевірте текст і компонування результату, дії Retry та Close, а також вибрану поведінку автоматичного приховування й закриття натисканням. Зміна дизайну не повинна непомітно змінювати ці правила.

Показ потребує окремої перевірки взаємодії. Для застосунку з вкладками:

  1. У неактивному стані перемкніть вкладку й активуйте елемент керування під HUD.
  2. Покажіть завантаження, натисніть у тих самих місцях і переконайтеся, що вибір вкладки не змінюється, а дії не спрацьовують.
  3. Повторіть перевірку з уже відкритими sheet і повноекранною презентацією, використовуючи заплановані рівні вікон застосунку.
  4. Перейдіть до успіху й помилки. Перевірте Close, Retry та вибрані правила закриття; елемент керування під HUD не повинен спрацьовувати разом із ними.
  5. Приховайте HUD і переконайтеся, що взаємодія відновилася. Перевірте ту саму послідовність із VoiceOver та в незалежних сценах.

Висновок

Повторно використовувати варто весь шлях від запуску операції до показу результату й повернення користувача до роботи із застосунком. Кожна частина застосунку й далі відповідає за свою роботу, але зміна її дизайну не повинна щоразу вимагати нової реалізації завантаження, показу результату й закриття HUD.

Саме таке розділення я заклав у DMUnLoader. Для першого підключення можна взяти стандартні view; для нового дизайну змінити їхні налаштування або замінити контент окремих станів усередині спільної картки HUD. Застосунок і далі повідомляє про завантаження, успіх або помилку через той самий API менеджера. SwiftUI та UIKit використовують ту саму модель, а вікно, прив’язане до сцени, дає презентеру місце для показу й обробки вводу незалежно від компонування окремого екрана.

Для мене практичний сенс такого дизайну стає зрозумілим під час наступної зміни: чи можу я замінити індикатор завантаження або компонування помилки, не переписуючи операцію Save і не створюючи ще один презентер? Саме це дозволяє повторно використовувати шар показу у різних сценаріях і застосунках, залишаючи кожному застосунку контроль над його роботою та виглядом.

Додаткові ресурси

Бібліотеки й приклади

Реалізація й тести

Документація Apple

Статті й альтернативні бібліотеки

Востаннє оновлено

Обговорення

Завантаження обговорення…

На цій сторінці