SwiftUI по полочкам: Анимация. Часть 1

  • Tutorial
image

Недавно мне попалась свежая статья, в которой ребята пытались воспроизвести интересный концепт средствами SwiftUI. Вот что у них получилось:

image

Я с интересом изучил их код, но испытал некоторое разочарование. Нет, не в том смысле, что они что-то сделали неправильно, вовсе нет. Просто я не узнал из их кода практически ничего нового. Их реализация, это скорее про Combine, нежели про анимацию. И я решил построить свой лунопарк написать свою статью об анимации в SwiftUI, реализовав примерно тот же концепт, но используя на 100% возможности встроенной анимации, даже если это не совсем эффективно. Изучать — так до конца. Экспериментировать — так с огоньком:)

Вот что получилось у меня:


Однако, для полноценного раскрытия темы мне пришлось довольно подробно рассказать о самых основах. Текст получился объемным, и потому, я разбил его на две статьи. Перед вами первая ее часть — скорее туториал по анимации вообще, не связанный непосредственно с радужной анимацией, о которой я еще расскажу подробно в следующей статье.

В этой статье же, я расскажу об основах, без понимания которых можно легко запутаться в более сложных примерах. Многое из того, о чем я буду говорить, в том или ином виде уже было рассказано в англоязычных статьях например этой серии (1, 2, 3, 4). Я же, сосредоточился не столько на перечислении способов работы, сколько на описании того, как именно это работает. И как всегда, я много экспериментировал, так что самыми интересными результатами спешу поделиться.

warning: под катом много картинок и gif-анимаций.

TLDR


Проект доступен на гитхабе. Текущий результат с радужной анимацией можно посмотреть в TransitionRainbowView(), но я не торопился бы на вашем месте, а подождал следующей статьи. К тому же, при ее подготовке, я и код немного причешу.

В этой статье мы будем обсуждать только основы, и затронем только содержимое папки Bases

Введение


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

Сразу хочу оговориться. Я не считаю, что в упомянутой статье были допущены какие-то ошибки, или подход использованный в ней неверен. Вовсе нет. В ней строится объектная модель процесса (анимации), которая, реагируя на полученный сигнал, начинает что-то делать. Однако, как по мне, данная статья скорее раскрывает работу с фреймворком Combine. Да, этот фреймворк — важная часть SwiftUI, но это скорее про react-like стиль вообще, чем про анимацию.

Мой вариант — уж точно не элегантрее, не быстрее и не проще поддерживать. Однако, он намного лучше раскрывает то, что под капотом у SwiftUI, а ведь именно в этом была цель статьи — разобраться в первую очередь.

Как я уже говорил в предыдущей статье по SwiftUI, я начал свое погружение в мир мобильной разработки сразу же со SwiftUI, проигнорировав UIKit. У этого, разумеется, есть своя цена, но есть и преимущества. Я не пытаюсь жить в новом монастыре по старому уставу. Честно сказать, мне вообще никакие уставы еще не знакомы, поэтому, у меня нет отторжения нового. Именно поэтому, данная статья, как мне кажется, может иметь ценность не только для новичков, как я, но и для тех, кто изучает SwiftUI уже имея background в виде разработки на UIKit. Мне кажется, именно свежего взгляда многим не хватает. Не делать все то же самое, пытаясь приладить новый инструмент к старым чертежам, а изменить свое видение согласно новым возможностям.

Мы, 1с-ники, проходили это с “управляемыми формами”. Это своего рода SwiftUI в мире 1с, который случился уже более 10 лет тому. На самом деле аналогия довольно точная, ведь управляемые формы — это всего лишь новый способ рисовать интерфейс. Однако он полностью изменил клиент-серверное взаимодействие приложения в целом, и картину мира в головах разработчиков в частности. Это далось нелегко, я и сам лет 5 никак не хотел его изучать, т.к. считал что многие возможности, которые там были обрезаны, мне просто необходимы. Но, как показала практика, кодить на управляемых формах не только можно, а только так и нужно.

Впрочем, не будем больше об этом. У меня получился подробный, самостоятельный гайд, не имеющий каких-то отсылок, или иных связей с упомянутой статьей или 1с-ным прошлым. Шаг за шагом мы будем погружаться в детали, особенности, принципы и ограничения. Поехали.

Анимируем Shape


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


Итак, основная идея анимации — это превращение какого-то конкретного, дискретного изменения в непрерывный процесс. Например, радиус круга был 100 единиц, стал 50 единиц. Без анимации, изменение произойдет мгновенно, с анимацией — плавно. Как это работает? Очень просто. Для плавности изменений нам потребуется интерполировать несколько значений внутри отрезка “Было… Стало”. В случае с радиусом, нам придется отрисовать несколько промежуточных кругов радиусом 98 единиц, 95 единиц, 90 единиц… 53 единицы и, наконец, 50 единиц. SwiftUI умеет делать это легко и непринужденно, достаточно завернуть код, выполняющий это изменение в withAnimation{...}. Это кажется магией… До того момента как ты захочешь реализовать что-то чуть сложнее “hello world”.

Давайте перейдем к примерам. Самым простым и понятным объектом для анимации принято считать анимацию форм. Shape (я все же буду называть структуру удовлетворяющую протоколу shape формой) в SwiftUI — это структура с параметрами, которая умеет вписывать себя в данные границы. Т.е. это структура имеющая функцию body(in rect: CGRect) -> Path. Все что нужно рантайму чтобы отрисовать эту форму, это запросить ее контур (результат функции — объект типа Path, фактически, представляет собой кривую Безье) для требуемого размера (указанного в качестве параметра функции, прямоугольника типа CGRect).

Shape — это хранимая структура. Инициализировав ее, вы храните в параметрах все что потребуется для отрисовки ее контура. Размер выделенного под данную форму может меняться, тогда все что нужно, это получить новое значение Path для нового фрейма CGRect, и вуаля.

Давайте начнем уже кодить:

struct CircleView: View{
    var radius: CGFloat
    var body: some View{
        Circle()
            .fill(Color.green)
            .frame(height: self.radius * 2)
            .overlay(
                Text("Habra")
                    .font(.largeTitle)
                    .foregroundColor(.gray)
                )

    }
}
struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}
struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
            CircleView(radius: radius)
               .frame(height: 200)
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


Итак, у нас есть есть круг (Circle()), радиус которого мы можем изменять с помощью ползунка. Это происходит плавно, т.к. ползунок выдает нам все промежуточные значения. Однако, при нажатии кнопки “set default radius”, изменение тоже происходит не мгновенно, а согласно инструкции withAnimation(.linear(duration: 1)). Линейно, без ускорений, растянуто на 1 секунду. Класс! Мы освоили анимацию! Расходимся:)

А что если мы захотим реализовать свою собственную форму, и анимировать ее изменения? Сложно ли это сделать? Давайте проверять.

Я сделал копию Circle следующим образом:

struct CustomCircle: Shape{
    public func path(in rect: CGRect) -> Path{
        let radius = min(rect.width, rect.height) / 2
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        return Path(){path in
            if rect.width > rect.height{
                path.move(to: CGPoint(x: center.x, y: 0))
                let startAngle = Angle(degrees: 270)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }else{
                path.move(to: CGPoint(x: 0, y: center.y))
                let startAngle = Angle(degrees: 0)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }
            path.closeSubpath()
        }
    }
}

Радиус круга рассчитываем как половину меньшей из величин ширины и высоты границы выделенной нам области экрана. Если ширина больше высоты, мы начинаем с середины верхней границы (Примечание 1), описываем полный круг по часовой стрелке (примечание 2), и на этом замыкаем наш контур. Если высота больше ширины, начинаем с середины правой границы, так же описываем полный круг по часовой и замыкаем контур.

Примечание 1
Apple предлагает разработчикам пользоваться перевернутой (а точнее отраженной) системой координат. В ней, левый верхний угол соответствует координатам (0, 0), а правый нижний угол может быть описан координатой (x, y), где x — ширина экрана, а y — его высота. Т.е. движение вниз по координатной сетке соответствует увеличению y. Положительное значение y — это высота вниз. Отсюда следует, что и углы меряются по этой же логике. Угол 90 градусов соответствует направлению вниз, 180 градусов — влево, 270 градусов — вверх.

Примечание 2
Из примечания 1 следует и то, что понятия “по часовой” и “против часовой” стрелке так же ивертированы вследствии использования отраженной координатной системы. Об этом говорится и в документации к Core Graphics (SwiftUI не более чем надстройка над ней):
In a flipped coordinate system (the default for UIView drawing methods in iOS), specifying a clockwise arc results in a counterclockwise arc after the transformation is applied.

Давайте проверим, как наш новый круг будет реагировать на изменения в блоке withAnimation:

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}

struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
                HStack{
                CircleView(radius: radius)
                    .frame(height: 200)
                CustomCircleView(radius: radius)
                    .frame(height: 200)
            }
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


Здорово! Мы научились делать собственные картинки произвольной формы и анимировать их! Ведь так?

На самом деле нет. Всю работу здесь делает модификатор .frame(width: self.radius * 2, height: self.radius * 2). Внутри блока withAnimation{...} мы изменяем State переменную, она посылает сигнал к повторной инициализации CustomCircleView() с новым значением radius, это новое значение попадает в модификатор .frame(), а уже этот модификатор умеет анимировать изменение параметров. Наша форма CustomCircle() реагирует на это с анимацией, поскольку она не зависит ни от чего, кроме размеров выделенной для нее области. Изменение области происходит с анимацией, (т.е. постепенно, интерполируя промежуточные значения между было-стало), потому и наш круг отрисовывается с этой же анимацией.

Давайте чуть упростим (или все же усложним?) нашу форму. Не будем вычислять радиус исходя из размеров доступной области, а передадим радиус в готовом виде, т.е. сделаем его хранимым параметром структуры.

struct CustomCircle: Shape{
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
        //let radius = min(rect.width, rect.height) / 2
...
    }
}

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle(radius: radius)
            .fill(Color.gray)
            //.frame(height: self.radius * 2)
...
    }
}


Ну вот, магия безвозвратно утеряна.

Мы исключили модификатор frame() из нашей CustomCircleView(), переложив ответственность за размер круга на саму форму, и анимация пропала. Но не беда, научить форму анимировать изменения ее параметров не слишком сложно. Для этого, нужно реализовать требования протокола Animatable:

struct CustomCircle: Shape, Animatable{
    var animatableData: CGFloat{
         get{
             radius
         }
         set{
            print("new radius is \(newValue)")
            radius = newValue
         }
     }
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
	...
}


Вуаля! Магия снова вернулась!

И вот теперь мы можем с уверенностью утверждать, что наша форма действительно анимирована — она умеет отражать изменения своих параметров с анимацией. Мы дали системе форточку, куда она может пропихивать интерполированные значения, необходимые для анимации. Если такая форточка есть — изменения анимируются. Если ее нет — изменения проходят без анимации, т.е. мгновенно. Ничего сложного, да?

AnimatableModifier


Как анимировать изменения внутри View


