Разработка
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 и связанным с ним модификаторам представления мы можем сделать это более привлекательным всего несколькими строчками кода:
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
, которую мы можем использовать для настройки внешнего вида исходного представления.
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
из окружения подробного представления.
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
на вызывающей стороне:
.navigationDestination(for: GradienConfiguration.self) { gradient in DismissableView { GradientView(configuration: gradient) } .navigationTransition(.zoom(sourceID: gradient.id, in: namespace)) }
Перетаскивание вниз для закрытия
В разделе App Store Today пользователи также могут перетащить вниз представление подробностей, чтобы закрыть его. Для реализации этой функции мы можем использовать новый модификатор представления SwiftUI onScrollGeometryChange
. Он предоставляет элегантный способ запуска действий на основе изменений геометрии Scroll View.
В нашем случае мы хотим отключать представление, когда пользователь потянет его вниз на определенную величину.
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
, чтобы мы могли соответствующим образом масштабировать все представление. Кроме того, мы изменим непрозрачность кнопки закрытия в зависимости от того, сколько пользователь тянет вниз.
@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
, который не реализует никакой подсветки:
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
можно следующим образом:
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, искусственном интеллекте и других темах.