Работаем с модальными вьюшками и алертами
Ранее мы создали приложение в котором пользователь мог с помощью навигационной панели переходить из экрана в котором отображается список с информацией на экран содержащий уже детальную информацию и такой паттерн навигации весьма распространен среди приложений и является одним из часто повторяющихся, однако сегодня я покажу вам еще один встречающийся паттерн, когда пользователь видит поднимающийся модальный экран.
На самом деле как пользователи айфонов вы должны быть знакомы с этим видом вьюшек, например когда вы пользуетесь календарем и нажимаете на создание нового события или нажимаете там на кнопку входящие, вот это как раз яркий пример полного модального окна, хотя есть и другие варианты, давайте посмотрим детально - как нам добиться такого же эффекта.
Для пользователя это довольно типичный вид перехода на экран и обычно он тригерится по нажатию на какую-то кнопку, чтобы например ввести какие-то данные и затем вернуться обратно на начальный экран. Причем модальный экран перекрывает начальный и таким образом блокирует взаимодействие с ним, как бы переключая фокус внимания на то что сейчас важнее для пользователя.
Этот сценарий очень удобен когда нужно заполнить какие-то данные для калькуляции или сохранения (как например в приложении контакты), а может быть для просмотра какой-то короткой, но более детальной информации (вы наверняка видели эти маленькие карточки со сведениями о юр. лице когда пользовались приложениями маркетплейсов).
Причем закрыть такой модальный экран достаточно просто, можно либо свайпнуть вниз, что к слову кажется весьма интуитивно понятным, либо нажать на кнопку назад если разработчик ее предоставит.
Итак давайте же разберемся с тем как нам самим доставить такие модальные экраны
Узнаем о Sheet в SwiftUI
Модальность - это метод проектирования, который представляет контент в отдельном, выделенном режиме, который препятствует взаимодействию с родительским представлением и требует явного действия для выхода. - Документация Apple о модальности
Прежде чем мы начнем самостоятельно строить свои интерфейсы давайте посмотрим на то, каким образом вообще можно вызвать модальное окно, в SwiftUI для этих целей предполагается специальный модификатор .sheet который по какому либо событию запустит сценарий отображения модального экрана и сделает это с анимацией и теми данными которые вы передадите в новое модальное окно.
Базовые примеры
Такой вариант ожидает от вас Bool свойство которое мы можем менять где-то в коде например по нажатию на какую нибудь кнопку.
.sheet(isPresented: $showModal) {
ModalView()
}
Такой вариант ожидает от вас некое Binding свойство опционального характера, если такое свойство будет не nil, то карточка также отобразится.
.sheet(item: $itemToDisplay) {
ModalView()
}
Подготовка проекта
Давайте начнем с того, что вы скачаете и распакуете проект который я для вас подготовил
Ссылка на проект
Давайте с ним познакомимся, на самом деле он довольно простой, но имеет заготовки - чтобы вам не пришлось тратить время на их создание во время этого туториала.
В контент вью мы видим следующее
Давайте разберем его по частям:
struct ContentView: View
: Наш основной экран в котором мы будем отображать наши статьи.var body: some View
: Свойствоbody
возвращает нам собственно ту вью которую мы и будем отображать на нашем экране.NavigationStack
: Это контейнер, который управляет навигацией между видами. В этом случае он содержит список статей. (обратите внимание, что у нас тут здесь нет никаких NavigationLink)List(articles) { article in ArticleRow(article: article) }
: Это таблица или список (List
), который генерируется из массива статей (articles
). Для каждой статьи в массиве создается строка списка (ArticleRow
)..listRowSeparator(.hidden)
: Этот модификатор списка, скрывает разделители строк..listStyle(.plain)
: Этот модификатор списка, устанавливает стиль списка в "plain" (простой)..navigationTitle("Статьи")
: Это модификатор навигационного стека, который устанавливает заголовок экрана.struct ArticleRow: View
: Это определение структурыArticleRow
, которая представляет собой строку списка для статьи.Внутри
ArticleRow
,VStack
используется для вертикального расположения элементов, таких как изображение, заголовок, автор, рейтинг и краткое описание статьи.Image(article.image)
: Это изображение, которое отображается для статьи.Text(article.title)
: Это заголовок статьи.Text("Автор (article.author)".uppercased())
: Это имя автора статьи.HStack
иForEach
используются для отображения рейтинга статьи в виде звезд.Text(article.excerpt)
: Это краткое описание статьи.
Теперь посмотрим на ArticleDetailView
struct ArticleDetailView: View
: Это вью которая будет отображать уже детальную информацию из статьиvar article: Article
: Это свойство, которое хранит статью, которую мы хотим отобразить.var body: some View
: Свойствоbody
возвращает нам ту вью которую мы и будем отображать на нашем экране.ScrollView
: Это контейнер, который позволяет прокручивать содержимое, если оно не помещается на экране.VStack
: Контейнер, с помощью которого мы располагаем элементы вертикально.Image(article.image)
: Это изображение, которое используется в статье.Group
: Это контейнер, который группирует элементы вместе. В этом случае он используется для применения общих модификаторов к заголовку и имени автора.Text(article.title)
: Это заголовок статьи.Text("Автор (article.author)".uppercased())
: Это имя автора статьи.Text(article.content)
: Это содержимое статьи..font(.body)
,.padding()
,.lineLimit(1000)
,.multilineTextAlignment(.leading)
: Это модификаторы текста, которые устанавливают шрифт, отступ, максимальное количество строк и выравнивание текста..ignoresSafeArea(.all, edges: .top)
: Это модификатор, который игнорирует безопасную зону вокруг экрана. В этом случае он используется для того, чтобы изображение могло заходить за верхний край экрана.
И наконец посмотрим на саму статью и заготовленный массив статей в файле Article
struct Article: Identifiable
: Это определение структурыArticle
, которая соответствует протоколуIdentifiable
. ПротоколIdentifiable
требует, чтобы структура имела свойствоid
, которое уникально идентифицирует экземпляр.var id = UUID()
: Это свойство, которое генерирует уникальный идентификатор для каждого экземпляраArticle
.var title: String
,var author: String
,var rating: Int
,var excerpt: String
,var image: String
,var content: String
: Это свойства структурыArticle
, которые хранят заголовок, автора, рейтинг, краткое описание, изображение и содержимое статьи.let articles = [...]
: Это определение массиваarticles
, содержащего экземплярыArticle
. (да это ужасная глобальная константа, но в рамках обучения совсем другой теме - пойдет)
Реализуем Модальную вью с помощью isPresented
Модификатор sheet
предоставляет нам два способа представления модального View. Давайте начнем с показа его с помощью isPresented. Для этого подхода нам нужна переменная состояния типа Bool, чтобы отслеживать статус модального View.
Объявите такую переменную в ContentView:
@State var showDetailView = false
По умолчанию установлено значение false
. Мы будем устанавливать значение этой переменной в true
при нажатии на одну из строк. Позже мы внесем это изменение в код.
Для того чтобы показать верную детальную информацию, нужно передать верные данные для показа, в нашем случае это статья. Раз мы будем ее передавать, то давайте создадим и такую переменную:
@State var selectedArticle: Article?
И наконец чтобы создать модальное представление, давайте добавим модификатор sheet
к нашему List
, весь код в ContentView
будет выглядеть следующим образом.
struct ContentView: View {
@State var showDetailView = false
@State var selectedArticle: Article?
var body: some View {
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.sheet(isPresented: $showDetailView) {
if let selectedArticle = selectedArticle {
ArticleDetailView(article: selectedArticle)
}
}
.navigationTitle("Статьи")
}
}
}
Итак наш sheet
будет работать в случае если showDetailView
будет true, в его комплишн блоке с работает проверка на то, имеется ли выбранная статья и если она есть - то мы отдаем View - ArticleDetailView
Давайте научим наш List
, а если быть точнее то чтобы его ячейки умели передавать выбранную статью в selectedArticle
, сделать это можно в onTapGesture
блоке. Давайте добавим его в качестве модификатора к ArticleRow
и сразу туда добавим туда информацию для showDetailView
и selectedArticle
.
struct ContentView: View {
@State var showDetailView = false
@State var selectedArticle: Article?
var body: some View {
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.listRowSeparator(.hidden)
.onTapGesture {
selectedArticle = article
showDetailView = true
}
}
.listStyle(.plain)
.sheet(isPresented: $showDetailView) {
if let selectedArticle = selectedArticle {
ArticleDetailView(article: selectedArticle)
}
}
.navigationTitle("Статьи")
}
}
}
Теперь в нашем клоужере мы выставляем ту самую статью которую мы выбрали и сообщаем о том что наше свойство showDetailView теперь отдает true
Вы можете запустить приложение и проверить как оно работает, вообще всё должно работать отлично, но есть один баг который мешает нормальную отображению детальной вью, мы поправим это позже. Но вообще, если вы сейчас нажмете на какую нибудь ячейку, а потом смахнет этот пустой модальный вид и нажмете на другую ячейку, то все отобразиться как мы того и ожидали.
Делаем Modal View с помощью опциональной привязки
Наш модификатор sheet
как вы наверное уже заметили предлагает нам возможность показывать модальное окно еще один способом, вместо того чтобы отдавать ему Bool свойство, мы можем просто передавать в него опциональный объект который, если окажется не nil сразу вызовет модальное окно. Давайте заменим наш .sheet
модификатор следующим образом:
.sheet(item: $selectedArticle) { article in
ArticleDetailView(article: article)
}
Обратите внимание параметр называется item и именно в него мы передаем свойство selectedArticle. Как вы видите это свойство опциональное и не имеет дефолтного значения, но как только, нажимая на ячейку мы передаем в это свойство значения наше модальное окно взлетает, так и работает этот модификатор. Кстати, как вы понимаете, теперь мы можем избавиться от showDetailView везде где мы его добавили. Давайте удалим его везде где он встречается. Итоговый код должен выглядеть следующим образом:
struct ContentView: View {
@State var selectedArticle: Article?
var body: some View {
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.listRowSeparator(.hidden)
.onTapGesture {
selectedArticle = article
}
}
.listStyle(.plain)
.sheet(item: $selectedArticle) { article in
ArticleDetailView(article: article)
}
.navigationTitle("Статьи")
}
}
}
Можно вновь запустить приложение и попробовать посмотреть как всё работает.
Создаем кнопку для выхода из модального представления
Разумеется модальный экран имеет обработку swipe жеста, а именно смахивание экрана вниз для его закрытия и вам может показаться - зачем тогда нам кнопка для закрытия?
На этот счет я считаю важным для каждого разработчика ознакомиться с документами HIG от Apple. В них наиболее подробно разобраны сценарии поведения пользователя и лучшие примеры того как сделать ваш интерфейс наиболее удобным для пользователя. Приведу лишь пару доводов которые я отметил для себя относительно такой кнопки для выхода из модального представления.
Есть часть пользователей которым банально просто удобнее пользоваться визуальными интерфейсами вроде кнопок, тумблеров и переключателей, потому что они ассоциируют определенные действия именно с такими переключателями - соответственно лишать их такой возможности было бы очень опрометчиво
Есть часть пользователей которые пришли на айфон с андроида, где наличие таких кнопок повсюду является абсолютной нормой и попав на такой экран без кнопок это привело бы их к заблуждению и возможно негативному отзыву в AppStore на ваше приложение - думаю этого вы хотите меньше всего.
Есть часть пользователей которые специально покупают маленькие айфоны, вроде iPhone SE или если помните не так давно выходивший iPhone Mini, такие люди любят дотягиваться пальцем до любой точки экрана, поэтому для них вариант с кнопкой наиболее приемлем.
Итак с необходимостью кнопки разобрались, так давайте же ее сделаем.
Давайте перейдем к файлу ArticleDetailView
и реализуем здесь кнопку, кстати будет здорово если вы попробуете сделать это сами, а потом вернетесь обратно к статье чтобы сверить насколько вы оказались близки, либо если не получается то давайте делать это вместе.
Мы хотим разместить кнопку в правом верхнем углу, наша кнопка будет закрывать наше модальное окно, а это значит что мы вновь обратимся к Environment
давайте добавим свойство dismiss
struct ArticleDetailView: View {
@Environment(\.dismiss) var dismiss
Теперь давайте мы создадим саму кнопку на экране, ее можно разместить на самом ScrollView и использовать для этого .overlay чтобы отобразить кнопку поверх нашей ScrollView, давайте разместим следующий код буквально перед ignoresSafeArea
.overlay(
HStack {
Spacer()
VStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark.square.fill")
.font(.largeTitle)
.foregroundStyle(.red)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
Кажется что можно было бы прикрепить кнопку с иконкой непосредственно к картинке, но в целом это все равно плюс минус была бы такая же реализация, мы бы пользовались overlay и в нем размещали бы кнопку с картинкой.
Итоговый код должен выглядеть следующим образом:
struct ArticleDetailView: View {
@Environment(\.dismiss) var dismiss
var article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Image(article.image)
.resizable()
.aspectRatio(contentMode: .fit)
Group {
Text(article.title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.lineLimit(3)
Text("Автор \(article.author)".uppercased())
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.bottom, 0)
.padding(.horizontal)
Text(article.content)
.font(.body)
.padding()
.lineLimit(1000)
.multilineTextAlignment(.leading)
}
}
.overlay(
HStack {
Spacer()
VStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark.square.fill")
.font(.largeTitle)
.foregroundStyle(.red)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
.ignoresSafeArea(.all, edges: .top)
}
}
Поздравляю у ваc получилось создать свой первый модальный экран и даже создать для него кнопку выхода, хочу лишь сказать что помимо модального экрана через .sheet вы можете создать такой экран который займет весь экран и его невозможно будет закрыть свайпом вниз, для этого сценария как вы понимаете кнопка выхода уже обязательна. Чтобы добавиться такого эффекта просто замените в ContentView .sheet на .fullScreenCover
Код должен выглядеть следующим образом:
struct ContentView: View {
@State var selectedArticle: Article?
var body: some View {
NavigationStack {
List(articles) { article in
ArticleRow(article: article)
.listRowSeparator(.hidden)
.onTapGesture {
selectedArticle = article
}
}
.listStyle(.plain)
.fullScreenCover(item: $selectedArticle) { article in
ArticleDetailView(article: article)
}
.navigationTitle("Статьи")
}
}
}
Запустите симулятор и попробуйте этот вариант перехода
Используем Alert
Помимо модальных вьюшек типа карт есть еще один модальный экран и это - Alert. Фактически он обладает точно такими же свойствами как и модальный, он тоже появляется на переднем плане во время отображения и тоже блокирует для взаимодействия то что находится под ним, а по завершению взаимодействия с ним вы возвращаетесь на первичный экран откуда был вызван такой Alert.
Я предлагаю создать такой Алерт в детальной вьюшке, будет показывать этот Алерт когда пользователь будет пытаться выйти с этого экрана.
Итак, разместите следующий код над строчкой где вы создавали overlay:
.alert("Подождите", isPresented: $showAlert, actions: {
Button {
dismiss()
} label: {
Text("Да, уверен")
}
Button(role: .cancel, action: {}) {
Text("Миссклик, буду читать дальше")
}
}, message: {
Text("Вы уже дочитали статью и хотите выйти?")
})
Мы создаем Алерт который будет показываться в случае если свойство showAlert окажется true, в самом Алерты мы отобразим две кнопки, одна из которых просто закроет его, вторая же вызовет dismiss() который закроет страницу полностью.
Разумеется вы уже поняли что для работы нам потребуется еще и создать свойство showAlert, давайте разместим его в теле ArticleDetailView
Итоговый код должен выглядеть следующим образом:
struct ArticleDetailView: View {
@Environment(\.dismiss) var dismiss
@State private var showAlert = false
var article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Image(article.image)
.resizable()
.aspectRatio(contentMode: .fit)
Group {
Text(article.title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.lineLimit(3)
Text("Автор \(article.author)".uppercased())
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.bottom, 0)
.padding(.horizontal)
Text(article.content)
.font(.body)
.padding()
.lineLimit(1000)
.multilineTextAlignment(.leading)
}
}
.alert("Подождите", isPresented: $showAlert, actions: {
Button {
dismiss()
} label: {
Text("Да, уверен")
}
Button(role: .cancel, action: {}) {
Text("Миссклик, буду читать дальше")
}
}, message: {
Text("Вы уже дочитали статью и хотите выйти?")
})
.overlay(
HStack {
Spacer()
VStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark.square.fill")
.font(.largeTitle)
.foregroundStyle(.red)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
.ignoresSafeArea(.all, edges: .top)
}
}
Теперь задачка для внимательных, на какой строчке есть проблема по которой наш alert так и не отобразится на экране?
Итак правильный ответ, проблема в том что мы нигде не присваиваем showAlert = true, в нашем случае место для этого в кнопке нашего оверлея, давайте поменяем одну строчку кода и получим итоговый результат:
struct ArticleDetailView: View {
@Environment(\.dismiss) var dismiss
@State private var showAlert = false
var article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Image(article.image)
.resizable()
.aspectRatio(contentMode: .fit)
Group {
Text(article.title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.lineLimit(3)
Text("Автор \(article.author)".uppercased())
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.bottom, 0)
.padding(.horizontal)
Text(article.content)
.font(.body)
.padding()
.lineLimit(1000)
.multilineTextAlignment(.leading)
}
}
.alert("Подождите", isPresented: $showAlert, actions: {
Button {
dismiss()
} label: {
Text("Да, уверен")
}
Button(role: .cancel, action: {}) {
Text("Миссклик, буду читать дальше")
}
}, message: {
Text("Вы уже дочитали статью и хотите выйти?")
})
.overlay(
HStack {
Spacer()
VStack {
Button {
showAlert = true
} label: {
Image(systemName: "xmark.square.fill")
.font(.largeTitle)
.foregroundStyle(.red)
}
.padding(.trailing, 20)
.padding(.top, 40)
Spacer()
}
}
)
.ignoresSafeArea(.all, edges: .top)
}
}
Теперь можете попробовать еще раз поиграть с итоговым приложением.
Итоги
Сегодня вы научились как показывать модальные окна, как карточного типа, так и типа всплывающих алертов. Вы также узнали что операционная система заботится как о нас разработчиках, так и о пользователях - предлагая из коробки обработку жестов вроде swipe to dismiss, ведь мы и строчки кода не написали чтобы такой функционал был, а он есть!
А на этом все, скоро выйдут следующие части уроков по SwiftUI, поэтому рекомендую попрактиковаться с тем что изучили сегодня и приходите снова за новой порцией знаний по SwiftUI.
Как и прежде подписывайтесь на мой телеграм канал - https://t.me/swiftexplorer
Буду рад вашим комментариям и лайкам!
Спасибо за прочтение!