Освобождаем модальные окна в SwiftUI

26 июля 2021

При создании iOS и MacOS приложений часто нам нужно отобразить отдельный вью модально, или пропушить его в стек навигации. Например, мы отображаем MessageDetailView в качестве модального окна, используя встроенный в SwiftUI модификатор sheet в комбинации с локальным свойством @State, который следит отображается ли сейчас view с детальной информацией или нет:

struct MessageView: View {
    var message: Message
    @State private var isShowingDetails = false

    var body: some View {
        ScrollView {
            Text(message.body)
            ...
        }
        .navigationTitle(message.subject)
        .navigationBarItems(trailing: Button("Details") {
            isShowingDetails = true
        })
        .sheet(isPresented: $isShowingDetails) {
            MessageDetailsView(message: message)
        }
    }
}

Вопрос в том, как мы можем отпустить наш MessageDetailView, если он уже отображается? Один из способов сделать это - добавить свойство isShowingDetails в наш MessageDetailsView в качестве биндинга, который далее наш MessageDetailView может установить на false тем самым отпустив себя из памяти:

struct MessageDetailsView: View {
    var message: Message
    @Binding var isPresented: Bool

    var body: some View {
        VStack {
            ...
            Button("Dismiss") {
                isPresented = false
            }
        }
    }
}

struct MessageView: View {
    var message: Message
    @State private var isShowingDetails = false

    var body: some View {
        ...
        .sheet(isPresented: $isShowingDetails) {
            MessageDetailsView(
                message: message,
                isPresented: $isShowingDetails
            )
        }
    }
}

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

Хорошие новости в том, что у нас есть переменная среды presentationMode, которая дает доступ к объекту, который может быть использован для удаления любого view, не зависимо от того как он был отображен:

struct MessageDetailsView: View {
    var message: Message
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        VStack {
            ...
            Button("Dismiss") {
                presentationMode.wrappedValue.dismiss()
            }
        }
    }
}

Как показано выше, нам больше не нужно вручную делать инъекцию флага isShowingDetails в качестве биндинга - SwiftUI автоматом изменит это свойство на false когда view будет удален (dismiss). В качестве дополнительного бонуса шаблон выше работает и в случае пуша нашего isShowingDetails в стек навигации, а не только в случае отображения в качестве sheet. В ситуации с навигационным стеком, наш view просто вылетает из стека, когда вызывается метод dismiss. Тонко сработано!

Однако есть один неловкий момент касательно реализации приведенной выше, а именно, нам нужно получить доступ к нашему значению переменной среды wrappedValue для того, чтобы иметь возможность вызвать метод dismiss (потому что это фактически Binding, а не сырое значение). Для того, чтобы избавиться от этого, Apple ввела в новую версию iOS 15 (и в остальные операционные системы 2021 года) метод, который просто называется dismiss. Эта новая API позволяет нам на прямую вызывать этот метод:

struct MessageDetailsView: View {
    var message: Message
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            ...
            Button("Dismiss") {
                dismiss()
            }
        }
    }
}

Заметка

Обратите внимание, что на момент написания статьи, iOS 15 находится еще в бете.

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

Таким образом, если мы хотим вставить dismiss в качестве экшена напрямую в кнопку, то мы должны передать ссылка на его метод callAsFunction:

struct MessageDetailsView: View {
    var message: Message
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            ...
            Button("Dismiss", action: dismiss.callAsFunction)
        }
    }
}

Конечно, нет ничего плохого в том, чтобы обернуть наш вызов dismiss в замыкание (на самом деле, я предпочитаю делать это в такой ситуации), но в этот раз я просто решил указать на такую возможность. А в этот раз посмотреть как SwiftUI принимает вызов в качестве функции для вышеуказанного API и других подобных.

Итак, есть три разных способа отпустить модальное или detail представление SwiftUI - два из которых обратно совместимы с iOS 14 и более ранними версиями, и современная версия, которая в идеале должна использоваться в приложениях, ориентированных на iOS 15 (или ее родственные операционные системы).

Содержание