Предотвращение закрытия контроллера модального представления в SwiftUI

На WWDC 2019 Apple анонсировала новый вид модальных презентаций в «карточном стиле», который принес с собой встроенные жесты для закрытия контроллеров модального представления, проведя пальцем вниз по карте. Они также представили новое свойство isModalInPresentation для UIViewController, чтобы вы могли запретить это поведение при увольнении, если вы того пожелаете.

Однако до сих пор я не нашел способа эмулировать это поведение в SwiftUI. Насколько я могу судить, использование .presentation(_ modal: Modal?) не позволяет вам таким же образом отключить жесты отклонения. Я также попытался поместить контроллер модального представления внутрь UIViewControllerRepresentableView, но это тоже не помогло:

struct MyViewControllerView: UIViewControllerRepresentable {
    func makeUIViewController(context: UIViewControllerRepresentableContext<MyViewControllerView>) -> UIHostingController<MyView> {
        return UIHostingController(rootView: MyView())
    }

    func updateUIViewController(_ uiViewController: UIHostingController<MyView>, context: UIViewControllerRepresentableContext<MyViewControllerView>) {
        uiViewController.isModalInPresentation = true
    }
}

Даже после показа .presentation(Modal(MyViewControllerView())) я смог смахнуть вниз, чтобы закрыть вид. Есть ли в настоящее время способ сделать это с помощью существующих конструкций SwiftUI?

Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
54
0
13 264
9
Перейти к ответу Данный вопрос помечен как решенный

Ответы 9

Изменяя gesture priority любого вида, который вы не хотите перетаскивать, вы можете предотвратить DragGesture на любом виде. Например, для Modal это можно сделать следующим образом:

Может быть, это не лучшая практика, но она отлично работает

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
        self.showModal.toggle()

    }) {
        Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
        ModalView()
    }
  }
}

struct ModalView : View {
@Environment(\.presentationMode) var presentationMode

let dg = DragGesture()

var body: some View {

    ZStack {
        Rectangle()
            .fill(Color.white)
            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
            .highPriorityGesture(dg)

        Button("Dismiss Modal") {
            self.presentationMode.wrappedValue.dismiss()
        }
    }
  }
}

На данный момент это лучшее решение, которое я видел.

Jumhyn 07.01.2020 22:59

есть проблема, хотя перетаскивание листа с двумя фигурами закроет его, также это решение не будет работать, если тело представления закрыто другим представлением (Loader, т.е. UIActivityIndicatorView).

Aleyam 01.04.2020 21:33

@Aleyam Те, что вы упомянули, могут быть новыми вопросами (протягивая лист двумя пальцами), и я уверен, что для этого есть решения. Конечно, этот блок кода не будет работать везде, где вы его вставляете. Это просто, чтобы получить идеи.

FRIDDAY 02.04.2020 07:59

да, я понял вашу точку зрения (идея этого ответа), дело в том, что вопрос заключается в том, чтобы «Предотвратить отклонение контроллера модального представления в SwiftUI», поэтому, если перетаскивание двумя пальцами отклоняет лист, кажется, что ответ неполный, а также реализация эта логика предотвращения увольнения становится кошмаром для комплексного просмотра.

Aleyam 02.04.2020 09:24

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

krummens 05.05.2020 05:16

Примечание. Этот код был отредактирован для ясности и краткости.

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

extension UIApplication {

    func visibleViewController() -> UIViewController? {
        guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
        guard let rootViewController = window.rootViewController else { return nil }
        return UIApplication.getVisibleViewControllerFrom(vc: rootViewController)
    }

    private static func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
        if let navigationController = vc as? UINavigationController,
            let visibleController = navigationController.visibleViewController  {
            return UIApplication.getVisibleViewControllerFrom( vc: visibleController )
        } else if let tabBarController = vc as? UITabBarController,
            let selectedTabController = tabBarController.selectedViewController {
            return UIApplication.getVisibleViewControllerFrom(vc: selectedTabController )
        } else {
            if let presentedViewController = vc.presentedViewController {
                return UIApplication.getVisibleViewControllerFrom(vc: presentedViewController)
            } else {
                return vc
            }
        }
    }
}

