Connect with us

Разработка

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 анимации состоит из трех основных этапов:

  1. Определите пространство имен, чтобы SwiftUI мог отслеживать идентичность исходного и целевого представления на протяжении всей анимации.
  2. Добавьте модификатор представления matchedTransitionSource к представлению, от которого вы хотите перейти, и укажите пространство имен, определенное на первом этапе, а также идентификатор представления в этом пространстве имен.
  3. На целевом представлении используйте модификатор вида 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, искусственном интеллекте и других темах.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.

Наши партнеры:

LEGALBET

Мобильные приложения для ставок на спорт
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: