SwiftUI туториал слайдер контроллера

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Этот туториал — руководство для реализации 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-анимация:

Анимация SwiftUI
Анимация SwiftUI

По умолчанию 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 мы рассмотрим чуть позже.

Анимация кнопки SwiftUI
Анимация кнопки SwiftUI

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 жеста:

  1. Захват и перелистывание страницы, во время которого мы обновляем волну.

  2. Отпускание волны, во время которого мы либо возвращаем волну на место, либо перелистываем страницу.

  3. Нажатие на волну, после которого мы сразу перелистываем страницу.

Все эти жесты комбинируются в одну сущность и передаются WaveView:

let gesture = DragGesture()
    .onChanged { ..1 }.onEnded { ..2 }
    .simultaneously(with: TapGesture().onEnded { ..3 })

Рассмотрим теперь реализацию жестов более подробно:

  1. Drag

Во время перелистывания мы первым делом запоминаем сторону волны, чтобы рисовать её сверху:

self.topSlider = data.wrappedValue.side

Затем мы на основе текущего состояние волны и переменных перемещения вычисляем новое состояние.

data.wrappedValue = data.wrappedValue.drag(value: $0)

Само вычисление нового состояния довольно простое, но мы обсудим его после того, как рассмотрим SliderData более детально.

  1. 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 анимированно показываем новые ползунки.

Изменение страницы SwiftUI
Изменение страницы SwiftUI
  1. 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/


Интересные статьи

Интересные статьи

С версии 2019.3 Unity поддерживает загрузку и выгрузку игры на Unity из нативного приложения для iOS или Android с помощью функции «Unity as a Library». Это удобный спосо...
Меня долго печалила ситуация с рекламой в Интернете. Стоит всего однажды зайти на продающий какие-либо товары сайт, как тут же начинаешь видеть соответствующую рекламу в соцсетях, в почте...
Многие компании в определенный момент приходят к тому, что ряд процессов в бизнесе нужно автоматизировать, чтобы не потерять свое место под солнцем и своих заказчиков. Поэтому все...
Приветствую вас, жители Хабра и все интересующиеся разработкой под IOS. На связи Анна Жаркова, Senior iOS/Android разработчик компании Usetech Сегодня мы поговорим о тех изменениях и...
Тема статьи навеяна результатами наблюдений за методикой создания шаблонов различными разработчиками, чьи проекты попадали мне на поддержку. Порой разобраться в, казалось бы, такой простой сущности ка...