Но давайте уже перейдем непосредственно к View. Предположим, мы хотим анимировать положение какого-то элемента внутри контейнера. В нашем случае, это будет простой прямоугольник зеленого цвета и шириной 10 единиц. Анимировать будем его положение по горизонтали.

struct SimpleView: View{
    @State var position: CGFloat = 0
    var body: some View{
        VStack{
            ZStack{
                Rectangle()
                    .fill(Color.gray)
                BorderView(position: position)
            }
            Slider(value: self.$position, in: 0...1)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.position = 0
                }
            }){
                Text("set to 0")
            }
        }
    }
}

struct BorderView: View,  Animatable{
    public var animatableData: CGFloat {
        get {
            print("Reding position: \(position)")
            return self.position
        }
        set {
            self.position = newValue
            print("setting position: \(position)")
        }
    }
    let borderWidth: CGFloat
    init(position: CGFloat, borderWidth: CGFloat = 10){
        self.position = position
        self.borderWidth = borderWidth
        print("BorderView init")
    }
    var position: CGFloat
    var body: some View{
        GeometryReader{geometry in
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
                // .borderIn(position: position)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        print("calculating position: \(position)")
        return -inSize.width / 2 + inSize.width * position
    }
}


Класс! Работает! Теперь мы знаем все об анимации!

На самом деле нет. Если посмотреть в консоль, то там увидим следующее:
BorderView init
calculating position: 0.4595176577568054
BorderView init
calculating position: 0.468130886554718
BorderView init
calculating position: 0.0

Во-первых, каждое изменение значения position с помощью слайдера вызывает повторную инициализацию BorderView с новым значением. Именно поэтому мы видим плавное движение зеленой линии вслед за ползунком, ползунок просто очень часто сообщает об изменении переменной, и это выглядит как анимация, но ею не является. Пользоваться ползунком действительно удобно когда вы отлаживаете анимацию. Вы можете с его помощью отслеживать какие-то переходные состояния.

Во-вторых, мы видим, что calculating position просто стало равно 0, и никаких промежуточных логов, как это было в случае правильной анимации круга. Почему?

Все дело, как и в предыдущем примере, в модификаторе. На этот раз, модификатор .offset() получает новое значение отступа, и сам анимирует это изменение. Т.е. на самом деле анимируется не изменение параметра position, как мы задумывали, а производное от него изменение отступа по горизонтали в модификаторе .offset(). В данном случае это безобидная замена, результат получился тот же. Но раз уж взялись, давайте копать глубже. Сделаем свой собственный модификатор, который на входе будет получать position (от 0 до 1), сам будет получать размер доступной области и вычислять отступ.

struct BorderPosition: ViewModifier{
    var position: CGFloat
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
            .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
            .animation(nil)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        let offset = -inSize.width / 2 + inSize.width * position
        print("at position  \(position) offset is \(offset)")
        return offset
    }
}

extension View{
    func borderIn(position: CGFloat) -> some View{
        self.modifier(BorderPosition(position: position))
    }
}

В исходной BorderView, соответственно, GeometryReader нам больше не нужен, равно как и функция по вычислению отступа:

struct BorderView: View,  Animatable{
    ...
    var body: some View{
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .borderIn(position: position)
    }
}


Да, мы все еще используем внутри нашего модификатора .offset(), но после него мы добавили модификатор .animation(nil), который блокирует собственную анимацию offset. Понимаю, на данном этапе вы можете решить, что достаточно убрать эту блокировку, но тогда мы не докопаемся до истины. А истина в том, что наша хитрость с animatableData для BorderView не работает. На самом деле, если посмотреть документацию к протоколу Animatable, можно заметить, что имплементация этого протокола поддерживается только для AnimatableModifier, GeometryEffect и Shape. View среди них нет.

Правильный подход — анимировать модификации


Сам подход, когда мы просим View анимировать какие-то изменения был не верен. Для View нельзя использовать тот же подход, что и для форм. Вместо этого, анимацию нужно вкладывать в каждый модификатор. Большинство встроенных модификаторов уже поддерживают анимацию из коробки. Если вы хотите анимацию для ваших собственных модификаторов, вы можете использовать протокол AnimatableModifier вместо ViewModifier. И вот там можно реализовать все то же самое, что и при анимации изменений форм, как мы делали выше.

struct BorderPosition: AnimatableModifier {
    var position: CGFloat
    let startDate: Date = Date()
    public var animatableData: CGFloat {
        get {
            print("reading position: \(position) at time \(Date().timeIntervalSince(startDate))")
            return position
        }
        set {
            position = newValue
            print("setting position: \(position) at time \(Date().timeIntervalSince(startDate))")
        }
    }
    func body(content: Content) -> some View {
...
    }
    ...
}


Вот теперь все правильно. Сообщения в консоли помогают понять, что работает действительно наша анимация, и .animation(nil) внутри модификатора ей совершенно не мешает. Но давайте все же разбираться, как именно это работает.

Для начала, нужно понять, что же такое модификатор.


Вот у нас есть View. Как я говорил в предыдущей части, это структура с хранимыми параметрами и инструкцией по сборке. Эта инструкция, по большому счету, содержит не последовательность действий, коей является обычный код, который мы пишем в не-декларативном стиле, а простой перечень. В нем перечислены другие View, примененные к ним модификаторы, и контейнеры, в которые те включены. Контейнеры нам пока не интересны, а вот о модификаторах давайте поговорим подробнее.

Модификатор, это опять таки структура с хранимыми параметрами, и инструкцией по обработке View. Это фактически такая же инструкция как у View — мы можем применять другие модификаторы, использовать контейнеры (как например, я использовал GeometryReader чуть выше) и даже другие View. Вот только у нас есть входящий content, и мы должны его как-то изменить с помощью этой инструкции. Параметры модификаторов — это часть инструкции. Но самое интересное, что они хранятся.

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

