![image](https://habrastorage.org/webt/1u/td/t6/1utdt6xa6tcsov4biwj2ura36s0.png)
Это — вторая часть повествования об анимации в SwiftUI. В первой статье мы на простых примерах разбирали основы, как вообще в SwiftUI работает анимация. В данной части пойдем дальше, и подробно разберем анонсированный пример с радужной анимацией.
![image](https://habrastorage.org/webt/d7/am/oa/d7amoa5ktffdezfstl4a1pisyjw.gif)
Под катом вас ждет разбор таймингов анимации, создание собственного механизма тайминговой кривой для работы в зацикленной линейной анимации, примеры реализации разных градиентов и несколько интересных приемов, которые помогут понять работу анимации под капотом.
Напомню, что идею реализовать такую анимацию я подсмотрел вот тут. Там авторы пошли по пути наименьшего сопротивления, и реализовали самый очевидный способ реализации этого концепта, анимировав shape. Принцип прост: каждая волна представляет собой контур очерченный кривой Безье, а параметр «время» влияет на положение правой границы этой волны.
Вот так выглядит анимация в готовом виде:
![image](https://habrastorage.org/getpro/habr/post_images/7a7/cbf/7a0/7a7cbf7a05561123ceff4be47fff5ed6.gif)
Для наглядности я немного разнесу волны по вертикали. Это несложно сделать, я просто добавил в конце NotchWave перемещение всего контура вниз на высоту волны, в соответствии с текущей фазой анимации:
return p.applying(.init(translationX: 0, y: height * self.phase))
Ну и еще .drowingGroup() надо не забыть отключить — он не подразумевает выход изображения за границы фиксированного фрейма.
![](https://habrastorage.org/webt/z3/zv/bi/z3zvbiseywno8t1fnt-i0ugcqgm.gif)
Подход простой, выдает требуемый результат… но мне показалось слишком скучно. Мой путь оказался куда длиннее, и не сказал бы что конечный результат будет хоть в чем-то лучше, но зато по пути будет интересно.
Если вы еще не очень хорошо представляете себе принципы работы анимации в SwiftUI, настоятельно рекомендую прочитать предыдущую статью. А желающих покопаться в исходниках я приветствую на гитхабе.
Работа с градиентами
В самом начале, я выбрал другой подход. Я посчитал, что намного полезнее разобраться с анимацией смещения объектов, чем с анимацией форм (я по-прежнему называю shape-структуру формой). Смещение может работать с чем угодно — различными фигурами, изображениями, другими View. В качестве учебной задачи, я захотел реализовать сглаженный цветовой переход от одной волны к другой. В оригинальном концепте что-то такое вроде бы такое было.
Всего SwiftIUI предлагает три возможных использования градиента — линейный, круговой и угловой.
![](https://habrastorage.org/webt/t7/o6/7d/t7o67dwcm_v5svufist7tfp0kwe.png)
В коде это выглядит каким-то блочным конструктором. Обратите внимание, как вся волна разбита на сегменты.
![](https://habrastorage.org/webt/wa/af/el/waafeltt9cgiasfzxg1gjexwc8i.png)
Линейный градиент характеризуется точкой начала и окончания градиента. Это не CGPoint точки с абсолютными координатами (x:y:), а UnitPoint точки, т.е. относительными координатами, где x:y: задаются в долях от ширины и высоты области, выделенной под данную View. Также есть предопределенные точки, соответствующие углам(.topLeading, .bottomTrailing и т.д.) и серединам сторон (.top, .trailing и т.д.).
Rectangle()
.fill(LinearGradient(
gradient: Gradient(stops: [
.init(color: self.end, location: 0),
.init(color: self.middle, location: 1 - self.middleGradientStop),
.init(color: self.start, location: 1)]),
startPoint: .leading,
endPoint: .trailing))
.frame(width: self.gradientLength)
Сегмент с линейным градиентом — “резиновый”. Он имеет фиксированную ширину, но его высота не указана. Таким образом он заполнит весь предоставленный объем.
Чуть сложнее история с круговым градиентом. Мы указываем точку — центр окружности, радиус начала градиента и радиус окончания градиента. Радиусы уже в абсолютном выражении, т.е. поинтах.
Центр окружности все еще задается в виде UnitPoint.
Rectangle()
.fill(RadialGradient(
gradient: Gradient(stops: [
.init(color: self.start, location: 0),
.init(color: self.middle, location: self.middleGradientStop),
.init(color: self.end, location: 1)]),
center: .bottomTrailing,
startRadius: self.topRadius,
endRadius: self.topRadius + self.gradientLength)
)
.frame(height: self.topRadius)
Кусочек с верхней окружностью имеет фиксированную высоту, но растягивается по горизонтали. Мы ведь задаем конкретные фиксированные радиусы в поинтах, так что нет нужды клеить дополнительно растягивающийся прямоугольник базового цвета, как в случае с линейным градиентом.
Сложнее всего применять угловой градиент. Он задается начальным и конечным углом, а так же направлением. Как можно видеть по листингу кода, работать с углами не очень удобно.
Приходится вспоминать школьный курс тригонометрии, чтобы вычислить требуемый угол, если тебе нужно начать угловой градиент в одной точке и закончить в другой.
Rectangle()
.fill(AngularGradient(
gradient: Gradient(stops: [
.init(color: self.end, location: 0),
.init(color: self.middle, location: 1 - self.angularGradientMiddleStop(blockWidth: geometry.size.width)),
.init(color: self.start, location: 1)]),
center: .bottomLeading,
startAngle: self.directionTo(gradientPart: self.start, blockWidth: geometry.size.width),
endAngle: Angle(degrees: 360)))
...
func directionTo(gradientPart: Color, blockWidth: CGFloat) -> Angle{
let angleOf = gradientAngles(blockWidth: blockWidth)
var angle = Angle.zero
switch gradientPart{
case start: angle = angleOf.start
case middle: angle = angleOf.middle
case end: angle = angleOf.end
default: fatalError("there is no gradient stop with that color: \(gradientPart)")
}
return angle
}
func gradientAngles(blockWidth: CGFloat) -> (start: Angle, middle: Angle, end: Angle){
let blockHeight = self.bottomRadius
let center = CGPoint(x: 0, y: blockHeight)
let topRight = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength)
let topGradientStarts = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength * (1 - self.middleGradientStop))
let startAngle = center.radialDirection(to: topRight)
let middleAngle = center.radialDirection(to: topGradientStarts)
let endAngle = Angle(degrees: 360)
return (start: startAngle, middle: middleAngle, end: endAngle)
}
...
extension CGPoint{
func radialDirection(to point: CGPoint) -> Angle{
let deltaX = point.x - self.x
let deltaY = point.y - self.y
var angle = Angle(degrees: 0)
if deltaX == 0{
if deltaY > 0{
angle = Angle(degrees: 90)
}else{
angle = Angle(degrees: 270)
}
}else if deltaY == 0{
if deltaX > 0{
angle = Angle(degrees: 0)
}else{
angle = Angle(degrees: 180)
}
}else if deltaX > 0 && deltaY > 0{
angle = Angle(radians: atan(Double(deltaY / deltaX)))
}else if deltaX > 0 && deltaY < 0{
angle = Angle(degrees: 270) + Angle(radians: atan(Double(deltaX / -deltaY)))
}else if deltaX < 0 && deltaY > 0{
angle = Angle(degrees: 90) + Angle(radians: atan(Double(-deltaX / deltaY)))
}else if deltaX < 0 && deltaY < 0{
angle = Angle(degrees: 180) + Angle(radians: atan(Double(deltaY / deltaX)))
}
return angle
}
}
Угловой градиент, представляющий собой хвост волны, тоже резиновый, но уже по ширине — он занимает половину оставшегося размера. Собственно, поэтому столько тригонометрии. Я знаю положение конкретных точек на границе экрана, где должен быть определенный цвет (т.к. я склеиваю круговой градиент с угловым). При этом, значения углов зависят не только от радиусов нижнего градиента, но и от ширины самого блока с градиентом. Впервые со школы я брал в руки карандаш и вспоминал алгебру:)
Все три варианта используют единое описание цветов внутри градиента. Это простое перечисление цветов в массиве. Их распределение будет равномерным на заданном отрезке. Можно уточнить распределение, используя массив элементов Stop. Фактически, это то же перечисление цветов, только для каждого из них указывается еще и координата на единичном отрезке, где он будет расположен.
Таким образом, я создал волну примерно такой же формы, что и в оригинальном концепте. Однако переход от одного цвета к другому теперь не резкий, а сглаженный, что потенциально должно сделать анимацию плавнее.
Color — это не то чем кажется
Есть один момент связанный с Color. Для создания эффекта накатывающей волны, я решил сделать переход между цветами не совсем линейным. Если присмотреться, можно заметить, что у самой кромки градиент резче, а чем дальше от кромки, тем плавнее он перетекает в основной цвет.
![](https://habrastorage.org/webt/hb/k-/f-/hbk-f-u1tcrri8furxdgeg8tx50.png)
Я добился этого вычислением промежуточного цвета, и созданием градиента с таким распределением:
начальный цвет — 0
промежуточный цвет — 0.3
конечный цвет — 1
Проблема возникла с вычислением среднего двух цветов. SwiftUI подразумевает использование объекта Color для градиентов, а я и повелся. На самом деле, если вы хотите работать с цветами именно как с RGB-объектами, закладывайте изначально в свою модель использование UIColor, потому что в Color нет доступа непосредственно к цвету. Обратно в UIColor его тоже так просто не конвертируешь. Единственное
И это не ошибка, не упущение. Смысл в том, что SwiftUI в объекте Color не дублирует функционал UIColor. Если вам нужна работа с rgb каналами — используйте именно его. Color в SwiftUI — это View имеющая некоторый базовый цвет, конкретное значение которого может несколько изменяться в зависимости от расположения звезд на небе — конкретный rgb цвет определяется только в момент отрисовки на экране. В документации сказано
SwiftUI only resolves it to a concrete value just before using it in a given environment., но что имеется в виду под environment: цвет стенки за спиной пользователя, или тема оформления IOS — непонятно. Если вам это не подходит, используйте UIColor изначально.
Бесконечная анимация
Анимация указывается модификатором .animation() и запускается после инициализации View в модификаторе
.onAppear()
путем изменения @State
переменной. В результате, модификатор .rotationEffect()
подписывается на получение animatableData в промежутке от «было» к «стало», согласованных с системным таймером. Мы говорили об этом в прошлой части.struct AnimatedRectObservedObject: View{
@State var angle: Double = 0
var body: some View{
return VStack{
Spacer()
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: angle))
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: false))
Spacer()
}
.onAppear{
self.angle += 90
}
}
}
![](https://habrastorage.org/webt/fl/dw/_0/fldw_0xguydvz5fe_-2tpcp3qkm.gif)
Запуская бесконечную анимацию таким образом, вы должны понимать, что любое последующее изменение анимированных модификаторов ее «сломает». Например, если после запуска, вы по какой-то причине измените значение angle на 0, получите вот такой эффект:
![](https://habrastorage.org/webt/ad/vi/iv/adviivgxhwuw0rwwv1itknh2kie.gif)
Почему? Ответ в механизме анимации перехода от одной анимации к другой. В нашем примере, мы при первом появлении View на экране запустили анимированный переход от 0 к 90. View на самом деле хранит в себе только конечное значение 90, а исходное значение 0 вообще нигде не хранится. Механизм анимации знает текущее положение во времени анимации, и текущее значение AnimatableData. В точке времени 0.5 оно будет 45. Что произойдет, если в этот момент пользователь изменит значение на 0? Ответ: начнется анимация изменения значения с 45 до 0. Все так же зацикленная. Вот только визуально, цикл получается не замкнутым, а разорванным.
Кроме того, есть случаи, когда анимация так же «ломается» если в вашей View используется @ObservedObject, или иные параметры, вызывающие повторную отрисовку View. Для решения этой проблемы, у модификатора .animation есть параметр .animation(: value:). Передавая туда значение, мы указываем рендеру, что рестарт анимации нужен только при изменении этого значения.
Однако, давайте разбираться, как же остановить анимацию, если она нам больше не требуется. Для этого, нужно обеспечить две вещи. Очевидно, что мы должны сообщить SwiftUI что бесконечная анимация более не нужна, заменив ее обычной, конечной. Но этого недостаточно. Модификатор .animation() представляет собой инструкцию, какие тайминги нужно использовать, но само вращение описано внутри модификатора .rotationEffect(), и он уже подписан на получение новых значений угла поворота по таймеру. Для остановки вращения нам потребуется изменить еще и его значение.
struct AnimatedRectStopButton: View{
@State var angle: Double = 0
@State var animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
var body: some View{
VStack{
Spacer()
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: angle))
.animation(animation)
Spacer()
Text("toggle Animation").onTapGesture {
if self.angle == 90{
self.angle = 0
self.animation = .default
}else{
self.angle = 90
self.animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
}
}
}
.onAppear{
self.angle = 90
}
}
}
![](https://habrastorage.org/webt/-o/-d/ui/-o-dui_0ep12qm5zwovo2iedvjm.gif)
Так же как ранее, изменение значения angle приводило к старту новой анимации (которая выглядела сломанной), это изменение тоже будет анимировано, но теперь уже с таймингами обычной анимации. Именно поэтому, наш квадрат прекращает вращаться не мгновенно, а как бы с замедлением.
То же самое можно написать чуть более лаконично, если подвесить весь функционал на одну
@State
переменную типа вкл/выкл:struct AnimatedRectStopButton: View{
@State var isStarted = false
let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
var body: some View{
VStack{
Spacer()
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: isStarted ? 90 : 0))
.animation(isStarted ? animation : .default)
Spacer()
Text("toggle Animation").onTapGesture {
self.isStarted.toggle()
}
}
.onAppear{
self.isStarted.toggle()
}
}
}
До этого момента все было относительно легко. Но обратите внимание, что все события здесь генерируются внутри View.
.onAppear{}
вызывается системой, а onTapGesture{}
, понятно — пользователем. Однако, как быть, если вы хотите инкапсулировать всю анимацию внутри одной View, передавая в нее лишь вкл/выкл? SwiftUI не предполагает возможности из родительской view каких-то методов дочерних. Теоретически, вы можете хранить дочернюю View как структуру, и вызывать ее mutating-методы, но вот @State
переменные дочерних View таким образом поменять не получится, я пробовал — не работает. Единственный способ сделать что-то подобное, это воспользоваться PassthroughSubject из Combine, как это и сделали в упомянутой статье.На самом деле все намного проще. Если четко уложить по полочкам в голове, что
@State
это внутреннее состояние View, и не пытаться манипулировать им извне, то правильное решение окажется очень простым:struct AnimatedRectStopButtonFromOutside: View{
var isStarted: Bool
let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
var body: some View{
VStack{
Spacer()
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: isStarted ? 90 : 0))
.animation(isStarted ? animation : .default)
Spacer()
}
}
}
struct AnimatedRectParentView: View{
@State var isOn = false
var body: some View{
VStack{
AnimatedRectStopButtonFromOutside(isStarted: isOn)
Text("toggle Animation").onTapGesture {
self.isOn.toggle()
}
}.onAppear(){
self.isOn = true
}
}
}
Нам не нужна в данном случае
@State
подписка на обновление View внутри дочерней view — она и так обновляется целиком при изменении внешнего для нее параметра. Иногда это не удобно. Иногда мы не хотели бы лишний раз инициализировать дочернюю view — например, чтобы не сломать анимацию, или в init() происходят какие-то сложные и ресурсоемкие вычисления (запросы например). В этих случаях лучше пользоваться объектными сущностями, за изменениями которых можно следить с помощью модификатора onReceive. Modifying state during view update
Есть еще с одним момент, достойный освещения. Мы оперировали только двумя значениями 0 и 90 градусов. Выключение анимации приводило к сбросу угла на 0. Но можно ли поставить анимацию на паузу прямо в тот момент, когда мы нажали кнопку, и снова ее продолжить с того же места при возобновлении? Давайте рассмотрим код, позволяющий это:
struct AnimatedRect: View{
@State var startTime: Date = Date()
@State var angle: Double = 0
@State var internalStarted: Bool
let externalStarted = true
var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
init(started externalStarted: Bool){
self.externalStarted = externalStarted
self._internalStarted = State(initialValue: !externalStarted)//forse to start animation
}
var body: some View{
//thats wrong. It just hiding a problem from SwiftUI not solving it
DispatchQueue.main.async {
if self.internalStarted && self.externalStarted == false{
// print("stop animation")
let timePassed = Date().timeIntervalSince(self.startTime)
let fullSecondsPassed = Double(Int(timePassed))
let currentStage = timePassed - fullSecondsPassed
self.internalStarted = false
let newAngle = self.angle - 90 + currentStage * 90
self.angle = newAngle
}else if self.internalStarted == false && self.externalStarted {
// print("start animation")
self.startTime = Date()
self.internalStarted = true
self.angle += 90
}
}
return VStack{
Rectangle()
.fill(Color.red)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: angle))
.animation(internalStarted ? animation : .default)
Spacer()
}
}
}
![](https://habrastorage.org/webt/e9/ci/ge/e9cigegcoxid1l9skuw2qxle238.gif)
Основная идея в том, чтобы в момент старта анимации засечь время. Спустя сколько-то времени, когда нам приспичит анимацию остановить, мы сможем проверить, сколько полных циклов анимации прошло, и предположить, какой угол поворота квадрата мы видим в данный момент. После этого, мы можем присвоить
@State
переменной угла поворота именно это значение.Для работы такого подхода, нам нужно отследить момент старта и окончания анимации. Init() не подойдет. Мы не можем обращаться к уже существующим
@State
параметрам для получения предыдущего времени старта анимации. Это фишка @State
переменных. Внутри init вы можете установить лишь начальное состояние этой переменной, но по окончании инициализации, если View уже существовала до init(), значение @State
переменных будет восстановлено.Поэтому тут реализована параметр let externalStarted (которым мы управляем извне), и внутренний параметр
@State
var internalStarted, с помощью которого мы управляем непосредственно анимацией.Не хватало только одного — какого-то модификатора, который бы проверял их соответствие и обновлял при необходимости, наподобие .onRecieve(), только чтобы отрабатывал при каждой отрисовке. И тут я подумал — ведь body и так вызывается для каждой отрисовке, почему бы прямо в нем не делать эту проверку?
Оказалось, что SwiftUI очень ругается, если в процессе отрисовки View менять значение
@State
переменных, выдает Modifying state during view update, this will cause undefined behavior.и блокирует такое изменение. Тогда я пошел на грязный хак, и использовал DispatchQueue.main.async. Но давайте разберемся, почему же это — грязный хак, и почему так делать не следует никогда?
На самом деле, проблема вот в чем. Если мы напишем внутри body какую-то очевидную глупость вроде i += 1, где i это какая-то
@State
переменная, то мы получим бесконечный цикл. В момент рендера мы делаем View в памяти не актуальной — ведь мы изменили исходные данные для отрисовки. Значит, сразу по окончании отрисовки наша View попадет в очередь на повторный рендер, но и тогда мы тут же снова сделаем ее неактуальной. Мы своими руками создаем бесконечный цикл. Асинхронный вызов в данном случае вообще ничего не меняет. Он лишь немного сдвигает инициирование очередного витка на момент «сразу после рендера». Таким образом, асинхронный вызов не решает проблему, а лишь маскирует ее, не давая SwiftUI ткнуть нас в нее носом. Это как в автомобиле лампочку «check engine» на приборке обрывать.С одной стороны, да что тут такого? В любом языке программирования есть куча способов выстрелить себе в ногу, если ты не понимаешь что делаешь. Но с другой стороны, этот подход — вообще не то, как должен работать SwiftUI. Работа с View в рантайме — плохо. Да, вы можете бить себя пяткой в грудь, гарантируя что вы всегда пишете правильный код, который никогда не попадет в бесконечный цикл… Но это как с не безопасным извлечением опционала — это просто плохая практика.
Как я уже говорил, в таких случаях следует использовать Combine, передавая новое значение с помощью PassthroughSubject. Но можно и обойтись обычным ObservableObject, подписавшись на его willChange событие.
struct AnimatedRectObservedObject: View{
@State var startTime: Date = Date()
@State var angle: Double = 0
@ObservedObject var animationHandler: AnimationHandlerTest
let animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
init(animationHandler: AnimationHandlerTest){
self.animationHandler = animationHandler
}
var body: some View{
VStack{
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.rotationEffect(Angle(degrees: angle))
.animation(animationHandler.isStarted ? animation : .default)
}.onReceive(animationHandler.objectWillChange){
let newValue = self.animationHandler.isStarted
if newValue == false{
let timePassed = Date().timeIntervalSince(self.startTime)
let fullSecondsPassed = Double(Int(timePassed))
let currentStage = timePassed - fullSecondsPassed
let newAngle = self.angle - 90 + currentStage * 90
withAnimation(.none){//not working:(((
self.angle = newAngle
}
}else {
self.startTime = Date()
self.angle += 90
}
}
.onAppear{
self.angle += 90
self.startTime = Date()
}
}
}
Спорный момент здесь — это использование непосредственно значений самого объекта в подписке на уведомление, что объект только будет изменен. Пока, в моих примерах, я имею уже измененный объект. Видимо, уведомления приходят с некоторым лагом. Но не могу гарантировать что так будет всегда, потому советую либо использовать самописные didChange уведомления, либо PassthroughSubject, передавая новое значение через него.
![](https://habrastorage.org/webt/wj/ou/nd/wjoundfihtxhnar7iucx7rrkvnm.gif)
Анимация изменения анимации
Как видно на гифке, анимация не останавливается мгновенно. Ей нужно некоторое время. Это происходит потому, что присваивая промежуточное значение угла, мы так же используем определенную анимацию — дефолтную.
.animation(animationHandler.isStarted ? animation : .default)
Это ведет к необычному поведению. Несмотря на относительную простоту всего того, что я описываю, под капотом остается куча нюансов по взаимодействию, чтобы картинка выглядела плавно всегда, даже если безалаберный программист чего-то не указал. Поэтому дефолтная анимация — это spring. Она больше всего подходит под имитацию законов физики. Причем это довольно короткая анимация, порядка 0.2 секунды. Если анимация перехода в новое состояние (вычисленный и указанный нами угло) не успевает красиво уложиться в дефолтные 0.2 секунды так, чтобы перейти от текущей скорости анимации с красивым замедлением до полной остановки, SwiftUI сама добавит еще один цикл бесконечной анимации, проигранный в ускоренном варианте.
![](https://habrastorage.org/webt/z0/b0/so/z0b0soljlzjqudxhek_irdxgyoy.gif)
Чтобы этого избежать, достаточно не использовать дефолтную анимацию. Для нижнего квадрата я отключил анимацию остановки вращения вот так:
.animation(animationHandler.isStarted ? animation : Animation.linear(duration: 0))
Либо, можно более точно рассчитывать требуемое значение параметра в момент остановки, но для этого нужно уже не примерно предполагать текущий момент анимации, а точно знать его. Я покажу ниже, как это можно сделать.
Движение нескольких волн одной анимацией
Вернемся к радужной анимации. Итак, у нас есть сами волны, давайте научим их двигаться. Первое что я хотел бы сделать — это запустить бесконечную анимацию. Я решил сделать следующим образом: у меня будет одна
@State
переменная, отвечающая за текущее положение анимации. При состоянии 0 первая волна будет в самом начале, а последняя в самом конце. Сами волны будут накладываться друг на друга. Таким образом, длина видимой части каждой волны будет зависеть от количества волн. Я реализовал это с помощью ZStack, в котором перечислены все волны, и собственным модификатором .wavePosition, внутри которого я вычисляю текущее положение каждой волны в данный момент анимации, и порядок их наложения друг на друга.
много кода
struct SharpWavePosition: AnimatableModifier {
let wave: WaveDescription
let animationHandler: AnimationHandler
var time: CGFloat
var currentPosition: CGFloat
public var animatableData: CGFloat {
get { time}
set {
self.time = newValue
let currentTime = newValue - CGFloat(Int(newValue))
self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime)
}
}
init(wave: WaveDescription, time: CGFloat, animationHandler: AnimationHandler){
self.wave = wave
self.time = time
self.animationHandler = animationHandler
self.currentPosition = 0
}
static func calculate(forWave: Int, ofWaves: Int, overTime: CGFloat) -> CGFloat{
let time = overTime - CGFloat(Int(overTime))
let oneWaveWidth = CGFloat(1) / CGFloat(ofWaves)
let initialPosition = oneWaveWidth * CGFloat(forWave)
let currentPosition = initialPosition + time
let fullRounds = Int(currentPosition)
var result = currentPosition - CGFloat(fullRounds)
if fullRounds > 0 && result == 0{
// at the end of the round it should be 1, not 0
result = 1
}
// print("wave \(forWave) in time \(overTime) was at position \(result)")
return result
}
func body(content: Content) -> some View {
let oneWaveWidth = CGFloat(1) / CGFloat(wave.totalWavesCount)
var thisIsFirstWave = false
if currentPosition < oneWaveWidth{
thisIsFirstWave = true
}
return
Group{
content
.offset(x: -wave.width + currentPosition * (wave.width + wave.gradientLength),
//to watch how waves move uncoment this
// y: CGFloat(self.waveInd * 20))
y:0)
.zIndex(-Double(currentPosition))
.transition(.identity)
.animation(nil)
if thisIsFirstWave{
content
.offset(x: wave.gradientLength, y: 0)
.zIndex(-2)
.transition(.identity)
.animation(nil)
}
}
}
}
extension View{
func positionOfSharp(wave: WaveDescription, inTime: CGFloat, animationHandler: AnimationHandler) -> some View {
return self.modifier(SharpWavePosition(wave: wave, time: inTime, animationHandler: animationHandler))
}
}
struct SharpRainbowView: View{
let waves: [SharpGradientBorder]
var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
//@ObservedObject
var animationHandler: AnimationHandler
@State var rainbowPosition: CGFloat = 0
init(animationHandler: AnimationHandler,
backgroundColor: Color = .clear
){
self.animationHandler = animationHandler
let bottomRadius = animationHandler.waveGeometry.bottomRadius
let topRadius = animationHandler.waveGeometry.topRadius
let gradientLength = animationHandler.waveGeometry.gradientLength
let rainbowColors = animationHandler.rainbowColors
guard var lastColor = rainbowColors.last else {fatalError("no colors to display in rainbow")}
var allWaves = [SharpGradientBorder]()
for color in rainbowColors{
let view = SharpGradientBorder(start: color,
end: lastColor,
bottomRadius: bottomRadius,
topRadius: topRadius,
gradientLength: gradientLength)
allWaves.append(view)
lastColor = color
}
self.waves = allWaves
}
var body: some View{
GeometryReader{geometry in
VStack{
ZStack{
ForEach(self.waves.indices, id: \.self){ind in
self.waves[ind]
.positionOfSharp(wave: WaveDescription(ind: ind,
totalWavesCount: self.waves.count,
width: geometry.size.width,
baseColor: self.waves[ind].end,
gradientLength: self.waves[ind].bottomRadius + self.waves[ind].topRadius),
inTime: self.rainbowPosition,
animationHandler: self.animationHandler)
.animation(self.animationHandler.isStarted ? self.animation : .linear(duration: 0))
}
}
// .clipped()
}
}
.onAppear(){
if self.animationHandler.isStarted{
self.rainbowPosition = 1
}
}
.onReceive(animationHandler.objectWillChange){
let newValue = self.animationHandler.isStarted
if newValue == false{
let newPosition = self.animationHandler.currentAnimationPosition
print("animated from \(self.rainbowPosition - 1) to \(self.rainbowPosition) stopped at \(newPosition)")
self.rainbowPosition = newPosition
}else {
self.rainbowPosition += 1
}
}
}
}
При первом отображении View на экране, мы меняем состояние с 0 на 1, что с учетом объявленной линейной анимации заставляет волны бесконечно бегать по экрану.
![](https://habrastorage.org/webt/bg/vr/ym/bgvrymyoubpekjmpgnsrtxxgco8.gif)
Когда край волны достигнет правого края, следующая ее позиция будет находится в самом начале, за краем экрана. Выглядеть это будет так, как будто из конца волны она просто исчезнет, обнажив фоновую подложку, чего мы хотели бы избежать. Именно для этой цели в модификаторе, в функции body() появился блок if{...}, в котором отрисовывается копия первой волны, пропавшей с экрана.
![](https://habrastorage.org/webt/si/6m/vt/si6mvtiimlv73kxy4c4imperxc4.gif)
Обратите внимание, что мне пришлось передавать геометрические характеристики каждой волны в виде параметров модификатора. Проблема была в том, что мне требуется переопределять z-index каждой волны исходя из текущей фазы анимации. Я мог бы извлечь ширину видимой области, использовав внутри модификатора GeometryReader{}, однако столкнулся с тем, что он блокирует изменение порядка наложения волн. Модификатор .zIndex() работает только в контексте первого ZStack{} контейнера вверх по иерархии View.
GeometryReader обрубает эту связь, и zIndex() перестает работать. Если до этого момента вы думали, что GeometryReader — это безобидный способ получить данные о размере текущей View — это не совсем так.
Анимация потока
Еще один важный момент, это понимание анимации встроенных модификаторов внутри ваших собственных модификаторов. В какой-то момент, каждая волна подходит к краю экрана, получая максимальный offset по горизонтали, а затем переходит в стартовое положение, получая минимальный offset. Это такое же изменение как и любое другое, и оно анимируется, даже внутри анимации. Ведь анимация — это просто очень частая передача значений в модификатор, и она тоже будет выполняться с анимацией, как бы странно это не звучало. Получается, что внутри анимации, передавая новое значение внутрь этого модификатора, мы лишь меняем конечную точку текущей анимации движения. Получается, что каждая волна, дойдя до края экрана, разворачивается и плывет обратно. Но до начала она тоже не доходит, а рано или поздно догоняет требуемую позицию, и тогда снова начинает двигаться слева направо.
![](https://habrastorage.org/webt/aj/xp/ky/ajxpkyp6nxn-e6ixzzrv7y-g-c4.gif)
Решить эту проблему можно вставив модификатор .animation(nil). Таким образом, я отключаю анимацию всех модификаторов, примененных выше по тексту. Это общая логика работы любого модификатора: он меняет то, что у него на входе, а на входе у нас результат работы всех предыдущих модификаторов.
Вообще говоря, здесь работает целая иерархия разных анимаций. Дефолтной считается та, которая будет использована для отображения изменений всех модификаторов, если только вы не указали иное для вашей View. У нас есть поток исполнения (допустим, это main), в рамках которого вызывается body. Будем считать, что у него есть параметр .animation, который проверяется каждый раз, когда какой-то модификатор получает новое значение. Если модификатор поддерживает анимацию (удовлетворяет протоколу AnimatableModifier), и для потока включена анимация (используется какая-то конкретная анимация, а не .none и не nil), то изменение будет анимировано. Именно это мы делаем, заключая какой-то код по изменению
@State
параметров в блок withAnimation{} — прописываем определенную анимацию в текущем потоке, а затем выполняем изменение какой-то @State
переменной. В этом случае withAnimation{}, это своего рода эквивалент транзакции, и все изменения выполненные в этой транзакции будут анимированы. В результате, внутри этого же потока запускается цикл трансляции этих изменений во все зависимые View, модификаторы этих View получают новое значение, и подписываются на получение промежуточных значений AnimatableData.Кроме того, есть элементы, на анимацию которых вы не можете повлиять. Например, позиция слайдера анимируется сама по себе, внутренними механизмами, и тут ничего не попишешь.
Таким образом, withAnimation() — это способ запустить анимацию по-умолчанию для выполняемого действия. Но для определенных View вы можете в явном виде указать собственную анимацию с помощью модификатора .animation(). В этом случае, модификаторы и формы, которые составляют контент, передаваемый в .animation() (все что перечислено в коде до применения .animation()) получают описанный вами тайминг, и игнорируют анимацию потока.
В своем модификаторе WavePosition, я использую .animation(nil) для того чтобы избавиться от встроенной анимации offset, дав таким образом указание игнорировать текущую анимацию потока.
Та же история и с концевой заглушкой. Напомню выводы из прошлой статьи. Фактически, у нас в памяти N структур-модификаторов WavePosition, по одной на каждую волну. И все они получают новое значение position по системному таймеру, вычисляя положение каждой волны в данный момент времени. Это значит, что у нас так же N концевых заглушек под каждую волну. Просто в каждый момент времени показывается только один из них, благодаря блоку if{}. Однако, этот же блок подкладывает нам свинью. Исчезновение и появление View также выполняются с анимацией потока. Это значит, что задействуется модификатор .transition для анимации появления и исчезновения View после изменения условия. Обычно, по-умолчанию используется .opacity, однако у меня почему-то вместо этого использовался .slide. Ни то ни другое мне не подходит, потому я просто отменил эту анимацию, используя .transition(.identity).
upd. Пока я готовил статью, вышел XCode 11.4, в котором, похоже, transition по-умолчанию переработали. По крайней мере, сейчас я без проблем закомментировал .transition(.identity) и не получил той проблемы, из-за которой мне пришлось его добавлять.
Тайминги анимации
И вот мы наконец подошли к самому интересному вопросу: а как заставить волны двигаться ускоренно? Как обычно, я зайду издалека.
На самом деле Animation — это описание скорости данной анимации. Маршрут из пункта А в пункт Б мы можем определить внутри самого модификатора, но SwiftUI сам решает, когда и какое именно значение AnimatableData передать в него. Делает он это с помощью тайминговой кривой. Предположим, пункт А мы возьмем за начало координат, а пункт Б отметим как точку с координатами (1; 1). По горизонтали мы будем отмечать прошедшее время (движение из пункта А в пункт Б занимает ровно 1 единицу времени). По вертикали — пройденное расстояние в долях единицы. При использовании линейной анимации, мы получим прямую. Если же мы хотим получить движение хоть чуть-чуть похожее на настоящее, то вначале нам нужно потратить немного времени на разгон, а в конце на торможение. Вот тут можно поиграться с разными вариантами, рисуя свою кривую и сравнивая ее анимацию движения со стандартными.
В SwiftUI, для различных типов анимации используются кривые Безье. В любом случае она должна начинаться в (0;0) и заканчиваться в (1;1). Кривизна линии определяется контрольными точками.
Вложенная тайминговая кривая внутри линейной бесконечной анимации
Так вот, что нам нужно сделать, чтобы добиться ускоренного движения каждой волны (easeIn) внутри линейной зацикленной анимации?
Самый отбитый вариант, который я смог придумать — это реализовать свою собственную тайминговую кривую, и преобразовать с ее помощь линейное течение времени в подобие .easeIn. На этом примере можно будет очень хорошо разобраться, как же это работает.
Идея простая. Мы сделаем свой класс, в основе которого будет path — кривая Bezier с помощью которой мы будем определять расстояние из пункта А в пункт Б, которое нужно показать в каждый определенный момент времени. На самом деле именно так и работает анимация в SwiftUI, да и в любом другом фреймворке с подобным функционалом. С помощью тайминговой кривой, реальные секунды и миллисекунды (которые чаще всего текут, все же, линейно) превращаются в доли расстояния(точнее, доли анимируемого отрезка — вектора AnimatableData, который представляет собой выполненное изменение).
Вот моя первая реализация:
func getActualPosition(of position: CGFloat) -> CGFloat{
let correctPosition = max(min(position, 1), 0) / duration
let trimmingCurve = TimingCurve.superEaseInPath
if correctPosition < 0.0000001{
let reversedCurve = Path(UIBezierPath(cgPath: trimmingCurve.cgPath).reversing().cgPath)
//trim to start point is impossible, so reverce the curve and get last point
guard let point = reversedCurve.currentPoint else{fatalError("cant get current timing curve start point")}
return point.y
}
guard let point = trimmingCurve.trimmedPath(from: 0, to: correctPosition).currentPoint else{fatalError("cant get current timing curve point at \(position)")}
return point.y * self.duration
}
Как видно, я немного схалтурил. По определению, я должен найти точку на кривой, соответствующую данному значению X, и вернуть ее Y. Мне не удалось найти какого-то стандартного встроенного метода для решения этой задачи, или популярного паттерна, потому я решил адаптировать для этих целей имеющийся метод trim(). Я решил что я вполне могу немного пересмотреть подход, и получить искомую точку с помощью получения части пути, соответствующей доли пройденного расстояния. Для нуля этот метод вернет 0, для 1 вернет 1, ну и в середине, наверное, все тоже будет примерно правильно.
Я отдавал себе отчет, что такой подход даст не слишком уж точный результат, однако вся острота проблемы всплыла позже, когда попытался воспроизвести точно такую же анимацию стандартным способом. Я удивился, насколько же она отличается, и решил проверить на графике, насколько далеко от исходной кривой будут точки, полученные таким образом. Результат меня не порадовал.
![](https://habrastorage.org/webt/fl/ed/s-/fleds-cdj4f8ellnui32gn-kiyk.png)
Мало того, что точки находятся далеко от требуемой кривой, так и сам характер кривой искажается. Появляется лишний перегиб, что мне вообще не нужно. В итоге, мне все же пришлось придумывать относительно точный способ поиска точки на кривой Безье, зная только одну координату:
Мне пришлось хранить в памяти множество отрезков, концы которых лежат на заданной кривой. Поиск Y по данной X при таком подходе представляет собой поиск соответствующего отрезка, его деление пополам при необходимости, до тех пор, пока одна из границ такого отрезка не будет лежать достаточно близко к искомой точке. В этом случае, мы находим точку с требуемой координатой X, лежащую на данном отрезке, и возвращаем ее Y. Если вам известны более простые пути нахождения точки, лежащей на произвольной кривой Безье, по одной заданной координате — напишите в комментариях. Думаю это может быть полезно многим.
В итоге, точки лежат довольно близко к графику:
![](https://habrastorage.org/webt/wx/bk/qi/wxbkqitda0fmf516tl1vjarqgoq.png)
Я сделал singlton объект для создания timing кривой по контрольным точкам и кэширования массива отрезков, с помощью которых происходит поиск Y. Таким образом, все что мне нужно для поиска конкретной точки на кривой — найти отрезок, внутри которого будет лежать эта точка, поделить его попалам до тех пор, пока один из концов отрезка не будет достаточно близко к этой точке, для попадания в заданную погрешность, и затем, линейно интерполировать значение Y по заданной X, подменив часть кривой этим отрезком. Не утверждаю что это лучший способ, и что он будет работать во всех случаях. Опять же, пишите в комментариях, если знаете способы лучше.
С помощью такого инструмента, трансформация линейной анимации в нелинейную становится несложной. Все что нужно — описать кривую по двум контрольным точкам, и пересчитать положение на отрезке согласно этой кривой. Если требуется анимация различной длительности, мы все равно используем единичную кривую, используя соответствующий коэффициент для X-координаты.
self.timing = TimingCurve.superEaseIn(duration: 1)
let animatedPosition = timing.getY(onX: currentPosition)
Вот так в итоге выглядит анимация вместе с нашей тайминговой кривой:
![](https://habrastorage.org/webt/vf/f0/fe/vff0fe07bgvlvqhhuok5vy_w8ga.gif)
За полным кодом добро пожаловать на гитхаб, смотреть файл TimingCurveView.
Разобравшись с этим примером, вы на 100% поймете как устроены тайминги анимации. Вы, кстати, сможете использовать это знание, ведь SwiftUI позволяет создавать свою тайминговую кривую по контрольным точкам с помощью функции timingCurve(), и использовать ее как любую другую анимацию.
Transition
Теперь, мы имеем сносную бесконечную анимацию. Осталось только придумать, как она должна включаться и выключаться.
Идея анимации появления заключается в том, чтобы блок с анимацией просто возникал на месте уже включенной, но с него как бы отдергивалась шторка, как портьера в театре. Если синхронизировать скорость движения шторки со скоростью движения волн внутри анимации, и в самой шторке сделать градиент от фонового цвета к цвету текущей набегающей волны, мы получим визуальный эффект, как будто эта шторка — это первая накатывающая волна. Для синхронизации скорости движения, мы просто используем ту же timingCurve что и для анимации движения волны. Благо, мы теперь знаем как это сделать.
let animation = Animation.timingCurve(Double(TimingCurve.control.point1.x),
Double(TimingCurve.control.point1.y),
Double(TimingCurve.control.point2.x),
Double(TimingCurve.control.point2.y),
duration: 1)
Animation.timingCurve(x:y:) позволяет задать свой тайминг анимации на основе контрольных точек кривых Безье. На выходе мы получим полноценный объект Animation, как например привычный .linear(duration: 1), который можно использовать без ограничений. А учитывая, что я для анимации волн использую ровно те же контрольные точки, анимация будет синхронной.
можно было бы поудобнее организовать код...
Но я ленивая жопа.
По идее, можно было более корректно реализовать предопределенные статические объекты для тайминговых кривых, описание их контрольных точек, сделать для каждой из таких кривых индивидуальное хранилище интерполированных отрезков… но мне уже стало лень, это все таки учебная задача, а не библиотека для распространения. Если кто-то захочет допилить, и взять эти механизмы в публичную библиотеку — я возражать не буду.
По идее, можно было более корректно реализовать предопределенные статические объекты для тайминговых кривых, описание их контрольных точек, сделать для каждой из таких кривых индивидуальное хранилище интерполированных отрезков… но мне уже стало лень, это все таки учебная задача, а не библиотека для распространения. Если кто-то захочет допилить, и взять эти механизмы в публичную библиотеку — я возражать не буду.
Отключение анимации можно сделать точно так же, только шторка должна наползать на блок с анимацией, постепенно закрывая его.
Первая проблема возникла в тот момент, когда мне потребовалось получить текущее состояние анимации, чтобы подобрать цвет шторки. Для этого я создал ObservableObject, который передаю внутрь модификатора, и который я изменяю внутри сеттера AnimatableData. Здесь важно понимать, что изменение @Published свойства 60 раз в секунду — это совсем не то что вам нужно. Это как изменять
@State
переменные внутри блока body. Нам не нужно инициировать что-либо при изменении состояния анимации. Но вот если нам потребуется, мы сможем узнать состояние анимации в любой момент. public var animatableData: CGFloat {
get { time}
set {
if animationHandler.isStarted{
self.time = newValue
if self.time != self.animationHandler.currentAnimationPosition{
self.animationHandler.currentAnimationPosition = self.time
}
}
let currentTime = newValue - CGFloat(Int(newValue))
self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime)
if currentPosition < 0.01{
animationHandler.currentWaveBaseColor = wave.baseColor
}
}
Именно поэтому я не подписывал SharpRainbowView на отслеживание изменений AnimationHendler:
//@ObservedObject
var animationHandler: AnimationHandler
Для иллюстрации работы этих шторок, я включу отображение того, что происходит за границами View, закомментировав .clipped(). Кроме того, я сдвину шторки чуть выше анимации, для наглядности. Теперь стало понятнее, неправда ли?
![](https://habrastorage.org/webt/6s/uf/0k/6suf0klfghhgxj95qfnrxd29zpe.gif)
Шторки должны были быть короткими, чтобы путь проходимый каждой волной и шторкой совпадал (иначе получится рассинхрон по анимации), но при этом, она успела полностью пропасть с экрана. При этом, шторка устроена как градиент из трех цветов. Это хорошо видно по замыкающей шторке. Это по причине длинных хвостов, которые мне так понравились в начале.
Все дело в том, что первая волна представляет собой градиент последнего и первого цвета переданного массива. Ни один из них может не совпадать с цветом фона. Поэтому, и возникла необходимость в шторках вообще. Сделать шторку двухцветной, от фонового цвета к базовому цвету первой волны, мне помешала длина шторки. Она просто не перекрывает длинный хвост первой волны, и получается граница одного цвета, а хвост — другого. Поэтому мне и пришлось делать шторку аж трехцветной — сначала от фона к цвету последней волны, а затем от цвета последней волны к первой. Тогда хвост первой волны хорошо стыкуется с границей шторки.
Примерно те же причины заставили сделать замыкающую шторку так же трехцветной. Иначе граница смотрелась неестественной. Но в результате, первая «волна» (а теперь мы знаем, что на самом деле это шторка, описанная в Transition.truncate) получилась короткой. И хоть она движется полностью синхронно с первой волной, т.е. ускоренно, а не линейно, ее ширина не меняется со временем, хотя ширина последующих волн визуально увеличивается. Это можно победить, динамически изменяя ширину шторки, но мне откровенно было уже лень. Давайте расценим это как домашнее задание для тех, кто хочет погрузиться в эти механизмы, и раскурить код (заранее прошу у таких людей прощения, там полно следов моих экспериментов, которые могут вас запутать).
Есть одна проблема. Если вы нажмете кнопку в неудачное время, шторка закрытия едва-едва прикроет очередную волну. Выглядит это не очень. Решить проблему можно путем введения небольшой задержки сокрытия View с анимацией. У нас есть способ узнать текущее положение анимации, нам нужно всего лишь немного отложить начало transition, и последняя волна всегда будет полностью показана.
Text("toggle animation").onTapGesture {
withAnimation(){
var delay: Double = 0
if self.isShown{
let waveChangeTime: Double = Double(1) / Double(self.animationHandler.rainbowColors.count)
let currentTime = Double(self.animationHandler.currentAnimationPosition)
let wavesPassed = Double(Int(currentTime / waveChangeTime))
delay = (wavesPassed + 1) * waveChangeTime - currentTime
delay = max(delay - 0.05, 0)
print("currentTime: \(currentTime); delay \(delay)")
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.isShown.toggle()
}
}
}
Можно конечно еще и шторку сделать переменной ширины, чтобы создать иллюзию разной скорости движения границы между фоном и шторкой, и шторкой и первой волной, а то сейчас первая волна смотрится немного неестественно, но мне уже откровенно лень заморачиваться.
Transition любит подкладывать свинью
Вообще, анимации появления и исчезновения, как по мне, очень прикольный инструмент. Но похоже, эта концепция нова, и нормально обрабатывается далеко не всеми инструментами.
Так, .rotation3DEffect() модификатор вполне успешно переворачивает View с какими-то сложными анимациями внутри, но вот .transition она переворачивать не умеет. Так обидно было написать лаконичный код, увидеть как твои волны зеркально разбегаются от центра экрана, а потом увидеть, что на левой View шторки так и бегают слева направо. Пришлось писать кучу бойлерплейта, дабы transition модификатор умел работать в обе стороны.
![](https://habrastorage.org/webt/pn/3c/fg/pn3cfg8f6zsersnfgdv5_ixrqu8.gif)
Еще, drowingGroup() модификатор, с помощью которого вы можете подключить Metal переложить на GPU отрисовку ZStack с большим количеством вложенных View, особенно View с градиентами, не умеет в transition. Он не понимает описанную вами анимацию появления и исчезновения и заменяет ее какой-то своей.
А как же мы прячем статус-бар?
Очень просто. В SwiftUI есть модификатор .statisBar(hidden:). Вот только api для управлением transition для статус-бара SwiftUI не предоставляет. Для этого нам придется воспользоваться возможностями UIKit. В файле SceneDelegate используется UIHostingController для превращения SwiftUI View в UIKit ViewController. Именно на этом этапе удобнее всего использовать какие-то глобальные функции UIKit, как то работа со статусбаром, или отключение системных жестов связанных с краем экрана (preferredScreenEdgesDeferringSystemGestures). Вы можете наследоваться от UIHostingController, переопределив значения каких-то системных свойств, и использовать этого наследника для передачи своей View в rootViewController. К сожалению, статусбар может принимать только ограниченное число transition: .fade, .slide и .none. По-умолчанию используется fade, и он сюда подходит лучше всего, так что оставим как есть. Будем надеяться, что этот функционал все же будут расширять.
Для того чтобы синхронизировать скрытие и появление статусбара с движением волны я создал отдельную View, внутри которой с паузой меняю значение этого параметра:
struct StatusBarHider: View{
var isShown: Bool
@State var internalIsShown = true
var body: some View{
if isShown == false && self.internalIsShown == true{
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7){
self.internalIsShown = self.isShown
}
}else if isShown == true && self.internalIsShown == false{
DispatchQueue.main.async(){
self.internalIsShown = self.isShown
}
}
return Spacer()
.statusBar(hidden: internalIsShown)
.animation(Animation.linear(duration: 0.3))
}
}
По сути, здесь формируется простой Spacer(), который мне и так был нужен, но на него сверху накручен модификатор statusBar. Вообще, конечно, со стороны Apple было нелогично выводить этот функционал в модификаторы, т.к. это не относится к конкретной View, которую ты модифицируешь, но, видимо, просто не придумал как сделать это лучше. Куда интереснее другое.
Здесь используется тот же грязный хак, что мы рассматривали ранее: внутри body я выполняю какие-то действия в рантайме, меняя при этом значение
@State
переменной. Логика в целом та же, есть параметр структуры, который передается извне, и @State
переменная, которая модифицируется спустя какое-то время, вызывая исчезновение или появление статусбара. В данном случае, этот хак мне понадобился потому, что управление анимацией статусбара — это функционал UIKit, который еще довольно плохо проработан в SwiftUI. По идее, я бы прикрутил анимацию с отложенным стартом к модификатору .statusBar(hidden:), но это не работает. Анимация скрытия и появления статусбара фиксирована, и не подлежит изменению со стороны SwiftUI.
На самом деле, мне ничего не мешало выполнить то же самое изменение асинхронно с нужной задержкой не внутри этой View, а в родительской, еще при нажатии кнопки пользователем — но оставим это как памятник моим попыткам разобраться что к чему (и еще одним поводом рассказать, что так делать не следует).
А как запихнули размеры и положение статусбара в @Environment?
@Environment (не путать с @EnvironmentObject) — это обертка, дающая доступ к фиксированному перечню переменных, отражающих окружение нашего приложения. Например, ориентацию экрана, или цветовую тему ОС. Эта же обертка позволяет вашим view быть подписанными на изменение этих параметров.
Этот перечень можно расширить. Я посчитал, что иметь в @Environment доступ к размеру и положению статусбара — это было бы правильно. Вот как я это сделал:
struct StatusBarFrame: EnvironmentKey {
static var defaultValue: CGRect {
CGRect()
}
}
extension EnvironmentValues {
var statusBarFrame: CGRect{
get {
return self[StatusBarFrame.self]
}
set {
self[StatusBarFrame.self] = newValue
}
}
}
Я создал структуру со статическим default значением. Затем, я расширил системную структуру EnvironmentValues, добавив в нее свое вычислимое свойство, геттер и сеттер которого ссылаются на созданный мной тип. Здесь используется именно тип, поскольку в EnvironmentValues реализован сабскрипт, в который ты передаешь тип, и получаешь хранимое значение, соответствующее этому типу. Не значение этого типа, а значение, хранимое в условном словаре, где ключом является сам тип.
Доступ к значению в @Environment осуществляется с помощью keyPath \.statusBarFrame. Например, для для передачи environment-значения всем view вниз по иерархии:
.environment(\.statusBarFrame, statusBarFrame)
И в самих View для извлечения значения из хранилища:
@Environment(\.statusBarFrame) var statusBarframe: CGRect
Кстати, для работы со статусбаром в объекте UIWindowScene в IOS 13 появился реквизит statusBarManager. Из него можно вытянуть некоторые параметры. А вот управлять ими теперь нельзя. Насколько я понял, раньше можно было получить доступ к ViewController-у статусбара, и добавить в него subView. Видимо, лавочку прикрыли.
Вообще говоря, я бы перенес функционал модификатора .statusBar(hidden:) именно сюда, тут он был бы более уместен, как по мне. Думаю рано или поздно, у разработчиков дойдут до этого руки.
Заключение
Что же, думаю на этом увлекательном примере экскурс в Анимацию средствами SwiftUI можно считать завершенным. На простых примерах я рассказал об основных инструментах анимации, рассказал как именно они работают под капотом, как их следует использовать и почему именно так. На более сложных примерах я показал типичные трудности, с которыми вы можете столкнуться и пути их преодоления.
Получился ли мой код, по сравнению с примером из упомянутой статьи, чище, понятнее, короче или может быть, его легче поддерживать? Пожалуй что нет. Потребляет ли он меньше ресурсов? Увы, тоже нет. Возможно, мой подход более универсален, но это не точно. Однако есть одна задача, которую он точно выполняет лучше. Он намного глубже иллюстрирует работу SwiftUI под капотом. С его помощью, вы сможете разложить анимацию по полочкам в своей голове и свободно применять в ваших приложениях.
SwiftUI активно пилится. Даже сейчас, две недели спустя написания кода к первой статье, сейчас, проверяя работу кода на текущей версии XCode, я вижу как некоторые баги, которые я вынужден был обходить, уже пофиксили. Поэтому я со статьей немного задержался. Кое что пришлось вырезать за неактуальностью, а все остальное — перепроверять. И это радует. SwiftUI — это определенно будущее нативной IOS разработки, и это очень познавательно, стоять у его истоков, наблюдать как он развивается.
Послесловие
В процессе подготовки материалов для данной статьи я и в своей голове очень многое разложил по полочкам. Выяснил множество нюансов, о которых не подозревал, когда только брался за ее написание. Поэтому я призываю всех, кто уже имеет опыт разработки в SwiftUI — не стесняйтесь, делитесь своим опытом и знаниями. Это очень полезно и для сообщества, и для полноты ваших собственных знаний. Это круче чем метод утенка, тут еще и ответ формулировать нужно. Главное не лениться искать такой ответ, чтобы его не стыдно было пацанам показать.