Как создать компонент Toast в SwiftUI

Вашему вниманию предлагается статья на тему SwiftUI. В ней мы рассмотрим быстрый и простой способ создания такого компонента SwiftUI, как всплывающее сообщение (англ. toast).

Здесь мы будем создавать этот компонент

Компонент Toast  —  это сообщение, которое появляется в нижней части интерфейса, не нарушая рабочий процесс. Оно обеспечивает моментальную обратную связь о результате выполнения действия. 

Всплывающие сообщения предназначены только для подтверждений, простых уведомлений и низкоприоритетных предупреждений, не прерывающих работу пользователя. 

1. Создание представления Toast 

Сначала создаем файл представления SwiftUI и называем его FancyToastView или как угодно. Это основное представление для всплывающего сообщения, которое мы показываем пользователю:

struct FancyToastView: View {
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .top) {
Image(systemName: "info.circle.fill")
.foregroundColor(Color.red)

VStack(alignment: .leading) {
Text("Error")
.font(.system(size: 14, weight: .semibold))

Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
.font(.system(size: 12))
.foregroundColor(Color.black.opacity(0.6))
}

Spacer(minLength: 10)

Button {
//TODO
} label: {
Image(systemName: "xmark")
.foregroundColor(Color.black)
}
}
.padding()
}
.background(Color.white)
.overlay(
Rectangle()
.fill(Color.red)
.frame(width: 6)
.clipped()
, alignment: .leading
)
.frame(minWidth: 0, maxWidth: .infinity)
.cornerRadius(8)
.shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 1)
.padding(.horizontal, 16)
}
}

Как видно по содержанию кода, всплывающее сообщение состоит из иконок, заголовков, сообщений и кнопок закрытия, расположенных горизонтально. Для иконок применяется SFSymbol, что снимает необходимость импорта активов (англ. assets) для выполнения кода. 

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

И просто, и красиво

А как же динамический контент во всплывающем сообщении? Об этом далее.

2. Создание стиля enum и модели всплывающего сообщения

Создадим 4 наиболее распространенных стиля оформления всплывающих сообщений: info (информация), error (ошибка), warning (предупреждение) и success (успешность выполнения). Они отличаются только иконками и цветом темы: 

enum FancyToastStyle {
case error
case warning
case success
case info
}

extension FancyToastStyle {
var themeColor: Color {
switch self {
case .error: return Color.red
case .warning: return Color.orange
case .info: return Color.blue
case .success: return Color.green
}
}

var iconFileName: String {
switch self {
case .info: return "info.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .success: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
}
}
}

Вот такой простой код. У нас 4 типа, для каждого из которых с помощью вычисляемого свойства получаем цвет темы и имя файла иконки.

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

struct FancyToast: Equatable {
var type: FancyToastStyle
var title: String
var message: String
var duration: Double = 3
}

Модель содержит: 1) ранее созданный тип enum, заголовок и содержимое сообщения, отображаемое во всплывающем окне; 2) срок действия duration, значение которого определяет длительность отображения сообщения и время его автоматического закрытия. В модели также используется Equatable для разграничения одного всплывающего сообщения от другого. 

3. Интеграция представлений с моделями 

Следующий этап  —  интеграция основного представления с моделью, что позволит сделать содержимое сообщения динамическим: 

struct FancyToastView: View {
var type: FancyToastStyle
var title: String
var message: String
var onCancelTapped: (() -> Void)
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .top) {
Image(systemName: type.iconFileName)
.foregroundColor(type.themeColor)

VStack(alignment: .leading) {
Text(title)
.font(.system(size: 14, weight: .semibold))

Text(message)
.font(.system(size: 12))
.foregroundColor(Color.black.opacity(0.6))
}

Spacer(minLength: 10)

Button {
onCancelTapped()
} label: {
Image(systemName: "xmark")
.foregroundColor(Color.black)
}
}
.padding()
}
.background(Color.white)
.overlay(
Rectangle()
.fill(type.themeColor)
.frame(width: 6)
.clipped()
, alignment: .leading
)
.frame(minWidth: 0, maxWidth: .infinity)
.cornerRadius(8)
.shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 1)
.padding(.horizontal, 16)
}
}

Этот код не сильно отличается от предыдущего. Жестко закодированное значение мы просто меняем на значение из модели. Обратите внимание на еще одно отличие: для действия кнопки отмены задействуется функция обратного вызова. Так мы получаем родительское представление, которое выполняет логику для закрытия всплывающего сообщения.

Попробуем выполнить код, вызвав представление следующим образом: 