Немного примитивных аналогий


Как бы мы в декларативном стиле описали стол? Ну, мы бы перечислили 4 ножки и столешницу. Объединили бы их в какой-то контейнер, и с помощью некоторых модификаторов прописали бы, как они друг с другом скрепляются. Например, к каждой ножке указали бы ориентацию по отношению к столешнице, и положение — какая ножка к какому углу приколочена. Да, инструкцию мы после сборки можем выбросить, но гвозди останутся в столе. Так и модификаторы. На выходе из функции body у нас не совсем стол. С помощью body мы создаем элементы стола (view), и крепежные элементы (модификаторы), и раскладываем все это по ящичкам. Сам стол собирает робот. Какой крепеж ты положишь в ящик к каждой ножке, такой стол у тебя и получится.

Функция .modifier(BorderPosition(position: position)), с помощью которой мы превратили структуру BorderPosition в модификатор, лишь кладет дополнительный винтик в ящичек к ножке стола. Структура BorderPosition и есть этот винтик. Рендер, в момент отрисовки, берет этот ящик, достает из него ножку (Rectangle() в нашем случае), и последовательно получает все модификаторы из списка, с сохраненными в них значениями. Функция body каждого модификатора, это инструкция, как этим винтиком прикрутить ножку к столешнице, а сама структура с хранимыми свойствами, это и есть тот винтик.

Почему это важно понимать именно в контексте анимации? Потому что анимация, позволяет изменять параметры одного модификатора не затрагивая других, после чего заново отрендерить изображение. Если вы сделаете то же самое с помощью изменения каких-то @State параметров — это вызовет повторную инициализацию вложенных View, структур-модификаторов и так далее, всего по цепочке. А анимация — нет.

На самом деле, когда мы при нажатии кнопки изменяем значение position — оно меняется. Сразу и до конца. Никаких промежуточных состояний в самой переменной не хранится, чего нельзя сказать о модификаторе. Для каждого нового кадра значения параметров модификатора меняется согласно прогрессу текущей анимации. Если анимация длится 1 секунду, то каждую 1/60 долю секунды (iphone показывает именно такое количество кадров в секунду) значение animatableData внутри модификатора будет изменяться, затем оно же будет прочитано рендером для отрисовки, после чего, еще через 1/60 долю секунды будет изменено снова, и снова прочитано рендером.

Что характерно, мы сначала получаем конечное состояние всей View, запоминаем его, и только потом механизм анимации начинает подсовывать в модификатор интерполированные значения position. Начальное состояние уже нигде не хранится. Где-то в недрах SwiftUI хранится только разница между начальным и конечным состоянием. Эта разница, каждый раз умножается на долю прошедшего времени. Именно так рассчитывается интерполированное значение, которое впоследствии и подставляется в animatableData.

Разница = Стало — Было

ТекущееЗначение = Стало — Разница * (1 — ВремениПрошло)
ВремениПрошло = ВремяСНачалаАнимации / ДлительностьАнимации


ТекущееЗначение нужно рассчитать столько раз, сколько кадров нам нужно показать.

Почему не используется “Было” в явном виде? Дело в том, что SwiftUI не хранит исходное состояние. Хранится только разница: так, в случае какого-то сбоя, можно просто отключить анимацию, и перейти к актуальному состоянию “стало”.

Данный подход позволяет сделать анимацию реверсивной. Допустим, где-то посредине одной анимации, пользователь снова нажал какую-то кнопку, и мы снова изменили значение этой же переменной. В этом случае, все что нам нужно сделать, чтобы красиво обыграть это изменение, это в качестве “Было” взять ТекущееЗначение внутри анимации на момент нового изменения, запомнить новую Разницу, и начать новую анимацию исходя из нового “Стало” и новой “Разницы”. Да, на самом деле эти переходы от одной анимации к другой могут быть чуть сложнее, чтобы имитировать инерцию, но смысл, я думаю, понятен.

Что интересно, так это то, что анимация каждый кадр запрашивает текущее значение внутри модификатора (используя геттер). Это, как можно заметить по служебным записям в логе, отвечает за состояние “Стало”. Затем, с помощью сеттера мы устанавливаем новое состояние, текущее для данного кадра. После этого, для следующего кадра снова запрашивается текущее значение из модификатора — и оно снова “Стало”, т.е. конечное значение к которому движется анимация. Вероятно, для анимации используются копии структур-модификаторов, и для получения значения “Стало” используется геттер одной структуры (настоящего модификатора актуальной View), а сеттер другой (временного модификатора используемого для анимации). Я не придумал способа как в этом удостовериться, но по косвенным признакам все выглядит именно так. Так или иначе, изменения внутри анимации никак не сказываются на хранимом значении структуры модификатора настоящей View. Если у вас есть идеи, как выяснить точно, что именно происходит с геттером и сеттером, пишите об этом в комментариях, я обновлю статью.

Несколько параметров


До этого момента у нас был только один параметр для анимации. Может возникнуть вопрос, а как быть, если в модификатор передается больше одного параметра? А если их оба требуется анимировать одновременно? Вот как с модификатором frame(width: height:) например. Мы ведь можем одновременно изменить и ширину и высоту данной View, и хотим чтобы изменение происходило одной анимацией, как это сделать? AnimatableData параметр ведь один, что в него подставить?

Если разобраться, то к animatableData у Apple только одно требование. Тип данных, который вы в него подставляете должен удовлетворять протоколу VectorArithmetic. Этот протокол требует об объекта обеспечения минимальных арифметических операций, которые необходимы для того чтобы можно было сформировать отрезок из двух значений, и интерполировать точки внутри этого отрезка. Операции, необходимые для этого — сложение, вычитание и умножение. Сложность в том, что мы должны производить эти операции с одним объектом, хранящим в себе несколько параметров. Т.е. мы должны весь перечень наших параметров упаковать в некий контейнер, который будет представлять собой вектор. Apple предоставляют такой объект из коробки, и предлагают нам пользоваться готовым решением для не очень сложных случаев. Он называется AnimatablePair.

Изменим немного задачу. Нам нужен новый модификатор, который будет не только двигать зеленую полосу, но и изменять ее высоту. Это будут два независимых параметра модификатора. Я не буду приводить полный код всех изменений, которые для этого нужно сделать, его вы можете посмотреть на гитхабе в файле SimpleBorderMove. Покажу только сам модификатор:

struct TwoParameterBorder: AnimatableModifier {
    var position: CGFloat
    var height: CGFloat
    let startDate: Date = Date()
    public var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get {
           print("animation read position: \(position), height: \(height)")
           return AnimatablePair(position, height)
        }
        set {
            self.position = newValue.first
            print("animating position at \(position)")
            self.height = newValue.second
            print("animating height at \(height)")
        }
    }
    init(position: CGFloat, height: CGFloat){
        self.position = position
        self.height = height
    }
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
                .animation(nil)
                .offset(x: -geometry.size.width / 2 + geometry.size.width * self.position, y: 0)
                .frame(height: self.height * (geometry.size.height - 20) + 20)
        }
    }
}


Добавил еще ползунок, и кнопку рандомного изменения обоих параметров сразу в родительской view SimpleView, но там ничего интересного, так что за полным кодом добро пожаловать на гитхаб.

Все работает, мы действительно получаем согласованное изменение пары параметров, упакованных в AnimatablePair кортеж. Неплохо.

Ничего не смущает в этой реализации? Лично я напрягся, увидев вот такую конструкцию:

        
self.position = newValue.first
self.height = newValue.second

Я ведь нигде не указывал, какой из этих параметров должен идти первым, а какой вторым. Как вообще SwiftUI решает, какое значение запихнуть в first, а какое в second? Ну не имена параметров функции же сопоставляет с именами атрибутов структуры?

Первой идеей был порядок следования атрибутов в параметрах функции и их типов, как это происходит с @EnvironmentObject. Там мы тоже просто кладем значения в ящик, не присваивая им никаких ярлыков, и затем достаем их оттуда, также никаких ярлыков не указывая. Там имеет значение тип, а в рамках одного типа — порядок. В каком порядке положили в ящик, в таком же и доставайте. Я перепробовал разный порядок аргументов функции, порядок аргументов инициализации структуры, порядок атрибутов самой структуры, в общем побился головой об стену, но так и не смог запутать SwiftUI чтобы он начал анимировать позицию значениями высоты и наоборот.

Тогда-то до меня доперло. Я сам указываю, какой параметр будет первым, а какой вторым в геттере. SwiftUI вообще не обязательно знать, как именно мы инициализируем эту структуру. Он может получить значение animatableData до изменения, получить его же после изменения, вычислить разницу между ними, и эту же разницу, масштабированную пропорционально интервала прошедшего времени, вернуть нам в сеттер. Ему вообще не нужно ничего знать о самом значении внутри AnimatableData. И если ты не напутаешь с порядком следования переменных в двух соседних строчках, то все будет в порядке, какой бы сложной ни была структура остального кода.

Но давайте проверим. Мы ведь можем создать свой контейнер-вектор (ох люблю я это, создавать свою реализацию существующих объектов, вы это еще по прошлой статье могли заметить).



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

Таким же образом наполняем наш объект требуемыми методами для протокола AdditiveArithmetic (VectorArithmetic включает в себя и его поддержку).

struct MyAnimatableVector: VectorArithmetic{
    static func - (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position - rhs.position, height: lhs.height - rhs.height)
    }
    
    static func + (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position + rhs.position, height: lhs.height + rhs.height)
    }
    
    mutating func scale(by rhs: Double) {
        self.position = self.position * CGFloat(rhs)
        self.height = self.height * CGFloat(rhs)
    }
    
    var magnitudeSquared: Double{
         Double(self.position * self.position) + Double(self.height * self.height)
    }
    
    static var zero: MyAnimatableVector{
        MyAnimatableVector(position: 0, height: 0)
    }
    
    var position: CGFloat
    var height: CGFloat
}

  • Думаю, зачем нужны + и — очевидно.
  • scale — это функция масштабирования. Мы берем разницу “Было — Стало”, и умножаем ее на текущую стадию показа анимации (от 0 до 1). “Стало + Разница * (1 — Стадия) ” и будет текущее значение, которое мы должны подсунуть в animatableData
  • Zero, вероятно, нужно для инициализации новых объектов, значения которых будут использоваться для анимации. Анимация использует .zero в самом начале, но я не смог разобраться как именно. Впрочем, не дума что это важно.
  • magnitudeSquared — это скалярное произведение данного вектора с самим собой. Для двумерного пространства, это означает длину вектора возведенную в квадрат. Вероятно, это используется для возможности сравнить два объекта между собой не поэлементно, а целиком. Вроде бы для целей анимации не используется.

Вообще говоря, в поддержку протокола входят так же и функции “-=” “+=”, но для структуры они могут быть сгенерированы автоматически в таком виде

    static func -= (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs - rhs
    }

    static func += (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs + rhs
    }


Для наглядности, я изложил всю эту логику в виде схемы.

Картинка кликабельна.

Красным выделено то, что мы получаем в процессе анимации — каждый следующий тик (1/60 секунды) таймер выдает новое значение t, и мы, в setter нашего модификатора получаем новое значение animatableData. Примерно так и работает анимация под капотом. При этом, важно понимать, что модификатор, это хранимая структура, и для показа анимации используется копия актуального модификатора с новым, актуальным состоянием.

Почему AnimatableData может быть только структурой


Есть еще один момент. Вы не сможете использовать классы в качестве объекта AnimatableData. Формально, вы можете описать для класса все необходимые методы соответствующего протокола, но это не взлетит, и вот почему. Как известно, класс — это ссылочный тип данных, а структура — тип данных основанный на значениях. Когда вы создаете одну переменную на основе другой, в случае класса, вы копируете ссылку на этот объект, а в случае структуры, вы создаете новый объект на основе значений имеющегося. Вот небольшой пример иллюстрирующий это различие:

    struct TestStruct{
        var value: CGFloat
        mutating func scaled(by: CGFloat){
            self.value = self.value * by
        }
    }
    class TestClass{
        var value: CGFloat
        func scaled(by: CGFloat){
             self.value = self.value * by
        }
        init(value: CGFloat){
            self.value = value
        }
    }
        var stA = TestStruct(value: 5)
        var stB = stA
        stB.scaled(by: 2)
        print("structs: a = \(stA.value), b = \(stB.value))") //structs: a = 5.0, b = 10.0)
        var clA = TestClass(value: 5)
        var clB = clA
        clB.scaled(by: 2)
        print("classes: a = \(clA.value), b = \(clB.value))") //classes: a = 10.0, b = 10.0)

С анимацией происходит ровно то же самое. У нас есть AnimatableData объект, представляющий разницу между “было” и “стало”. Нам нужно вычислить часть этой разницы, чтобы отразить на экране. Для этого мы должны скопировать эту разницу и умножить ее на число, представляющее текущую стадию анимации. В случае со структурой, это не повлияет на саму разницу, а в случае с классом — повлияет. Первый же кадр, который мы отрисовываем — это состояние “было”. Для этого, мы должны вычислить Стало + Разница * ТекущаяСтадия — Разница. В случае класса, в первом же кадре мы умножить разницу на 0, обнуляя ее, и все последующие кадры отрисовываются так, что разница = 0. т.е. анимация вроде бы отрисовывается исправно, но фактически мы видим мгновенный переход из одного состояния в другое, как будто никакой анимации нет.

Наверное можно написать какой-то низкоуровневый код, создающий новые адреса памяти для результата умножения — но зачем? Можно просто пользоваться структурами — они для того и созданы.

Для желающих досконально разобраться в том, как именно SwiftUI вычисляет промежуточные значения, какими именно операциями и в какой момент, в проекте понатыкано сообщений в консоль. К тому же, я там вставил sleep 0.1 секунду для имитации ресурсоемких вычислений внутри анимации, развлекайтесь :)

Анимация появления на экране: .transition()


До этого момента, мы говорили об анимации изменения значения, передаваемого в модификатор или форму. Это довольно сильные инструменты. Но есть еще один инструмент, который также использует анимацию — это анимация появления и исчезновения View.

В прошлой статье мы говорили о том, что в декларативном стиле if-else, это вовсе не управление потоком выполнения кода в рантайме, а скорее view Шрёдингера. Это контейнер содержащий одновременно две View, который решает, какую из них показать в соответствии с определенным условием. Если вы упускаете блок else, то вместо второй view показывается EmptyView.

Переключение между этими двумя View также можно анимировать. Для этого используется модификатор .transition().

struct TransitionView: View {
    let views: [AnyView] = [AnyView(CustomCircleTestView()), AnyView(SimpleBorderMove())]
    @State var currentViewInd = 0
    var body: some View {
        VStack{
            Spacer()
            ZStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                        }
                    }
                }
            }
            HStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    RoundedRectangle(cornerRadius: 10)
                        .fill(ind == self.currentViewInd ? Color.green : Color.gray)
                        .overlay(
                            Text("\(ind + Int(1))"))
                        .onTapGesture{
                            withAnimation{
                                self.currentViewInd = ind
                            }
                    }
                }
            }
                .frame(height: 50)
            Spacer()
        }
    }
}

Давайте смотреть, как это работает. Прежде всего, мы заранее, еще на этапе инициализации родительской view создали и поместили несколько View в массив. Массив имеет тип AnyView, поскольку элементы массива должны иметь один и тот же тип, иначе их не получиться использовать в ForEach. Opaque result type из предыдущей статьи, помните?

Далее, мы прописали перебор индексов этого массива, и для каждого из них выводим view по этому индексу. Мы вынуждены так делать, а не перебирать сразу View, потому, что для работы с ForEach нужно каждому элементу присваивать внутренний идентификатор, чтобы SwiftUI мог перебирать содержимое коллекции. Как альтернатива, нам бы пришлось в каждой View создавать реквизит-идентификатор, но зачем, если можно использовать индексы?

Каждую view из коллекции мы оборачиваем в условие, и показываем только в том случае, если активна именно она. Однако, конструкция if-else просто так здесь не может существовать, компилятор принимает ее за управление потоком, поэтому мы заключаем все это в Group для того чтобы компилятор точно понимал, что это именно View, а если точнее, инструкция для ViewBuilder создать опциональный контейнер ConditionalContent<View1, View2>.

Теперь, при изменении значения currentViewInd, SwiftUI скрывает предыдущую активную view, и показывает текущую. Как вам такая навигация внутри приложения?


Все что осталось сделать, это поместить изменение currentViewInd в обертку withAnimation, и переключение между окнами станет плавным.

Добавим модификатор .transition, указав в качестве параметра .scale. Это сделает анимацию появления и исчезновения каждой из этих view иной — с использованием масштабирования, а не прозрачности, используемой SwiftUI по умолчанию.

                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                                .transition(.scale)
                        }
                    }
                }


Обратите внимание, view появляются и исчезают с одинаковой анимацией, только исчезновение прокручивается в обратном порядке. На самом деле, мы можем по отдельности назначать анимацию как для появления, так и для исчезновения view. Для этого используется асимметричный transition.

                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                               .transition(.asymmetric(
                                    insertion: insertion: AnyTransition.scale(scale: 0.1, anchor: .leading).combined(with: .opacity),
                                    removal: .move(edge: .trailing)))
                        }
                    }


Для появления на экране используется та же .scale анимация, но теперь мы уточнили параметры ее использования. Она начинается не с нулевого размера (точки), а с размера 0.1 от обычного. И стартовая позиция маленького окошка находится не по центру экрана, а сдвинута к левому краю. Кроме того, за появление отвечает не один transition, а целых два. Их можно комбинировать с помощью .combined(with:). В данном случае, мы добавили прозрачности.

Исчезновение view теперь отрисовывается другой анимацией — смахиванием за правый край экрана. Я сделал анимацию чуть медленнее, чтобы вы успели рассмотреть это.

И как всегда, мне не терпится написать свой вариант транзитной анимации. Это еще проще чем анимированные формы или модификаторы.

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            .clipped()
    }
}

extension AnyTransition {
    static func spinIn(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: -90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
    static func spinOut(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: 90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
}


Для начала мы пишем обычный модификатор в который передаем некое число — угол поворота в градусах, а также точку, относительно которой этот поворот происходит. Затем, мы расширяем тип AnyTransition двумя функциями. Можно было бы и одной, но так мне показалось удобнее. Я посчитал, что проще назначить говорящие имена каждой из них, чем управлять градусами поворота непосредственно в самой View.

Тип AnyTransition имеет статический метод modifier, в который мы передаем два модификатора, а получаем объект AnyTransition, описывающий плавный переход от одного состояния, к другому. identity — это модификатор обычного состояния анимируемой View. Active — это состояние начала анимации для появления view, или окончания анимации для исчезновения, т.е. другой конец отрезка, состояния внутри которого будут интерполированы.

Итак, spinIn подразумевает, что я буду использовать ее для появления view из-за границ экрана (или выделенного для View пространства) путем вращения по часовой стрелке вокруг указанной точки. spinOut подразумевает что view будет исчезать точно так же, вращаясь вокруг той же точки, так же по часовой.

По моей задумке, если использовать одну и ту же точку для появления и исчезновения View, получится эффект вращения всего экрана вокруг этой точки.

Вся анимация строится на стандартной механике модификаторов. Если вы пишите полностью кастомный модификатор, вы должны реализовать требования протокола AnimatableModifier, как мы поступили ранее с TwoParameterBorder, или использовать внутри него встроенные модификаторы, которые предоставляют собственную дефолтную анимацию. Я в данном случае положился на встроенную анимацию .rotationEffect() внутри моего SpinTransitionModifier модификатора.

Модификатор .transition() всего лишь уточняет что считать точкой начала и конца анимации. Если для анимации обычного изменения мы должны запросить состояние AnimatableData до начала анимации, затем запросить AnimatableData модификатора актуального состояния, вычислить разницу, и затем анимировать уменьшение этой разницы от 1 до 0, то .transition() всего лишь меняет исходные данные. Вы не привязаны к состоянию вашей View, вы не опираетесь на нее. Вы в явном виде сами указываете начальное и конечное состояние, из них вы получаете AnimatableData, вычисляете разницу и анимируете ее. Затем, по окончанию анимации, на первый план выходит ваша актуальная View.

Кстати, identity — это модификатор который так и останется примененным к вашей View по окончанию анимации. В противном случае, ошибка здесь вела бы к скачкам при окончании анимации появления, и начале анимации исчезновения. Так что transition можно рассматривать как “два в одном” — применение определенного модификатора непосредственно ко View + возможность анимировать его изменения при возникновении и исчезновении View.

Честно сказать, этот механизм управления анимации мне кажется очень сильным, и мне немного жаль, что мы не можем использовать его для любой анимации. Я бы не отказался от такого для создания бесконечной замкнутой анимации. Впрочем, о ней мы поговорим уже в следующей статье.

Чтобы лучше видеть, как происходит само изменение, я заменил наши тестовые View на элементарные квадраты, подписанные номерами, и выделенные рамкой.

                    Group{
                        if ind == self.currentViewInd{
                            //self.views[ind]
                            Rectangle()
                                .fill(Color.gray)
                                .frame(width: 100, height: 100)
                                .border(Color.black, width: 2)
                                .overlay(Text("\(ind + 1)"))
                              .transition(.asymmetric(
                                  insertion: .spinIn(anchor: .bottomTrailing),
                                  removal: .spinOut(anchor: .bottomTrailing)))
                        }
                    }


А чтобы еще лучше можно было рассмотреть это движение, я убрал .clipped() из модификатора SpinTransitionModifier:

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            //.clipped()
    }
}


Кстати, теперь нужда в собственном модификаторе SpinTransitionModifier вообще отпала. Он был создан только ради того, чтобы объединить два модификатора, rotationEffect и clipped() в один, чтобы анимация поворота не выходила за рамки области, выделенной для нашей View. Теперь же, мы можем внутри .modifier() использовать непосредственно .rotationEffect(), посредник в виде SpinTransitionModifier нам не нужен.

