На 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?





Изменяя 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()
}
}
}
}
есть проблема, хотя перетаскивание листа с двумя фигурами закроет его, также это решение не будет работать, если тело представления закрыто другим представлением (Loader, т.е. UIActivityIndicatorView).
@Aleyam Те, что вы упомянули, могут быть новыми вопросами (протягивая лист двумя пальцами), и я уверен, что для этого есть решения. Конечно, этот блок кода не будет работать везде, где вы его вставляете. Это просто, чтобы получить идеи.
да, я понял вашу точку зрения (идея этого ответа), дело в том, что вопрос заключается в том, чтобы «Предотвратить отклонение контроллера модального представления в SwiftUI», поэтому, если перетаскивание двумя пальцами отклоняет лист, кажется, что ответ неполный, а также реализация эта логика предотвращения увольнения становится кошмаром для комплексного просмотра.
Он закрывается не только при перетаскивании двумя пальцами, но и при перетаскивании любого другого представления внутри листа. Хотя это принятый ответ, должен быть лучший, менее хакерский способ сделать это.
Примечание. Этот код был отредактирован для ясности и краткости.
Используя способ получить текущую сцену окна из здесь, вы можете получить контроллер вида сверху с помощью этого расширения здесь из @ Бобдж-С.
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 Имеет ли какое-либо значение для вас редактирование с использованием его в качестве модификатора представления?
Скопировал ваше расширение и ViewModifier и использовал его для изменения содержимого моего листа. Это прекрасно работает! Отлично выглядит и работает без проблем.
Согласно приведенному ниже 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)
}
}
}
Я тоже хотел это сделать, но нигде не нашел решения. Ответ, который перехватывает жест перетаскивания, работает, но не тогда, когда он отклоняется путем прокрутки представления или формы прокрутки. Подход в вопросе также менее хакерский, поэтому я исследовал его дальше.
Для моего варианта использования у меня есть форма на листе, которую в идеале можно было бы закрыть, когда нет контента, но ее нужно подтверждать с помощью предупреждения, когда контент есть.
Мое решение этой проблемы:
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)
}
}
Это идеально подходит для моего случая использования, надеюсь, это поможет вам или кому-то еще.
Это способ сделать это. Ничего хакерского и очень элегантного. Спасибо, Гвидо.
Если вы используете это в SwiftUI и переменные состояния не обновляются, попробуйте применить модификатор .presentation к элементу на листе, который не требует обновления пользовательского интерфейса (например, Spacer(), Divider()).
ЛУЧШИЙ ответ на июль 2020 года!
Спасибо, что ответили на этот старый вопрос @GuidoHendriks. Я обновил принятый ответ, чтобы отразить тот факт, что это кажется «правильным» способом добиться этого. Я попытался погрузиться в представляемый UIViewController, но не возился с контроллером представления.
Предложения от благодарного читателя: isModal не должно быть привязкой, потому что она доступна только для чтения. Однако удаление @Binding нарушает это, потому что координатор будет хранить только начальное значение isModal. Чтобы исправить это, вы можете сделать modalView в координаторе var, а затем обновить его в updateUIViewController, как context.coordinator.modalView = self, чтобы он обновлялся правильно, если isModal изменится. Комментарий о том, что переменные состояния не обновляются, можно исправить, выполнив uiViewController.rootView = view в updateUIViewController, иначе представление не будет обновляться должным образом.
Использование PresentationMode.dismiss в представлении, которое я пытаюсь представить модально, больше не работает. Кажется, единственный способ закрыть — это провести пальцем вниз (когда isModal == false). У кого-нибудь еще есть эта проблема?
@jjatie Я не использую \.presentationMode, а использую привязку к переменной, которая делает лист видимым в этом случае .sheet(isPresented: $showSheet), поэтому поставьте @Binding var showSheet: Bool и закройте self.showSheet = false вместо self.presentationMode.wrappedValue.dismiss()
По какой-то странной причине это не работает с NavigationView или даже с простым текстом. Однако он работает с формой.
@Helam - твое исправление было именно тем, что мне было нужно. Вы сэкономили мне СОООООООчень много сил.
Кажется, это не работает для меня на iPad.
В дополнение к изменениям в комментарии @Helam мне также пришлось использовать подкласс UIHostingController, чтобы переопределить willMove(to: parent), чтобы установить родительский presentationController, потому что updateUIViewController не происходило до того, как я впервые попытался закрыть контроллер представления.
Есть ли способ сделать это, когда View не встроен в UIControllerRepresentable?
@guido-hendriks не хотели бы вы обновить этот ответ, упомянув новый API iOS 15 interactiveDismissDisabled(_:) вверху и оставить устаревший ответ для тех, кому необходимо поддерживать iOS 14 и ниже (или нужна функциональность onDismissalAttempt)?
Как упоминает @AbdalrahmanShatou, на моем iPad с iOS 14.5 это работает только так, как показано. Но если вы удалите форму или добавите что-либо, кроме кнопки внутри формы, лист может быть снова закрыт.
@vedosity Я думаю, что столкнулся с той же проблемой, что и вы. Что именно вы подразумеваете под «установкой родительского презентационного контроллера»? Как выглядит реализация willMove(to: parent)? Спасибо
Не работает вообще...
Мы создали расширение, упрощающее управление модальным закрытием, по адресу 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 над рассматриваемыми представлениями. Все еще ищу идеальный ответ :(
Да, это делает то же предположение, что и принятый ответ, что представлением представления управляет родительский контроллер хоста. Я согласен, что это не идеально.
Ха! Вы знаете, я действительно пропустил первый уход на второй круг, когда этот ответ попал в uiViewController.parent. Вы правы, что в этом плане он ничем не хуже. Я попробую оба из них на iPad и приму ваш ответ, если он в конечном итоге будет работать лучше :)
Принятый ответ работает для меня как на iPad, так и на iPhone. Какую проблему вы видели?
Хм. Мне удалось закрыть лист на iPad. Хотя, возможно, это была случайность. Я удалю этот комментарий из своего ответа. Спасибо, что проверили это.
Если вы можете воспроизвести в любой момент, я хотел бы услышать, как это сделать. Также может быть ошибка в SwiftUI... в любом случае, мне нравится ваше решение, потому что оно позволяет избежать необходимости определять объект Coordinator за счет необходимости использования пользовательского подкласса UIHostingController. Интересно, есть ли способ вызвать updateUIViewController сразу после презентации, чтобы избежать пользовательского подкласса...
Я думаю, что это можно сделать, отправив в основную очередь и изменив некоторое состояние. Тем не менее, я думаю, что у uiViewController может еще не быть своего parent.
Давайте продолжить обсуждение в чате.
Начиная с 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.
Да, это приводит к другому внешнему виду, который визуально указывает пользователю, что модальное окно нельзя закрыть с помощью жеста перетаскивания. Я думаю, что это, вероятно, желательно в большинстве случаев. Кроме того, это решение использует задокументированные конструкции SwiftUI для выполнения задачи.
Конечно, просто хотел убедиться, что было упомянуто, что это не решение точной проблемы, указанной в вопросе, чтобы любой, кто использует этот ответ, не удивился, когда получил другой внешний вид! :)
Начиная с 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 Нет, я ничего не нашел в документах. Тем не менее, это шаг вперед.
Для всех, у кого есть проблемы с решением @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!
Если вам нужно завершить presentationControllerDidDismiss, он отлично работает с решением выше. Спасибо!
Если на вашу переменную для canDismissSheet ссылается другое представление и она может анимироваться, то анимация не работает, а interactiveDismissDisabled(_:) работает. Наверное, это из-за uiViewController.rootView = view.
На данный момент это лучшее решение, которое я видел.