SwiftUI уроки (часть 10)
Работа с Navigation UI и Navigation Bar Customization
В большинстве приложений вы сталкивались с навигационным интерфейсом. Этот тип пользовательского интерфейса обычно состоит из панели навигации и списка данных, позволяя пользователям переходить к детальному представлению при нажатии на содержимое. В UIKit мы реализуем этот тип интерфейса с помощью UINavigationController. Для SwiftUI Apple создал NavigationView, в iOS 16 он известен как NavigationStack. В этой главе я проведу вас через реализацию навигационных представлений и покажу, как выполнять некоторые настройки. Как и обычно, мы будем работать над демо проектом, чтобы вы могли получить практический опыт работы с NavigationStack.
Подготовка стартового проекта
Начнем с демонстрационного проекта который мы делали в предыдущем уроке, если вы еще его не делали - придется вернуться туда, в том числе для того чтобы погрузиться в контекст табличных представлений. Я надеюсь Вы хорошо знакомы с этим приложением, но если вы что-то забыли, то все же я настаиваю чтобы вы еще раз прошли предыдущий урок. Сейчас вы должны увидеть это.
В прошлый раз мы остановились именно тут и сейчас мы с вами собираемся встроить эту таблицу в навигационный стек.
Реализация Navigation Stack
До iOS 16 SwiftUI предоставлял нам такой вью как NavigationView для создания навигации. Чтобы встроить нашу таблицу в NavigationView, все что нам нужно сделать, это обернуть List с помощью NavigationView следующим образом:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationView {
List(rockGroups) { group in
RockCell(group: group)
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
}
}
}
Обратите внимание, я убрал у таблицы возможность игнорировать safe area, нам это потребуется в последствии, чтобы мы могли задать название нашей панели навигации.
Итак, как я и говорил ранее в IOS 16 у нас появился такой объект как NavigationStack и NavigationView уже имеет информационную приписку о том, что в последствии он будет deprecated, но если вы все еще на предыдущих версиях оси, то я должен был вам показать его. Теперь давайте все же заменим NavigationView на NavigationStack и будем двигаться далее.
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
RockCell(group: group)
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
}
}
}
Как вы уже наверное заметили, внешне ничего не изменилось, но при этом мы используем модернизированую версию навигации, чем ту что предоставляли Apple нам ранее, давайте наконец зададим название для нашей навигационной панели, сделать это достаточно просто с помощью модификатора .navigationTitle
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
RockCell(group: group)
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
.navigationTitle("Рок Группы")
}
}
}
Теперь наше приложение на этой странице имеет титульное название, которое по умолчанию имеет большой стиль, при скролле таблицы оно будет уменьшено - попробуйте поскролить таблицу
Передача данных в DetailView с помощью NavigationLink
Итак, мы добавили навигацию в проект, но не используем ее так как и задумано, для того чтобы понять ее возможности, давайте создадим простенький экран формата детального отображения наших рок групп. Это будет просто экран в котором есть большая картинка рок группы и ее название, разумеется в обычном приложении - вы бы предоставляли пользователю действительно детальную информацию по его выбору, но здесь у нас другие цели обучения.
Создайте новый SwiftUI View файл и назовите его RockGroupView и добавьте туда следующий код:
struct RockGroupView: View {
var rockGroup: RockGroup
var body: some View {
VStack {
Image(rockGroup.groupImageName)
.resizable()
.aspectRatio(contentMode: .fit)
Text(rockGroup.groupName)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Spacer()
}
}
}
#Preview {
RockGroupView(rockGroup: RockGroupData.data.first!)
}
Прежде чем мы будем двигаться далее, давайте я объясню что мы тут сделали
В структуре мы определили свойство rockGroup
типа RockGroup
, которое представляет модель данных рок-группы и его мы используем чтобы впоследствии отображать именно ту группу которую выберет наш пользователь.
В теле структуры (var body: some View
) мы используем контейнер VStack
, который располагает свои дочерние элементы вертикально а внутри него находятся следующие элементы:
Image(rockGroup.groupImageName)
- создает изображение из имени файла, которое хранится в свойствеgroupImageName
объектаrockGroup
. Модификаторыresizable()
иaspectRatio(contentMode: .fit)
изменяют размер изображения так, чтобы оно подходило по ширине и высоте, сохраняя пропорции.Text(rockGroup.groupName)
- создает текст с именем группы, которое хранится в свойствеgroupName
объектаrockGroup
. Модификаторыfont(.system(.title, design: .rounded))
иfontWeight(.black)
устанавливают шрифт заголовка с округлым дизайном и жирным начертанием.Spacer()
- добавляет промежуток, который заполняет оставшееся пространство между текстом и нижней частью контейнераVStack
.
В конце кода - в превью (#Preview
) для структуры RockGroupView
. Мы используем первый элемент из массива RockGroupData.data
для отображения примера интерфейса в редакторе SwiftUI.
Отлично, знания освежили - теперь давайте переходить к самому интересному. Для того чтобы передать какие то данные вместе с перемещением используя навигацию, мы можем воспользоваться такой сущностью как NavigationLink, у него есть такой параметр как destination в который мы и передаем ту вью в которую мы хотим перейти. Ну а в само тело этого NavigationLink мы можем поместить тот View который и станет нашей ссылкой для навигации.
NavigationLink(destination: DetailView()) {
Text("Я похож на гиперссылку")
}
Например этот код создал бы текст, по нажатию на который могли бы перейти на некий DetailView. Но давайте перейдем от абстракции к реальным примерам. Обновите свой код в ContentView следующим образом:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
NavigationLink(destination: RockGroupView(rockGroup: group)) {
RockCell(group: group)
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
}
.listStyle(.plain)
.navigationTitle("Рок Группы")
}
}
}
Поздравляю, вы только что создали интерактивные ячейки, по нажатию на которые вы будете переходить на экран с детальной информацией, причем именно о той группе которую вы выберете!
Обратите внимание на сам NavigationLink в атрибут destination мы передали RockGroupView который создали ранее и передали в его инициализатор ту самую группу которая связана с ячейкой которую в последствии и нажмет пользователь. При этом в тело NavigationLink мы поместили именно наши ячейки - поэтому они и стали интерактивными.
Самые внимательные наверное уже задались вопросом, а что случилось с нашими ячейками? Ведь они должны быть красными!
Проблема заключается в том, что модификатор listRowBackground
как и другие модификаторы применяются к ячейке RockCell
- которая находится внутри NavigationLink
. Когда вы нажимаете на ячейку, NavigationLink
становится активным, и цвет фона ячейки перестает быть видимым, потому что он заменяется цветом фона активного NavigationLink
.
Чтобы решить эту проблему, вы можете перенести модификаторы на NavigationLink
. Измените ваш код следующим образом:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
NavigationLink(destination: RockGroupView(rockGroup: group)) {
RockCell(group: group)
}
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
.navigationTitle("Рок Группы")
}
}
}
Кастомизируем Navigation Bar
Для начала давайте разберемся с отображением заглавного текста для нашей навигационной панели, как вы уже поняли по дефолту нам предоставляется текст большого размера, но если мы хотим чтобы изначально мы могли видеть наш текст в другом виде мы можем воспользоваться следующим модификатором.
.navigationBarTitleDisplayMode(.inline)
Давайте добавим его в наш код:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
NavigationLink(destination: RockGroupView(rockGroup: group)) {
RockCell(group: group)
}
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Рок Группы")
}
}
}
В дефолтном состоянии этот модификатор работает как .automatic что и дает нам большой размер, но вы можете пользоваться .inline если вам нужен компактный вариант
Давай вернемся опять к большому варианту установив:
.navigationBarTitleDisplayMode(.automatic)
На текущий момент времени Apple так и не предоставили нам легкого способа кастомизировать наши навигационные панели, однако уверен рано или поздно у них дойдут руки и до этого инструмента, а пока нам придется пользоваться силами UIKit
Давайте добавим следующий код внутрь нашего ContentView
init() {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 35) ?? UIFont()]
navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 20) ?? UIFont()]
UINavigationBar.appearance().standardAppearance = navBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
UINavigationBar.appearance().compactAppearance = navBarAppearance
}
В этом коде мы настраиваем внешний вид панели навигации. Этот код выполняется в методе init()
когда загружается наша View
В первой строке кода мы создаем экземпляр класса UINavigationBarAppearance
. Этот класс используется для настройки внешнего вида панели навигации в вашем приложении.
В следующих двух строках кода мы настраиваем текстовые атрибуты для большого заголовка и обычного заголовка. Мы устанавливаем цвет текста в красный и шрифт в "AmericanTypewriter-CondensedBold" с размерами 35 и 20 соответственно. Если шрифт "AmericanTypewriter-CondensedBold" недоступен (но он разумеется будет доступен, просто apple заставляют нас обезопасить инициализатор из-за того что мы могли ошибиться и прописать несуществующий шрифт), мы используем стандартный шрифт.
В последних трех строках кода мы применяем настроенный внешний вид к стандартному, к виду при прокрутке и компактному внешнему виду панели навигации. Мы делаем это с помощью свойств standardAppearance
, scrollEdgeAppearance
и compactAppearance
соответственно. Эти свойства доступны через класс UINavigationBar
, и мы используем метод appearance()
, чтобы получить доступ к общим параметрам внешнего вида для всех панелей навигации в приложении.
В итоге, этот код настраивает внешний вид панели навигации в нашем приложении, изменяя цвет и шрифт заголовков и применяя эти изменения ко всем панелям навигации в приложении.
Кнопка назад и ее цвет
Когда мы переходим с помощью навигации на следующий экран, то мы видим кнопку которая может отправить нас к тому экрану который привел на сюда
По дефолту эта кнопка всегда синего цвета и имеет шеврон смотрящий влево, но на самом деле вы можете поменять как картинку на другую, так и собственно сам цвет.
Давайте начнем с шеврона, добавьте этот код в наш инициализатор:
navBarAppearance.setBackIndicatorImage(UIImage(systemName: "arrow.uturn.left"), transitionMaskImage: UIImage(systemName: "arrow.uturn.left"))
Отлично, а теперь давайте еще и заменим цвет, например также на красный, добавьте этот модификатор к вашему NavigationStack
.accentColor(.red)
Весь код должен выглядеть так:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
NavigationLink(destination: RockGroupView(rockGroup: group)) {
RockCell(group: group)
}
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.automatic)
.navigationTitle("Рок Группы")
}
.accentColor(.red)
}
init() {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 35) ?? UIFont()]
navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 20) ?? UIFont()]
navBarAppearance.setBackIndicatorImage(UIImage(systemName: "arrow.uturn.left"), transitionMaskImage: UIImage(systemName: "arrow.uturn.left"))
UINavigationBar.appearance().standardAppearance = navBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
UINavigationBar.appearance().compactAppearance = navBarAppearance
}
}
И у нас получилось следующее:
Неплохо да? Но давайте продвинемся еще дальше и попробуем кастомизировать панель еще сильнее.
Кастомная кнопка назад
Вместо только чтобы пользоваться инструментарием данным нам UIKit мы можем взять в свои руки другой мощный инструмент - .toolbar
С его помощью мы можем фактически сделать нашу кнопку настолько кастомной - насколько вы вообще можете кастомизировать любую кнопку.
Прежде чем воспользоваться этим самым тулбаром, нам нужно убрать нашу дефотлную кнопку назад
Давайте перейдем в наш RockGroupView.swift и обновим код следующим образом:
struct RockGroupView: View {
var rockGroup: RockGroup
var body: some View {
VStack {
Image(rockGroup.groupImageName)
.resizable()
.aspectRatio(contentMode: .fit)
Text(rockGroup.groupName)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Spacer()
}
.navigationBarBackButtonHidden(true)
}
}
Мы только что убрали кнопку назад и теперь если перейти на этот экран из начального экрана, то это будет тупиковый сценарий причем с перерезанным путем назад - давайте это исправлять.
Добавим следующий код в наш RockGroupView сразу после модификатора скрывающего нашу дефолтную кнопку назад:
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "laser.burst")
}
}
}
По какой-то причине Xcode ругается что он не знает никаких методов назад, сейчас мы это исправим и я вам все объясню, добавьте этот фрагмент над rockGroup: RockGroup
@Environment(\.dismiss) var dismiss
Что вообще за Environment такой? Environment
- это особый тип свойства, который позволяет вам передавать данные между представлениями без необходимости явно передавать их через параметры. Это позволяет избежать создания длинных цепочек параметров между представлениями, что делает код более читабельным и легко поддерживаемым.
В SwiftUI есть множество встроенных свойств окружения (environment properties), которые вы можете использовать в ваших представлениях. Например, свойство colorScheme
позволяет вам определить текущую схему цветов (светлую или темную), свойство timeZone
позволяет узнать текущую временную зону, а свойство managedObjectContext
предоставляет доступ к контексту Core Data, но на самом деле этих свойств там очень много, рекомендую ознакомиться с ними отдельно.
Собственно .dismiss открыл нам доступ к методу dismiss() который позволяет нам закрыть текущий экран, вызываем мы его как вы поняли при нажатии на нашу кнопку в тулбаре.
Давайте разберем тулбар пошагово:
.toolbar { ... }
- это модификатор, который добавляет панель инструментов к представлению. Внутри модификатора мы указываем содержимое панели инструментов.ToolbarItem(placement: .topBarLeading) { ... }
- это контейнер, который указывает, где на панели инструментов должна быть расположена кнопка. В этом случае мы указываем, что кнопка должна быть расположена слева в верхней части панели инструментов.Button { ... } label: { ... }
- это кнопка, которая будет отображаться на панели инструментов. Первый блок кода ({ ... }
) указывает действие, которое должно быть выполнено при нажатии на кнопку, а второй блок кода (label: { ... }
) указывает метку кнопки.dismiss()
- это действие, которое вызывается при нажатии на кнопку. В этом случае мы вызываем функциюdismiss()
, которая закрывает текущее представление.Image(systemName: "laser.burst")
- это метка кнопки, которая отображается на панели инструментов. В этом случае мы указываем, что метка должна быть изображением системного имени "laser.burst".На самом деле как вы понимаете в теле тулбара может быть все что только вам захочется, хоть картинка, хоть текст, хоть текст и картинка (попробуйте здесь поэкспериментировать)
Итоги
Навигация, это важный элемент вашего приложения, без нее вы не сможете построить приложение которое будет вести вашего пользователя по экранам которые содержат фичи разрабатываемые вами или вашей командой, экспериментируйте и пробуйте добавлять различные кнопки в навигационную панель, попробуйте сделать детальную вью в которой например есть ScrollView вместо таблицы и поместите в ней сверху картинку которая будет сжиматься если Scroll свайпнули вверх и наоборот увеличиваться если его свайпнули вниз (довольно частый сценарий во многих приложениях).
Кстати говоря добиться такого или схожего эффекта можно и без участия навигационной панели (можно ее вообще скрыть)
One More Thing
Для примера оставлю вам расширение для нашего приложения которое покажет вам эффект с рястягивающимся хидером в детальной вьюшке.
Обновите ваш ContenView следующим образом:
struct ContentView: View {
var rockGroups = RockGroupData.data
var body: some View {
NavigationStack {
List(rockGroups) { group in
NavigationLink(destination: RockParalax(imageName: group.groupImageName)) {
RockCell(group: group)
}
.listRowBackground(Color.red)
.listRowSeparatorTint(.blue)
.listRowInsets(EdgeInsets(top: 20, leading: 50, bottom: 20, trailing: 50))
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.automatic)
.navigationTitle("Рок Группы")
}
.accentColor(.red)
}
init() {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 35) ?? UIFont()]
navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.red, .font: UIFont(name: "AmericanTypewriter-CondensedBold", size: 20) ?? UIFont()]
navBarAppearance.setBackIndicatorImage(UIImage(systemName: "arrow.uturn.left"), transitionMaskImage: UIImage(systemName: "arrow.uturn.left"))
UINavigationBar.appearance().standardAppearance = navBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
UINavigationBar.appearance().compactAppearance = navBarAppearance
}
}
#Preview {
ContentView()
}
struct RockCell: View {
var group: RockGroup
var body: some View {
HStack {
Image(group.groupImageName)
.resizable()
.frame(width: 180, height: 180)
Text("\(group.groupName)")
.font(.system(.title3))
.bold()
}
}
}
Создайте файл RockParalax и обновите его код следующим образом:
import SwiftUI
struct RockParalax: View {
@Environment(\.dismiss) var dismiss
var imageName: String
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 15, pinnedViews: [.sectionHeaders], content: {
GeometryReader { reader -> AnyView in
let offset = reader.frame(in: .global).minY
return AnyView(
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width, height: 250 + (offset > 0 ? offset : 0))
.cornerRadius(2)
.offset(y: (offset > 0 ? -offset : 0))
.overlay(
HStack {
Button(action: { dismiss() }, label: {
Image(systemName: "arrow.left")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.red)
})
Spacer()
Button(action: {}, label: {
Image(systemName: "suit.heart.fill")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.red)
})
}
.padding(), alignment: .top
)
)
}
.frame(height: 250)
Section() {
ForEach(1..<70) { index in
Text("\(index)")
.font(.title)
.foregroundColor(.black)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(10)
}
}
}
)
}
.navigationBarHidden(true)
}
}
struct RockParalax_Previews: PreviewProvider {
static var previews: some View {
RockParalax(imageName: "The Jimi Hendrix")
}
}
Постарайтесь самостоятельно разобраться как так получилось, что теперь наше изображение реагирует на свайп таблицы увеличиваясь когда мы свайпаем вниз и уменьшается когда мы отпускаем таблицу, и что еще интереснее как так получилось что у нас как будто есть навигационная панель при этом она же - .navigationBarHidden(true)
А на этом все, скоро выйдут следующие части уроков по SwiftUI, поэтому рекомендую попрактиковаться с тем что изучили сегодня и приходите снова за новой порцией знаний по SwiftUI.
Как и прежде подписывайтесь на мой телеграм канал - https://t.me/swiftexplorer
Буду рад вашим комментариям и лайкам!
Спасибо за прочтение!