struct FancyToastView_Previews: PreviewProvider {
static var previews: some View {
VStack {
FancyToastView(
type: .error,
title: "Error",
message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}

FancyToastView(
type: .info,
title: "Info",
message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") {}
}
}
}

Получаем вот такое представление:

На шаг ближе к цели…

4. Создание ViewModifier для показа всплывающего сообщения 

Всплывающие сообщения должны отображаться легко, бесперебойно и многократно, потому что они будут использоваться во многих представлениях приложения. Есть один сложный и утомительный вариант: создать ZStack и затем вручную разместить в каждом представлении всплывающее сообщение в представлении tp. Но мы пойдем другим путем и решим задачу с помощью ViewModifier!

struct FancyToastModifier: ViewModifier {
@Binding var toast: FancyToast?
@State private var workItem: DispatchWorkItem?

func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(
ZStack {
mainToastView()
.offset(y: -30)
}.animation(.spring(), value: toast)
)
.onChange(of: toast) { value in
showToast()
}
}

@ViewBuilder func mainToastView() -> some View {
if let toast = toast {
VStack {
Spacer()
FancyToastView(
type: toast.type,
title: toast.title,
message: toast.message) {
dismissToast()
}
}
.transition(.move(edge: .bottom))
}
}

private func showToast() {
guard let toast = toast else { return }

UIImpactFeedbackGenerator(style: .light).impactOccurred()

if toast.duration > 0 {
workItem?.cancel()

let task = DispatchWorkItem {
dismissToast()
}

workItem = task
DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration, execute: task)
}
}

private func dismissToast() {
withAnimation {
toast = nil
}

workItem?.cancel()
workItem = nil
}
}

Возможно, перед вами самый сложный и вызывающий вопросы код из всех ранее рассмотренных. Сейчас мы с ним разберемся. 

В этом модификаторе представления есть 2 переменные с разной оберткой свойств. 

Первая переменная  —  toast, модель всплывающего сообщения, которую мы привязываем к представлению Toast с помощью обертки свойства @Binding. Это позволяет менять значение изнутри, а также напрямую отражать изменение значения извне. 

Вторая переменная  —  workItem, представляющий собой DispatchWorkItem. Он закрывает всплывающее сообщение по истечении установленного времени его действия. 

Кроме того, у нас в наличии 2 функции представления и 2 логические функции. Нет смысла подробно объяснять код UI, так как наверняка все вы владеете основами SwiftUI. В вышеуказанном коде нет ничего сложного, только базовые знания.

В коде мы устанавливаем размер содержимого области сообщения равным бесконечности, что означает его соответствие экрану устройства. Компонент Toast будет появляться в нижней части экрана, как и планировалось. Далее применяем наложение и ZStack для размещения компонента Toast поверх содержимого. Кроме того, используем модификатор onChange для выполнения функции, отвечающей за отображение всплывающего сообщения. 

Что касается функции showToast, здесь тоже все предельно просто. Вызываем функцию UIImpactFeedbackGenerator, которая показывает всплывающее сообщение. После этого устанавливаем задачу workItem и выполняем ее по истечении заданного времени действия. 

С последней функцией dismissToast тоже все ясно. Устанавливаем nil для привязки всплывающего сообщения, потом отменяем и устанавливаем nil для workItem, чтобы предотвратить утечки памяти. 

Почти готово

На последнем этапе упрощаем и наводим красоту. С этой целью создаем функцию в расширении View для применения компонента в более свойственной для SwiftUI манере. 

extension View {
func toastView(toast: Binding<FancyToast?>) -> some View {
self.modifier(FancyToastModifier(toast: toast))
}
}

Наконец, все компоненты созданы и готовы к работе! 

Пример использования компонента: 

struct ContentView: View {
@State private var toast: FancyToast? = nil
var body: some View {
VStack {
Button {
toast = FancyToast(type: .info, title: "Toast info", message: "Toast message")
} label: {
Text("Run")
}

}
.toastView(toast: $toast)
}
}

Выполняем и получаем следующий результат:

Превосходно! 

Заключение 

Всплывающие сообщения  —  это довольно распространенная функциональность в приложениях. В статье мы рассмотрели легкий способ создания компонента Toast. Он будет интересен тем, кто до этого момента не знал, как создаются подобные компоненты в SwiftUI. 

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Farhan Adji: Create a Fancy Toast Component Using SwiftUI

Предыдущая статья7 способов создать приложение React
Следующая статьяСоздание хука Git pre-commit для автопроверки и исправления кода JavaScript и TypeScript