Когда умирают View


Интересный момент, это жизненный цикл View в случае помещения ее в if-else. View, хоть и инициирована, и записана в качестве элемента массива, не хранится в памяти. Все ее State параметры сбрасываются на дефолтные в момент следующего появления на экране. Это почти то же самое что и инициализация. Несмотря на то, что сам объект-структура все еще существует, рендер убрал ее из своего поля зрения, для него ее нет. С одной стороны, это уменьшает использование памяти. Если у вас в массиве большое количество сложных View, рендер должен был бы все их постоянно отрисовывать, реагируя на изменения — это негативно влияло на производительность. Если я не ошибаюсь, до обновления XCode 11.3 так и было. Теперь же, не активные view выгружаются из памяти рендера.

С другой стороны, мы должны вынести всё важное состояние за рамки этой View. Для этого лучше всего использовать @EnvironmentObject переменные.

Возвращаясь к жизненному циклу, следует еще отметить, что модификатор .onAppear{}, если таковой прописан внутри данной View, срабатывает сразу же после изменения условия и появления View на экране, еще до того как анимация началась. Соответственно, onDisappear{} срабатывает после окончания анимации исчезновения. Имейте это в виду, если планируете их использовать вместе с transition-анимацией.

Что дальше?


Уф. Получилось довольно объемно, зато подробно, и, надеюсь, доходчиво. Честно говоря, я рассчитывал в рамках одной статьи рассказать еще и о радужной анимаци, но не смог вовремя остановиться с подробностями. Так что ждите продолжения.

В следующей части нас ожидает:

  • использование градиентов: линейный, круговой и угловой — нам все пригодится
  • Color — это вовсе не цвет: выбирайте с умом.
  • зацикленная анимация: как запустить и как остановить, и как остановить немедленно (без анимации изменения анимации — да, такая тоже есть)
  • текущая анимация потока: приоритеты, переопределения, разная анимация для разных объектов
  • подробно о таймингах анимации: тайминги мы будем гонять и в хвост и в гриву, вплоть до собственной реализации timingCurve (ох, держите меня семеро:))
  • как узнать текущий момент проигрываемой анимации
  • Если SwiftUI не достаточно

Обо всем этом я буду подробно рассказывать на примере создания радужной анимации, как на картинке:


Я не стал идти легким путем, а собрал все грабли, до которых смог дотянуться, воплощая эту анимацию на описанных выше принципах. Рассказ об этом должен получиться весьма содержательным, и богатым на трюки и всевозможные хаки, о которых мало где писали, и которые пригодятся тем, кто решит стать первопроходцем в SwiftUI.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 9

    +1
    Крутая статья! Рад, что наш материал вас зацепил и вы пошли еще дальше и еще глубже! Больше информации по теме полезно всему нашему дружному сообществу iOS разработчиков :) Ждем продолжения.
      +1
      Благодарю! Вынужден признать, что задача оказалась не так проста, как могло показаться на первый взгляд. При подготовке статьи мне пришлось очень не слабо прокачать свое понимание процессов в SwiftUI. Но, тем интереснее:)
        0
      +1
      Перечитал статью, и понял, что много текста, мало схем. Решил это исправить, набросав примерную схемку:
        0
        Крутая статья, спасибо!
        Андрей, вы говорите, что вы начинающий, но знания у вас (по моему мнению) на уровне Middle. Скажите, сколько времени вы потратили на изучение SwiftUI?
          0
          Тут сложно говорить о каких-то конкретных сроках. Я несколько месяцев изучал swift вообще. Я из 1с перешел, для меня вообще все было непривычным, не только конкретный синтаксис. Где-то в декабре, наверное, я переключился на SwiftUI и начал свой пет-проект. Но я ленивая жопа. К тому же, это мой путь восстановления от выгорания, так что свою продуктивность в изучении я бы не назвал высокой. Я бы сказал что очень упорный и мотивированный человек уложился бы в эти же сроки, изучая все это в свободное от основной работы время. Жаль, я не такой:(

          Действительно по-полочкам я для себя все раскладываю в процессе подготовки очередной статьи. Я трепетно отношусь к тому, насколько полезен должен быть мой материал, потому много экспериментирую, в целях проверки того или иного утверждения. Это очень прокачивает детальное понимание механизмов. Сам бы я, вероятно, не стал бы так углубляться (хотя это скорее про вторую часть, которая уже почти готова), но, как писал @nmivan, пацанам должно быть не стыдно показать.
          0
          Статья крайне познавательная, спасибо. От себя я бы крайне рекомендовал посмотреть курсы Стэнфордского университета 2020 года. Если кто, вдруг, не в курсе, на ютубе уже 10 лекций по SwiftUI, одна из которых как раз посвящена анимации. Сначала посмотреть ее, она даст общие представления, а потом уже читать эту статью для более глубокого погружения. Вообще по SwiftUI мало информации, в-основном, какие-то примитивные примеры, но при попытке сделать что-то более сложное постоянно сталкиваешься с проблемами. Я, например, потратил просто кучу времени на то, чтобы сделать редактируемый список с выбором строки. Кстати, Apple сделала демо-приложение Landmarks под все свои платформы (https://developer.apple.com/tutorials/swiftui/), я думаю, будет полезным посмотреть на то, как выглядит «эталонный» код.
            0
            У лекций на ютубе одна проблема. Их сложно гуглить когда тебя интересует какой-то конкретный вопрос. Поэтому я больше уважаю текстовый формат.

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

          Самое читаемое