и превратите его в модификатор вида следующим образом:

struct DisableModalDismiss: ViewModifier {
    let disabled: Bool
    func body(content: Content) -> some View {
        disableModalDismiss()
        return AnyView(content)
    }

    func disableModalDismiss() {
        guard let visibleController = UIApplication.shared.visibleViewController() else { return }
        visibleController.isModalInPresentation = disabled
    }
}

и используйте как:

struct ShowSheetView: View {
    @State private var showSheet = true
    var body: some View {
        Text("Hello, World!")
        .sheet(isPresented: $showSheet) {
            TestView()
                .modifier(DisableModalDismiss(disabled: true))
        }
    }
}

К сожалению, здесь нет никакого эффекта. Я не думаю, что делаю что-то неправильно, так как в основном это копирование и расширение, а также один оператор if.

iMaddin 08.04.2020 04:55

@iMaddin Имеет ли какое-либо значение для вас редактирование с использованием его в качестве модификатора представления?

R. J. 09.04.2020 21:51

Скопировал ваше расширение и ViewModifier и использовал его для изменения содержимого моего листа. Это прекрасно работает! Отлично выглядит и работает без проблем.

ThorLindberg 12.05.2021 22:36
Ответ принят как подходящий

Обновление для iOS 15

Согласно приведенному ниже pawello2222отвечать, теперь это поддерживается новым interactiveDismissDisabled(_:) API.

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

Ответ до iOS-15

Я тоже хотел это сделать, но нигде не нашел решения. Ответ, который перехватывает жест перетаскивания, работает, но не тогда, когда он отклоняется путем прокрутки представления или формы прокрутки. Подход в вопросе также менее хакерский, поэтому я исследовал его дальше.

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

Мое решение этой проблемы:

struct ModalSheetTest: View {
    @State private var showModally = false
    @State private var showSheet = false
    
    var body: some View {
        Form {
            Toggle(isOn: self.$showModally) {
                Text("Modal")
            }
            Button(action: { self.showSheet = true}) {
                Text("Show sheet")
            }
        }
        .sheet(isPresented: $showSheet) {
            Form {
                Button(action: { self.showSheet = false }) {
                    Text("Hide me")
                }
            }
            .presentation(isModal: self.showModally) {
                print("Attempted to dismiss")
            }
        }
    }
}

Значение состояния showModally определяет, должно ли оно отображаться модально. Если это так, перетаскивание его вниз для закрытия вызовет только закрытие, которое просто печатает «Попытка закрытия» в примере, но может использоваться для отображения предупреждения для подтверждения закрытия.

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let isModal: Bool
    let onDismissalAttempt: (()->())?
    
    func makeUIViewController(context: Context) -> UIHostingController<T> {
        UIHostingController(rootView: view)
    }
    
    func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
        context.coordinator.modalView = self
        uiViewController.rootView = view
        uiViewController.parent?.presentationController?.delegate = context.coordinator
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        let modalView: ModalView
        
        init(_ modalView: ModalView) {
            self.modalView = modalView
        }
        
        func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
            !modalView.isModal
        }
        
        func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
            modalView.onDismissalAttempt?()
        }
    }
}

extension View {
    func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
        ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
    }
}

Это идеально подходит для моего случая использования, надеюсь, это поможет вам или кому-то еще.

Это способ сделать это. Ничего хакерского и очень элегантного. Спасибо, Гвидо.

Junyi Wang 09.05.2020 08:14

Если вы используете это в SwiftUI и переменные состояния не обновляются, попробуйте применить модификатор .presentation к элементу на листе, который не требует обновления пользовательского интерфейса (например, Spacer(), Divider()).

user1909186 31.05.2020 19:57

ЛУЧШИЙ ответ на июль 2020 года!

