Один из лучших способов освоить SwiftUI (как и большинство технологий) — это реализовать что-то небольшое и полезное. Всплывающее окно (поп-ап) является отличным вариантом — это один из самых популярных инструментов интерфейса, который часто нужно кастомизировать, если нативный вариант не устраивает по виду или функционалу.
В этом туториале мы создадим минимальный вариант всплывающего окна на SwiftUI с нуля. В итоге получим рабочий компонент пользовательского интерфейса, который можно внедрить в свое SwiftUI приложение или продолжить его настраивать и улучшать. Вот как это будет выглядеть:
Выбор API
Сначала определимся с функциональностью поп-апа, который мы хотим реализовать. Это будет элемент, который отображается при нажатии на кнопку. В нем будет отображен просто лейбл. Он будет появляться и исчезать анимированно. Нам нужна возможность определять внешний вид и стиль поп-апа, добавлять его в стек интерфейса и контролировать его состояние.
Мы начнем с написания примера использования поп-апа, как будто он уже существует, а самой реализацией займёмся чуть позже. Такой подход позволит сразу понять, как будет удобнее использовать компонент в своем коде.
struct ContentView : View {
@State var showingPopup = false // 1
var body: some View {
ZStack {
Color.red.opacity(0.2)
Button("Push me") {
showingPopup = true // 2
}
}
.popup(isPresented: $showingPopup) { // 3
ZStack { // 4
Color.blue.frame(width: 200, height: 100)
Text("Popup!")
}
}
}
}
Вот что делает этот простой пример:
Мы добавляем всплывающее окно в качестве модификатора нашего элемента, передавая @Binding showingPopup для управления состоянием внутри реализации самого элемента.Переменная @Statevar showingPopup будет управлять отображением поп-апа.
Отдельная кнопка на экране будет изменять состояние переменной showingPopup.
Мы добавляем всплывающее окно в качестве модификатора нашего элемента, передавая @BindingshowingPopup для управления состоянием внутри реализации самого элемента.
Дизайн и содержимое всплывающего окна также передаются в качестве параметра.
Сам поп-ап реализован как ViewModifier, как принято в SwiftUI.
Теперь, когда у нас есть представление о желаемом интерфейсе и внешнем виде поп-апа, давайте приступим к фактической реализации.
Реализация View Modifier
extension View {
public func popup<PopupContent: View>(
isPresented: Binding<Bool>,
view: @escaping () -> PopupContent) -> some View {
self.modifier(
Popup(
isPresented: isPresented,
view: view)
)
}
}
Этот фрагмент кода не требует пояснений - это определение модификатора поп-апа для View. Мы уже знаем, что нам нужны два параметра — isPresented, который представляет собой Binding Property Wrapper для управления состоянием всплывающего окна, и view, который отвечает за внешний вид поп-апа.
Теперь мы можем приступить к самой интересной части.
Реализация всплывающего элемента
Инициализатор и публичные переменные понятны — они уже были определены и объяснены, когда мы выбирали API:
public struct Popup<PopupContent>: ViewModifier where PopupContent: View {
init(isPresented: Binding<Bool>,
view: @escaping () -> PopupContent) {
self._isPresented = isPresented
self.view = view
}
/// Controls if the sheet should be presented or not
@Binding var isPresented: Bool
/// The content to present
var view: () -> PopupContent
Список приватных переменных расскажет больше о деталях реализации — поп-ап будет отображаться и скрываться путем изменения оффсета view, которое его содержит. Этот оффсет легко посчитать и анимировать, и он подходит для всех размеров экрана, поскольку прямым образом от него зависит.
Приватные переменные будут содержать данные фрейма, необходимые для показа и скрытия поп-апа — фреймы родительского контроллера и содержимого поп-апа, оффсеты для скрытого и показанного состояния, а также вспомогательные методы для получения размера экрана.
// MARK: - Private Properties
/// The rect of the hosting controller
@State private var presenterContentRect: CGRect = .zero
/// The rect of popup content
@State private var sheetContentRect: CGRect = .zero
/// The offset when the popup is displayed
private var displayedOffset: CGFloat {
-presenterContentRect.midY + screenHeight/2
}
/// The offset when the popup is hidden
private var hiddenOffset: CGFloat {
if presenterContentRect.isEmpty {
return 1000
}
return screenHeight - presenterContentRect.midY + sheetContentRect.height/2 + 5
}
/// The current offset, based on the "presented" property
private var currentOffset: CGFloat {
return isPresented ? displayedOffset : hiddenOffset
}
private var screenWidth: CGFloat {
UIScreen.main.bounds.size.width
}
private var screenHeight: CGFloat {
UIScreen.main.bounds.size.height
}
Само UI-наполнение поп-апа минимально: мы считываем фрейм основного контента, затем добавляем sheet оверлея, содержащий поп-ап. Сам sheet делает практически то же самое — считывает свой фрейм для позиционирования видимого UI поп-апа, а также добавляет обработчик для удаления показанного поп-апа по нажатию и простую анимацию. Тут же используется ранее вычисленный currentOffset:
// MARK: - Content Builders
public func body(content: Content) -> some View {
ZStack {
content
.frameGetter($presenterContentRect)
}
.overlay(sheet())
}
func sheet() -> some View {
ZStack {
self.view()
.simultaneousGesture(
TapGesture().onEnded {
dismiss()
})
.frameGetter($sheetContentRect)
.frame(width: screenWidth)
.offset(x: 0, y: currentOffset)
.animation(Animation.easeOut(duration: 0.3), value:
currentOffset)
}
}
private func dismiss() {
isPresented = false
}
Чтобы реализовать простую анимацию в SwiftUI, достаточно добавить однострочный модификатор, что мы и делаем в конце создания sheet. Естественно, его можно анимировать как вам угодно — просто заменив итоговый размер и положение на экране, можно получить другой тип UI элемента (например, верхний или нижний тост).
Скорее всего, вы обратили внимание на модификатор frameGetter. Это некрасивый, но необходимый в SwiftUI метод получения фрейма (по крайней мере, судя по документации, более удобного способа получить фрейм в SwiftUI у нас нет). Надеемся, что в будущем появится более удобный способ:
extension View {
func frameGetter(_ frame: Binding<CGRect>) -> some View {
modifier(FrameGetter(frame: frame))
}
}
struct FrameGetter: ViewModifier {
@Binding var frame: CGRect
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy -> AnyView in
let rect = proxy.frame(in: .global)
// This avoids an infinite layout loop
if rect.integral != self.frame.integral {
DispatchQueue.main.async {
self.frame = rect
}
}
return AnyView(EmptyView())
})
}
}
GeometryReader — это view в SwiftUI, который в качестве параметра принимает координатное пространство и предоставляет информацию о своем размере содержимому (иными словами, как раз то, что нужно для получения данных о фрейме).
Заключение
Приведенный выше код — это все, что нам нужно для простой версии поп-апа, и большая его часть понятна и легко читается. Этот код может стать основой для более сложной реализации всплывающего окна, если вам нужно что-то более кастомное.
Можете также посмотреть на нашу библиотеку для поп-апа на SwiftUI. На самом деле, этот туториал — упрощенная версия кода в нашем репозитории. Он заметно более сложен из-за большего количества стилей для поп-апов, расширенных параметров и коллбэков, а также поддержкой нескольких платформ. Сам код отображения и добавления поп-апа идентичен приведённому выше. Библиотека предоставляет как простые поп-апы:
так и более сложные кастомные вью: