Как создать сложные серии анимаций в SwiftUI

Вопрос или проблема

Как можно создать «сложные» анимации в SwiftUI? Где сложные означают, что полная анимация состоит из нескольких под-анимаций.

Пример

Я работаю над переносом существующего проекта с UIKit на SwiftUI. Проект использует пользовательский UIView для отображения индикатора прогресса, который анимирует изменения между положительными и отрицательными значениями. Положительные значения отображаются зеленым индикатором, отрицательные – красным.

Предположим, что maxValue равно 100:

  • Значение +60 будет показано как 60% зеленый индикатор.
  • Изменение значения на +20 анимирует зеленый индикатор вниз до 20% за 1 секунду.
  • Изменение значения на -40 анимирует зеленый индикатор до 0 за 1/3 секунды, а затем анимирует красный индикатор плавно от 0 до 40% за 2/3 секунды. Эта полная анимация также занимает 1 секунду всего.

Таким образом, общая длительность анимации всегда составляет 1 секунду, независимо от того, как меняется значение. При изменении с положительного на отрицательное значение (или наоборот) индикатор сжимается до 0, прежде чем изменить свой цвет и снова стать длиннее.

Простой ProgressBar

Создание простого индикатора прогресса в SwiftUI не составило труда:

struct SomeProgressBar: View {
    var height: CGFloat = 20
    
    var minValue: Double = 0
    var maxValue: Double = 100
    var currentValue: Double = 20
    
    var body: some View {
        let maxV = max(minValue, maxValue)
        let minV = min(minValue, maxValue)
        
        let currentV = max(min(abs(currentValue - minValue), maxV), minV)
        let isNegative = currentValue < minValue
        
        let progress = (currentV - minV) / (maxV - minV)
        
        Rectangle()
            .fill(.gray)
            .frame(height: height)
            .overlay(alignment: .leading) {
                GeometryReader { geometry in
                    Rectangle()
                        .fill(isNegative ? .red : .green)
                        .frame(width: geometry.size.width * progress)
                }
            }
            .animation(.easeInOut(duration: 0.5), value: progress)
    }
}

struct SomeProgressBarTestView: View {
    @State var value: Double = 40
    
    var body: some View {
        VStack {
            SomeProgressBar(currentValue: value)
            
            Button("Изменить") {
                value = .random(in: -100...100)
            }
        }
    }
}

Проблема

Хотя это работает нормально, когда используются только положительные или только отрицательные значения, переключение между положительными и отрицательными значениями не анимируется так, как задумано. Например, переключение между +x и -x изменит только цвет индикатора, но не анимирует сам индикатор. Это неудивительно, поскольку ширина не меняется, она составляет x% до и после изменения значения. Однако желаемый результат заключался бы в анимации ширины от x до 0 и обратно до x.

Все источники, которые я нашел до сих пор, только различают между неявными и явными анимациями. Хотя это изменяет способ реализации / описания анимации, похоже, что это не влияет на то, что анимация может делать.

Какой будет «правильный» подход к созданию желаемой «сложной» анимации?

Две разные анимации?

Конечно, можно рассчитать, произошел ли переход от положительного к отрицательному, и в этом случае инициировать две разные анимации с помощью .withAnimation { ... }. Рассчитать длительность первой анимации и второй анимации по разнице значений не составит проблемы.

Однако, это будет работать только для линейных анимаций. При использовании любой функции замедления, такой как пружина и т.д., добиться непрерывной анимации с таким подходом не получится.

Кроме того, индикатор прогресса – это только один пример. Что если нужно объединить еще больше анимаций для получения желаемого результата.

Существует ли решение для создания пользовательских анимаций, подобных этим?

Вы можете соответствовать Animatable. Извлеките часть, относящуюся к анимации, в ViewModifier, который соответствует Animatable.

var body: some View {
    let maxV = max(minValue, maxValue)
    let minV = min(minValue, maxValue)
    
    let currentV = max(min(abs(currentValue - minValue), maxV), minV)
    let progress = (currentV - minV) / (maxV - minV)
    
    Rectangle()
        .fill(.gray)
        .frame(height: height)
        .overlay {
            Rectangle()
                .modifier(ProgressBarModifier(
                    progress: currentValue < minV ? -progress : progress
                ))
        }
        .animation(.linear(duration: 1), value: progress)
}

struct ProgressBarModifier: ViewModifier, Animatable {
    var progress: Double // между -1 и 1
    
    nonisolated var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .foregroundStyle(progress < 0 ? .red : .green)
            // это яснее, чем GeometryReader, но GeometryReader также должен работать
            .scaleEffect(x: abs(progress), anchor: .leading)
    }
}

На каждом кадре анимации SwiftUI будет устанавливать animatableData на некоторое интерполированное значение между начальной и конечной точками анимации. Затем будет вызван body и обновлен интерфейс пользователя с новым кадром.


Вы также можете настроить весь SomeProgressBar под Animatable, но в этом случае модификатор animation необходимо переместить в родительский вид.

Ответ или решение

Как создать сложные анимации в SwiftUI

Создание сложных анимаций в SwiftUI может оказаться более изящным и мощным, чем в UIKit. SwiftUI позволяет комбинировать различные анимации для создания более динамичного пользовательского интерфейса. В данной статье мы обсудим, как можно реализовать сложные анимации на примере прогресс-бара, который показывает изменения значений как в положительном, так и в отрицательном диапазоне.

Определение задачи

Наша цель – создать анимацию прогресс-бара, которая учитывает следующие аспекты:

  1. Положительные значения отображаются зелёным цветом.
  2. Отрицательные значения отображаются красным цветом.
  3. Переход между положительными и отрицательными значениями осуществляется через анимацию, которая плавно изменяет ширину прогресс-бара.
  4. Мы хотим, чтобы смена состояния (положительное/отрицательное) происходила за фиксированное время в 1 секунду, включая сжатие к нулю и плавную смену цвета.

Основная структура прогресс-бара

Простая реализация прогресс-бара может выглядеть так:

struct SimpleProgressBar: View {
    var height: CGFloat = 20
    var minValue: Double = 0
    var maxValue: Double = 100
    var currentValue: Double = 20

    var body: some View {
        let progress = (currentValue - minValue) / (maxValue - minValue)

        Rectangle()
            .fill(Color.gray)
            .frame(height: height)
            .overlay(alignment: .leading) {
                Rectangle()
                    .fill(currentValue < minValue ? Color.red : Color.green)
                    .frame(width: (max(0, min(1, progress)) * 100))
            }
            .animation(.easeInOut(duration: 0.5), value: progress)
    }
}

Реализация сложной анимации

Теперь давайте модифицируем реализацию, добавив сложную анимацию, включая переключение между положительным и отрицательным значениями. Чтобы это сделать, мы воспользуемся протоколом Animatable и создадим ViewModifier.

Создание ProgressBarModifier
struct ProgressBarModifier: ViewModifier, Animatable {
    var progress: Double // between -1 and 1

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func body(content: Content) -> some View {
        content
            .foregroundStyle(progress < 0 ? Color.red : Color.green)
            .scaleEffect(x: abs(progress), anchor: .leading)
    }
}
Использование ProgressBarModifier в SomeProgressBar
struct SomeProgressBar: View {
    var height: CGFloat = 20
    var minValue: Double = 0
    var maxValue: Double = 100
    @State var currentValue: Double = 20

    var body: some View {
        let progress = (currentValue - minValue) / (maxValue - minValue)

        Rectangle()
            .fill(Color.gray)
            .frame(height: height)
            .overlay {
                Rectangle()
                    .modifier(ProgressBarModifier(progress: currentValue < minValue ? -progress : progress))
            }
            .animation(.linear(duration: 1), value: progress)
    }
}

Как работает анимация

В этом примере, когда currentValue изменяется, SwiftUI автоматически рассчитывает промежуточные состояния для animatableData, передавая новые значения в body. Это позволяет ProgressBarModifier обновлять UI в каждом кадре анимации. Таким образом, при переходе между положительными и отрицательными значениями, бар сначала сжимается до нуля, а затем раздвигается до нового значения с соответствующим цветом.

Пример тестового представления

Вот как может выглядеть тестовое представление, которое вызывает изменения значений:

struct SomeProgressBarTestView: View {
    @State private var value: Double = 40

    var body: some View {
        VStack {
            SomeProgressBar(currentValue: value)
            Button("Изменить") {
                let randomValue = Double.random(in: -100...100)
                withAnimation {
                    value = randomValue
                }
            }
        }
    }
}

Заключение

Используя Animatable и соответствующие ViewModifier, вы можете создавать сложные анимации в SwiftUI, позволяя вашему приложению выглядеть более интерактивным и отзывчивым. Эта методология не только упрощает управление состояниями анимаций, но также повышает читаемость и поддержку вашего кода. Анализируя переключения значений, вы всегда можете обеспечить плавные переходы и эффектные визуализации для улучшения пользовательского взаимодействия.

Оцените материал
Добавить комментарий

Капча загружается...