Разработка
Hero анимация в SwiftUI с помощью NavigationTransition
В этой статье вы узнаете, как реализовать Hero анимацию, похожую на анимацию в представлении «Сегодня» в App Store.
Мы все знаем и любим Hero анимацию (анимацию перемещения элемента с одного экрана на другой) в App Store — она отлично подходит для визуально насыщенных пользовательских интерфейсов, таких как представление «Сегодня» в App Store, приложение Apple TV, поток Explore в AirBnB и многих других приложениях.
Благодаря протоколу NavigationTransition
в SwiftUI, представленному в iOS 18, реализовать анимацию можно всего в трех строках кода.
В этой статье вы узнаете, как реализовать Hero анимацию, похожую на анимацию в представлении «Сегодня» в App Store. Для достижения такого внешнего вида и ощущения требуется не три строки кода, поэтому мы также рассмотрим возможность превращения этого компонента в многократно используемый компонент SwiftUI.
Масштабирование от нуля до героя
Представим, что мы работаем над приложением, которое позволяет пользователям просматривать градиенты. В следующем фрагменте отображается список градиентов. Пользователь может перейти к экрану подробностей, нажав на любую из карточек.
import SwiftUI
struct PlainNavigationDemo: View {
var gradients = GradienConfiguration.samples
var body: some View {
NavigationStack {
ScrollView {
ForEach(gradients) { gradient in
NavigationLink(value: gradient) {
GradientView(configuration: gradient)
.frame(height: 450)
.padding(16)
}
}
.navigationDestination(for: GradienConfiguration.self) { gradient in
GradientView(configuration: gradient)
}
}
}
}
}
#Preview {
PlainNavigationDemo()
}
Такая drill-down навигация является обычной для многих приложений в iOS и существует с самой первой версии iOS. Она хорошо подходит для перехода от элементов списка к их деталям (как в приложении «Контакты»). Однако для красивых сетчатых градиентов (или постера фильма, карточки товара или любого другого визуально насыщенного вида) она не выглядит достаточно эффектно.
Благодаря новому протоколу NavigationTransition
в SwiftUI и связанным с ним модификаторам представления мы можем сделать это более привлекательным всего несколькими строчками кода:
xxxxxxxxxx
import SwiftUI
struct HeroNavigationDemo: View {
@Namespace var namespace
var gradients = GradienConfiguration.samples
var body: some View {
NavigationStack {
ScrollView {
ForEach(gradients) { gradient in
NavigationLink(value: gradient) {
GradientView(configuration: gradient)
.matchedTransitionSource(id: gradient.id, in: namespace)
.frame(height: 450)
.padding(16)
}
}
.navigationDestination(for: GradienConfiguration.self) { gradient in
GradientView(configuration: gradient)
.navigationTransition(.zoom(sourceID: gradient.id, in: namespace))
}
}
}
}
}
#Preview {
HeroNavigationDemo()
}
Реализация Hero анимации состоит из трех основных этапов:
- Определите пространство имен, чтобы SwiftUI мог отслеживать идентичность исходного и целевого представления на протяжении всей анимации.
- Добавьте модификатор представления
matchedTransitionSource
к представлению, от которого вы хотите перейти, и укажите пространство имен, определенное на первом этапе, а также идентификатор представления в этом пространстве имен. - На целевом представлении используйте модификатор вида
navigationTransition
и укажите анимацию, которую вы хотите использовать. На данный момент доступны только анимации.automatic
(это анимация скольжения по умолчанию) и.zoom
, которая включает Hero анимацию. При использовании.zoom
необходимо указать то же пространство имен и идентификатор, которые вы использовали для исходного представления.
Настройка внешнего вида исходного представления
Дизайн исходного представления оставляет желать лучшего (даже если мы показываем градиент…), но, к счастью, существует перегруженная версия модификатора представления .matchedTransitionSource
, которую мы можем использовать для настройки внешнего вида исходного представления.
xxxxxxxxxx
GradientView(configuration: gradient)
.matchedTransitionSource(id: gradient.id, in: namespace, configuration: { source in
source
.background(.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 8)
})
.frame(height: 450)
.padding(16)
Внутри замыкания configuation
модифицированного представления .matchedTransformationSource
мы можем использовать параметр source
, чтобы настроить внешний вид исходного представления.
Стоит отметить, что source
не соответствует View
и не является прямым представлением самого исходного представления. Вместо этого он соответствует протоколу EmptyMatchedTransitionSourceConfiguration
.
Это означает, что мы можем управлять только тенью, цветом фона и формой клипа исходного представления. Также невозможно обернуть исходное представление внутри другого представления, поскольку возвращаемый тип замыкания — MatchedTransitionSourceConfiguration
.
Удивительно, что модификатор clipShape
поддерживает только RoundedRectangle
— так что если вы надеялись использовать UnevenRoundedRectangle
для причудливых асимметричных закругленных углов, вам, возможно, придется обратиться в Apple, чтобы получить поддержку этого в одной из следующих бета-версий.
Но даже с ограниченным количеством модификаторов вида мы можем добиться гораздо более привлекательного внешнего исходного вида.
Добавление кнопки закрытия
Если вы сравните представление подробностей с экраном подробностей раздела «Сегодня» в App Store, то заметите, что на экране подробностей App Store нет кнопки «Назад» — вместо этого пользователь может закрыть экран, либо нажав на кнопку «Отмена» в правом верхнем углу, либо потянув вниз по экрану.
Давайте сначала реализуем кнопку отмены.
Анимация zoom
в SwiftUI может переходить от любого представления к любому другому представлению, поэтому мы можем создать представление-обертку, которое скрывает навигационную панель и накладывает GradientView
с кнопкой закрытия.
Чтобы код было легче читать и он был более удобным для сопровождения, лучше создать для этого отдельное представление. Это также позволит нам использовать действие Dismiss
из окружения подробного представления.
xxxxxxxxxx
struct DismissableView<Content: View>: View {
@Environment(\.dismiss) private var dismiss
private var content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var dismissButton: some View {
HStack {
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.frame(width: 30, height: 30)
.foregroundColor(Color(uiColor: .label))
.background(Color(uiColor: .systemBackground))
.clipShape(Circle())
}
.padding([.top, .trailing], 30)
}
}
var body: some View {
ZStack(alignment: .topLeading) {
content()
dismissButton
}
.ignoresSafeArea()
.navigationBarBackButtonHidden()
.statusBarHidden()
}
}
Обернув содержимое в ZStack
, мы можем поместить кнопку отмены поверх основного содержимого. Теперь мы можем избавиться от кнопки навигации назад и строки состояния, и, наконец, использовать ignoresSafeArea()
, чтобы позволить содержимому занять все пространство экрана.
Теперь мы можем использовать DismissableView
на вызывающей стороне:
xxxxxxxxxx
.navigationDestination(for: GradienConfiguration.self) { gradient in
DismissableView {
GradientView(configuration: gradient)
}
.navigationTransition(.zoom(sourceID: gradient.id, in: namespace))
}
Перетаскивание вниз для закрытия
В разделе App Store Today пользователи также могут перетащить вниз представление подробностей, чтобы закрыть его. Для реализации этой функции мы можем использовать новый модификатор представления SwiftUI onScrollGeometryChange
. Он предоставляет элегантный способ запуска действий на основе изменений геометрии Scroll View.
В нашем случае мы хотим отключать представление, когда пользователь потянет его вниз на определенную величину.
xxxxxxxxxx
var body: some View {
ScrollView {
ZStack(alignment: .topLeading) {
content()
dismissButton
}
}
.ignoresSafeArea()
.navigationBarBackButtonHidden()
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < -50
} action: { _, isTornOff in
if isTornOff {
dismiss()
}
}
}
onScrollGeometryChange
принимает три параметра: первый задает тип, к которому мы хотим привязать изменения геометрии прокрутки. Мы хотим определить, следует ли нам отменить просмотр, поэтому это Bool
.
Второй параметр — это замыкание, в котором мы сопоставляем изменения геометрии прокрутки с типом, указанным в первом параметре. Здесь мы возвращаем true
, если пользователь протащил вид вниз более чем на 50 пикселей.
Последний параметр — это ключ, который будет вызван при изменении значения, возвращенного из замыкания трансформации. Он получает как старое, так и новое значение. В нашем случае нам нужно только новое значение — если оно равно true
, мы знаем, что пользователь перетащил вид вниз за точку отрыва, и можем отменить просмотр.
Уменьшение масштаба
Чтобы реализовать эффект масштабирования представления «Сегодня» в App Store, мы можем применить то, что узнали в предыдущем разделе.
На этот раз мы хотим перевести изменения геометрии прокрутки в scaleFactor
типа CGFloat
, чтобы мы могли соответствующим образом масштабировать все представление. Кроме того, мы изменим непрозрачность кнопки закрытия в зависимости от того, сколько пользователь тянет вниз.
xxxxxxxxxx
@State var scaleFactor: CGFloat = 1
@State var cornerRadius: CGFloat = 16
@State var opacity: CGFloat = 1
var body: some View {
ScrollView {
ZStack(alignment: .topLeading) {
content()
dismissButton
.opacity(opacity)
}
.scaleEffect(scaleFactor)
}
.ignoresSafeArea()
.navigationBarBackButtonHidden()
.background(Color(UIColor.secondarySystemBackground))
.scrollIndicators(scaleFactor < 1 ? .hidden : .automatic, axes: .vertical)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { oldValue, newValue in
if newValue >= 0 {
scaleFactor = 1
cornerRadius = 16
opacity = 1
}
else {
scaleFactor = 1 - (0.1 * (newValue / -50))
cornerRadius = 55 - (35 / 50 * -newValue)
opacity = 1 - (abs(newValue) / 50)
}
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < -50
} action: { _, isTornOff in
if isTornOff {
dismiss()
}
}
}
Отключение подсветки исходного представления
Последний твик — избавиться от подсветки, которую SwiftUI применяет к исходному представлению, когда пользователь нажимает на него. Для этого мы можем определить новый ButtonStyle
, который не реализует никакой подсветки:
xxxxxxxxxx
import SwiftUI
struct NoHighlightButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
}
}
extension ButtonStyle where Self == NoHighlightButtonStyle {
static var noHighlight: NoHighlightButtonStyle {
get { NoHighlightButtonStyle() }
}
}
Примените стиль к NavigationLink
можно следующим образом:
xxxxxxxxxx
NavigationLink(value: gradient) {
GradientView(configuration: gradient)
.matchedTransitionSource(id: gradient.id, in: namespace, configuration: { source in
source
.background(gradient.colors.last ?? .gray)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 8)
})
.frame(height: 450)
.padding(16)
}
.buttonStyle(.noHighlight)
Заключение
В SwiftUI 16 стало как никогда просто реализовать Hero анимацию, которая позволяет нам создавать пользовательские интерфейсы, вызывающие восторг у наших пользователей. С помощью всего трех строк кода мы смогли добавить переход, похожий на анимацию раздела “Сегодня” в App Store. Приложив немного дополнительных усилий, мы смогли еще больше улучшить пользовательский опыт.
Если вам интересно узнать больше о создании пользовательских компонентов SwiftUI, ознакомьтесь с моим интерактивным руководством “Создание переиспользуемых компонентов в SwiftUI”, а также не забывайте следить за мной в Twitter — я регулярно пишу о SwiftUI, Firebase, искусственном интеллекте и других темах.
-
Видео и подкасты для разработчиков4 недели назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.10
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.11
-
Видео и подкасты для разработчиков2 недели назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript