Этот туториал — руководство для реализации LiquidSwipe на SwiftUI.

Мы вместе реализуем ползунок для переключения между страницами с красивой анимацией, которую можно использовать для пролистывания историй в iOS-приложениях или для навигации.
LiquidSwipeView
Для реализации нашего контрола нам потребуется новая View:
struct LiquidSwipeView: View
Она будет отображать содержимое текущий страницы, а также два ползунка слева и справа для перелистывания страниц:
var body: some View { ZStack { content() slider(data: $leftData) slider(data: $rightData) } .edgesIgnoringSafeArea(.vertical) }
ZStack — это контейнер, который отображает своё содержимое друг над другом и хорошо подходит для создания перекрывающихся элементов. В нашем случае, левый и правый ползунок отображаются поверх основной страницы.
edgesIgnoringSafeArea() позволяет нам рисовать содержимое на весь экран, выходя за рамки safe area.
Параметры ползунков задаются с помощью переменных состояния, которые мы рассмотрим позже:
@State var leftData = SliderData(side: .left) @State var rightData = SliderData(side: .right)
В отличие от оригинального фреймворка Cuberto, наша реализация будет отображать два ползунка. Поэтому мы должны определить сторону ползунка, на которую будем ссылаться:
enum SliderSide { case left case right }
Содержание
В нашем примере каждая страница представляет собой пустой цветной фон. Для этого мы заполняем всю область прямоугольником нужного цвета:
func content() -> some View { return Rectangle().foregroundColor(Config.colors[pageIndex]) }
Все доступные страницы определены с помощью массива констант Config.colors и контролируются переменной pageIndex:
@State var pageIndex = 0
@State переменные — это очень удобный механизм в SwiftUI, который позволяет автоматически обновлять UI при изменении состояния переменной. В данном случае нам достаточно будет вызывать pageIndex = 1 в нашем коде, чтобы переключить страницу.
Ползунок
Давайте теперь подробнее рассмотрим устройство ползунка:
func slider(data: Binding<SliderData>) -> some View { return ZStack { wave(data: data) button(data: data.wrappedValue) } .zIndex(topSlider == data.wrappedValue.side ? 1 : 0) .offset(x: data.wrappedValue.side == .left ? -sliderOffset : sliderOffset) }
Первое, на что надо обратить внимание — это тип переменной data: Binding<SliderData>. Он означает, что мы не только имеем доступ к значению переменной, но и получаем возможность её поменять. Заметьте, чтобы получить ссылку на связь (binding) с переменной, мы указали её название со знаком доллара:
slider(data: $leftData) slider(data: $rightData)
Мы пользуемся этой возможностью в методе wave, который задаёт форму нашего ползунка. В остальных же случаях мы получаем доступ к значению переменной через data.wrappedValue.
Итак, наш ползунок использует уже знакомый нам ZStack и состоит из волны и кнопки. Помимо этого мы задаём ещё два свойства этого контейнера, давайте разберём их подробней:
zIndex(topSlider == data.wrappedValue.side ? 1 : 0)
zIndex позволяет задать порядок, в котором отображаются элементы внутри нашего ZStack. Мы контролируем их с помощью переменной topSlider:
@State var topSlider = SliderSide.right
Эта переменная показывает, какой из ползунков находится сверху. Когда мы будем тянуть за какой-то из ползунков, мы будем устанавливать значение этой переменной таким образом, чтобы тот ползунок, который мы тянем, всегда отображался над вторым ползунком.
offset(x: data.wrappedValue.side == .left ? -sliderOffset : sliderOffset)
offset позволяет сдвинуть наш ползунок немного влево или вправо. Мы используем следующую переменную состояния:
@State var sliderOffset: CGFloat = 0
Большую часть времени наш ползунок не меняет своего положения. Но во время анимации переключения страницы мы скрываем новые ползунки из вида (устанавливая sliderOffset = 100), а затем возвращаем значение в начальное состояние, чтобы ползунки снова появились на экране. В SwiftUI такая анимация создаётся необычайно просто:
self.sliderOffset = 100 withAnimation(.spring()) { self.sliderOffset = 0 }
Посмотрите как эффектно смотрится spring-анимация:

По умолчанию iOS использует линейную анимацию. Однако мы можем настроить его, используя withAnimationпараметры:
withAnimation(.spring(dampingFraction: 0.5))

Теперь посмотрим на кнопку и wave, из которых состоит наш ползунок.
Кнопка
Наша кнопка состоит из серой окружности и белой стрелки, которая описывается несложной математикой:
func button(data: SliderData) -> some View { let aw = (data.side == .left ? 1 : -1) * Config.arrowWidth / 2 let ah = Config.arrowHeight / 2 return ZStack { circle(radius: Config.buttonRadius).stroke().opacity(0.2) polyline(-aw, -ah, aw, 0, -aw, ah).stroke(Color.white, lineWidth: 2) } .offset(data.buttonOffset) .opacity(data.buttonOpacity) }
Когда мы определяем ZStack, то центр кнопки находится в точке (0, 0). Затем с помощью метода offset мы перемещаем кнопку в нужное положение, которое вычисляется из переданных данных. Таким же образом мы задаём и прозрачность кнопки, поскольку чем дальше мы двигаем ползунок, тем прозрачнее становится кнопка. Класс SliderData мы рассмотрим чуть позже.

Wave
Для отображения волны мы используем отдельную структуру: WaveView. В этом методе мы лишь ссылаемся на эту структуру, а также задаём фон и жесты для неё:
func wave(data: Binding<SliderData>) -> some View { let gesture = ... return WaveView(data: data.wrappedValue).gesture(gesture) .foregroundColor(Config.colors[index(of: data.wrappedValue)]) }
Метод index возвращает индекс предыдущей или следующей страницы, в зависимости от стороны ползунка. К тому же, в этом методе мы позволяем переключаться с первой страницы на последнюю и наоборот:
private func index(of data: SliderData) -> Int { let last = Config.colors.count - 1 if data.side == .left { return pageIndex == 0 ? last : pageIndex - 1 } else { return pageIndex == last ? 0 : pageIndex + 1 } }
Теперь нам нужно определить 3 жеста:
-
Захват и перелистывание страницы, во время которого мы обновляем волну.
-
Отпускание волны, во время которого мы либо возвращаем волну на место, либо перелистываем страницу.
-
Нажатие на волну, после которого мы сразу перелистываем страницу.
Все эти жесты комбинируются в одну сущность и передаются WaveView:
let gesture = DragGesture() .onChanged { ..1 }.onEnded { ..2 } .simultaneously(with: TapGesture().onEnded { ..3 })
Рассмотрим теперь реализацию жестов более подробно:
-
Drag
Во время перелистывания мы первым делом запоминаем сторону волны, чтобы рисовать её сверху:
self.topSlider = data.wrappedValue.side
Затем мы на основе текущего состояние волны и переменных перемещения вычисляем новое состояние.
data.wrappedValue = data.wrappedValue.drag(value: $0)
Само вычисление нового состояния довольно простое, но мы обсудим его после того, как рассмотрим SliderData более детально.
-
Drop
Логика этого метода чуть более сложная:
if data.wrappedValue.isCancelled(value: $0) { withAnimation(.spring()) { data.wrappedValue = data.wrappedValue.initial() } } else { self.swipe(data: data) }
В первой строчке мы проверяем, достаточно ли мы передвинули ползунок для перелистывания страницы. Если нет, то мы возвращаем ползунок в начальное положение с помощью уже знакомой нам withAnimation(.spring()):

Если же расстояние достаточное, то мы перелистываем страницу с помощью отдельного метода swipe:
private func swipe(data: Binding<SliderData>) { withAnimation(.spring()) { data.wrappedValue = data.wrappedValue.final() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.pageIndex = self.index(of: data.wrappedValue) self.leftData = self.leftData.initial() self.rightData = self.rightData.initial() self.sliderOffset = 100 withAnimation(.spring()) { self.sliderOffset = 0 } } }
В этом методе мы сначала анимируем волну до финального положения. Затем, после короткой паузы, мы меняем страницу, переводим волны в начальное положение, и с помощью переменной sliderOffset анимированно показываем новые ползунки.

-
Tap
При клике на волну мы делаем уже два знакомых нам действия. Сначала запоминаем, что волна должна отображаться выше второй волны:
self.topSlider = data.wrappedValue.side
И после этого делаем перелистывание страницы:
self.swipe(data: data)
Осталось разобрать только параметры ползунка SliderData, а также отображение волны WaveView.
SliderData
Данные ползунка задаются тремя ключевыми параметрами: левая или правая сторона, центр волны по вертикали и прогресс перелистывания от 0 до 1.
struct SliderData { let side: WaveSide let centerY: Double let progress: Double ... }
Все остальные параметры вычисляются уже динамически на базе ключевых параметров:

Давайте теперь посмотрим, как мы манипулируем этими данными. Изначально мы создаём данные для каждого ползунка с нулевым прогрессом и разным начальным центром для левой и правой волны.
init(side: SliderData) { self.init(side: side, centerY: side == .left ? Config.leftWaveY : Config.rightWaveY, progress: 0) }
Помимо этого после свайпа мы возвращаем волну в начальное или конечное состояние. В этом случае центр волны не меняется, а progress выставляется в крайнее значение:
func initial() -> SliderData { return SliderData(side: side, centerY: centerY, progress: 0) } func final() -> SliderData { return SliderData(side: side, centerY: centerY, progress: 1) }
Реализация перемещения волны выглядит ненамного сложнее:
func drag(value: DragGesture.Value) -> SliderData { let dx = (side == .left ? 1 : -1) * Double(value.translation.width) let progress = min(1.0, max(0, dx * Config.swipeVelocity / SliderData.width)) return SliderData(side: side, centerY: Double(value.location.y), progress: progress) }
Прогресс линейно коррелирует с перемещением волны по горизонтали, а центр волны совпадает с точкой прикосновения.
Последний метод, который мы использовали для проверки куда перемещать волну после drag’n’drop тоже довольно простой:
func isCancelled(value: DragGesture.Value) -> Bool { return drag(value: value).progress < Config.swipeCancelThreshold }
Мы просто сравниваем текущий прогресс с константой (равной 0.15 в нашем примере), и если прогресс не достиг нужной точки, то волна возвращается на место.
WaveView
И, наконец, последняя структура, которую нам осталось рассмотреть — это WaveView. Реализация выглядит следующим образом:
struct WaveView: Shape { private let side: SliderSide private var centerY: Double private var progress: Double init(data: SliderData) { self.side = data.side self.centerY = data.centerY self.progress = data.progress } ... }
Это Shape, который использует те же 3 параметра, что и SliderData. Единственное отличие в том, что центр волны и прогресс могут меняться. Это нужно для того, чтобы позволить SwiftUI автоматически анимировать нашу волну, имея лишь начальное и конечное состояние.
Для этого мы определяем переменную animatableData:
internal var animatableData: AnimatablePair<Double, Double> { get { AnimatablePair(centerY, progress) } set { centerY = newValue.first progress = newValue.second } }
С помощью этой переменной мы показывает, что изменяемое состояние нашей волны можно представить как 2 числа с плавающей точкой и показываем, как их получить и поменять. SwiftUI уже знает, как анимировать double-числа, поэтому этой переменной достаточно, чтобы анимировать нашу волну автоматически.
Ну и последнее, что нам остаётся — это определить метод path, который будет описывать форму нашей волны.
func path(in rect: CGRect) -> Path { var path = Path() ... return path }
Мы не будем подробно останавливаться на форме волны, поскольку там довольно много математики, но вы всегда можете посмотреть полную реализацию в нашем Github.
Заключение
Как видите, SwiftUI отлично подходит не только для создания простых пользовательских интерфейсов. Надеемся, что наш туториал продемонстрировал, что сложные элементы управления и эффекты также могут быть лаконично описаны с его помощью. Не забудьте ознакомиться с другими нашими туториалами по SwiftUI, и удачи!
ссылка на оригинал статьи https://habr.com/ru/post/649761/
Добавить комментарий