Paul D. 14.07.2020 21:06

Спасибо, что ответили на этот старый вопрос @GuidoHendriks. Я обновил принятый ответ, чтобы отразить тот факт, что это кажется «правильным» способом добиться этого. Я попытался погрузиться в представляемый UIViewController, но не возился с контроллером представления.

Jumhyn 19.07.2020 16:04

Предложения от благодарного читателя: isModal не должно быть привязкой, потому что она доступна только для чтения. Однако удаление @Binding нарушает это, потому что координатор будет хранить только начальное значение isModal. Чтобы исправить это, вы можете сделать modalView в координаторе var, а затем обновить его в updateUIViewController, как context.coordinator.modalView = self, чтобы он обновлялся правильно, если isModal изменится. Комментарий о том, что переменные состояния не обновляются, можно исправить, выполнив uiViewController.rootView = view в updateUIViewController, иначе представление не будет обновляться должным образом.

Helam 23.07.2020 01:51

Использование PresentationMode.dismiss в представлении, которое я пытаюсь представить модально, больше не работает. Кажется, единственный способ закрыть — это провести пальцем вниз (когда isModal == false). У кого-нибудь еще есть эта проблема?

jjatie 02.08.2020 14:02

@jjatie Я не использую \.presentationMode, а использую привязку к переменной, которая делает лист видимым в этом случае .sheet(isPresented: $showSheet), поэтому поставьте @Binding var showSheet: Bool и закройте self.showSheet = false вместо self.presentationMode.wrappedValue.dismiss()

LetsGoBrandon 20.08.2020 13:45

По какой-то странной причине это не работает с NavigationView или даже с простым текстом. Однако он работает с формой.

Abdalrahman Shatou 22.01.2021 22:52

@Helam - твое исправление было именно тем, что мне было нужно. Вы сэкономили мне СОООООООчень много сил.

spentag 29.01.2021 17:10

Кажется, это не работает для меня на iPad.

SlimeBaron 04.02.2021 23:46

В дополнение к изменениям в комментарии @Helam мне также пришлось использовать подкласс UIHostingController, чтобы переопределить willMove(to: parent), чтобы установить родительский presentationController, потому что updateUIViewController не происходило до того, как я впервые попытался закрыть контроллер представления.

vedosity 07.02.2021 04:07

Есть ли способ сделать это, когда View не встроен в UIControllerRepresentable?

ArielSD 17.02.2021 18:49

@guido-hendriks не хотели бы вы обновить этот ответ, упомянув новый API iOS 15 interactiveDismissDisabled(_:) вверху и оставить устаревший ответ для тех, кому необходимо поддерживать iOS 14 и ниже (или нужна функциональность onDismissalAttempt)?

Jumhyn 10.06.2021 17:38

Как упоминает @AbdalrahmanShatou, на моем iPad с iOS 14.5 это работает только так, как показано. Но если вы удалите форму или добавите что-либо, кроме кнопки внутри формы, лист может быть снова закрыт.

Bart van Kuik 25.06.2021 09:46

@vedosity Я думаю, что столкнулся с той же проблемой, что и вы. Что именно вы подразумеваете под «установкой родительского презентационного контроллера»? Как выглядит реализация willMove(to: parent)? Спасибо

gpichler 01.11.2021 18:44

Не работает вообще...

Richard Topchii 08.02.2022 06:31

Мы создали расширение, упрощающее управление модальным закрытием, по адресу https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0.

/// Example:
struct ContentView: View {
    @State private var presenting = false
    
    var body: some View {
        VStack {
            Button {
                presenting = true
            } label: {
                Text("Present")
            }
        }
        .sheet(isPresented: $presenting) {
            ModalContent()
                .allowAutoDismiss { false }
                // or
                // .allowAutoDismiss(false)
        }
    }
}

Вы можете использовать этот метод для передачи содержимого модального представления для повторного использования.

Используйте NavigationView с gesture priority, чтобы отключить dragging.

import SwiftUI

struct ModalView<Content: View>: View
{
    @Environment(\.presentationMode) var presentationMode
    let content: Content
    let title: String
    let dg = DragGesture()
    
    init(title: String, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.title = title
    }
    
    var body: some View
    {
        NavigationView
        {
            ZStack (alignment: .top)
            {
                self.content
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar(content: {
                ToolbarItem(placement: .principal, content: {
                    Text(title)
                })
                
                ToolbarItem(placement: .navigationBarTrailing, content: {
                    Button("Done") {
                        self.presentationMode.wrappedValue.dismiss()
                    }
                })
            })
        }
        .highPriorityGesture(dg)
    }
}

В представлении содержимого:

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
       self.showModal.toggle()
    }) {
       Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
       ModalView (title: "Title") {
          Text("Prevent dismissal of modal view.")
       }
    }
  }
}

Результат!

Это решение сработало для меня на iPhone и iPad. Он использует isModalInPresentation. Из документы:

The default value of this property is false. When you set it to true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.

Ваша попытка близка к тому, что сработало для меня. Хитрость заключается в установке isModalInPresentation на родитель хост-контроллера в willMove(toParent:)

class MyHostingController<Content: View>: UIHostingController<Content> {
    var canDismissSheet = true

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)
        parent?.isModalInPresentation = !canDismissSheet
    }
}

struct MyViewControllerView<Content: View>: UIViewControllerRepresentable {
    let content: Content
    let canDismissSheet: Bool

    func makeUIViewController(context: Context) -> UIHostingController<Content> {
        let viewController = MyHostingController(rootView: content)
        viewController.canDismissSheet = canDismissSheet
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
        uiViewController.parent?.isModalInPresentation = !canDismissSheet
    }
}

Спасибо за тестирование на iPad. Это выглядит как хорошее решение, но мне не нравится, что оно, кажется, полагается на личные данные об иерархии UIKit над рассматриваемыми представлениями. Все еще ищу идеальный ответ :(

Jumhyn 05.02.2021 00:26

Да, это делает то же предположение, что и принятый ответ, что представлением представления управляет родительский контроллер хоста. Я согласен, что это не идеально.

SlimeBaron 05.02.2021 00:39

Ха! Вы знаете, я действительно пропустил первый уход на второй круг, когда этот ответ попал в uiViewController.parent. Вы правы, что в этом плане он ничем не хуже. Я попробую оба из них на iPad и приму ваш ответ, если он в конечном итоге будет работать лучше :)

Jumhyn 05.02.2021 00:42

Принятый ответ работает для меня как на iPad, так и на iPhone. Какую проблему вы видели?

Jumhyn 05.02.2021 15:47

Хм. Мне удалось закрыть лист на iPad. Хотя, возможно, это была случайность. Я удалю этот комментарий из своего ответа. Спасибо, что проверили это.

SlimeBaron 05.02.2021 17:52

Если вы можете воспроизвести в любой момент, я хотел бы услышать, как это сделать. Также может быть ошибка в SwiftUI... в любом случае, мне нравится ваше решение, потому что оно позволяет избежать необходимости определять объект Coordinator за счет необходимости использования пользовательского подкласса UIHostingController. Интересно, есть ли способ вызвать updateUIViewController сразу после презентации, чтобы избежать пользовательского подкласса...

Jumhyn 05.02.2021 17:56

Я думаю, что это можно сделать, отправив в основную очередь и изменив некоторое состояние. Тем не менее, я думаю, что у uiViewController может еще не быть своего parent.

SlimeBaron 05.02.2021 22:42

Давайте продолжить обсуждение в чате.

Jumhyn 05.02.2021 22:53

Начиная с iOS 14, вы можете использовать .fullScreenCover(isPresented:, content:) (Документы) вместо .sheet(isPresented:, content:), если вам не нужны жесты отклонения.

struct FullScreenCoverPresenterView: View {
    @State private var isPresenting = false

    var body: some View {
        Button("Present Full-Screen Cover") {
            isPresenting.toggle()
        }
        .fullScreenCover(isPresented: $isPresenting) {
            Text("Tap to Dismiss")
                .onTapGesture {
                    isPresenting.toggle()
                }
        }
    }
}

Примечание: fullScreenCover недоступен на macOS, но хорошо работает на iPhone и iPad.

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

Это также представляет собой использование другого пользовательского интерфейса — как следует из названия, он имитирует модальный стиль представления fullScreen и не дает вам модального стиля «карточки» из iOS 13.

Jumhyn 12.02.2021 19:19

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

SlimeBaron 12.02.2021 19:27

Конечно, просто хотел убедиться, что было упомянуто, что это не решение точной проблемы, указанной в вопросе, чтобы любой, кто использует этот ответ, не удивился, когда получил другой внешний вид! :)

Jumhyn 12.02.2021 19:30

iOS 15

Начиная с iOS 15 мы можем использовать interactiveDismissDisabled:

func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View

Нам просто нужно прикрепить его к листу:

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

При необходимости вы также можете передать переменную, чтобы контролировать, когда лист можно отключить:

.interactiveDismissDisabled(!userAcceptedTermsOfUse)

Круто, рад видеть, что это включено в новое обновление! Есть ли API для выполнения действия, когда пользователь пытается закрыть?

Jumhyn 09.06.2021 00:33

@Jumhyn Нет, я ничего не нашел в документах. Тем не менее, это шаг вперед.

pawello2222 09.06.2021 00:44

Для всех, у кого есть проблемы с решением @Guido и NavigationView. Просто объедините решение @Guido и @SlimeBaron

class ModalHostingController<Content: View>: UIHostingController<Content>, UIAdaptivePresentationControllerDelegate {
    var canDismissSheet = true
    var onDismissalAttempt: (() -> ())?

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)

        parent?.presentationController?.delegate = self
    }

    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
        canDismissSheet
    }

    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
        onDismissalAttempt?()
    }
}

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let canDismissSheet: Bool
    let onDismissalAttempt: (() -> ())?

    func makeUIViewController(context: Context) -> ModalHostingController<T> {
        let controller = ModalHostingController(rootView: view)

        controller.canDismissSheet = canDismissSheet
        controller.onDismissalAttempt = onDismissalAttempt

        return controller
    }

    func updateUIViewController(_ uiViewController: ModalHostingController<T>, context: Context) {
        uiViewController.rootView = view

        uiViewController.canDismissSheet = canDismissSheet
        uiViewController.onDismissalAttempt = onDismissalAttempt
    }
}

extension View {
    func interactiveDismiss(canDismissSheet: Bool, onDismissalAttempt: (() -> ())? = nil) -> some View {
        ModalView(
            view: self,
            canDismissSheet: canDismissSheet,
            onDismissalAttempt: onDismissalAttempt
        ).edgesIgnoringSafeArea(.all)
    }
}

Применение:

struct ContentView: View {
    @State var isPresented = false
    @State var canDismissSheet = false

    var body: some View {
        Button("Tap me") {
            isPresented = true
        }
        .sheet(
            isPresented: $isPresented,
            content: {
                NavigationView {
                    Text("Hello World")
                }
                .interactiveDismiss(canDismissSheet: canDismissSheet) {
                    print("attemptToDismissHandler")
                }
            }
        )
    }
}

Спасибо! Это работает и для других контейнеров SwiftUI, а не только для Form!

Bio-Matic 01.08.2021 08:46

Если вам нужно завершить presentationControllerDidDismiss, он отлично работает с решением выше. Спасибо!

nikolsky 10.10.2021 03:21

Если на вашу переменную для canDismissSheet ссылается другое представление и она может анимироваться, то анимация не работает, а interactiveDismissDisabled(_:) работает. Наверное, это из-за uiViewController.rootView = view.

Manabu 30.01.2022 23:26

Другие вопросы по теме