Чиним дрейф лоадера в SwiftUI
Давным-давно я сделал приложение с площадками для тренировок. Несколько дней назад владелец сервера для этого приложения провел "реформы", что привело к долгим загрузкам в мобильном приложении, и к отображению алертов с ошибками. Но самое некрасивое - это катающийся по экрану лоадер 😱
Косяк был в старом компоненте LoadingOverlayModifier:
@State private var isAnimating = false
Image(.loadingIndicator)
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
.animation(.linear(duration: 2.0).repeatForever(autoreverses: false), value: isAnimating)
.onAppear { isAnimating = true }Идея была простая: спиннер появился → onAppear включает флаг → SwiftUI анимирует поворот 0°→360°, и repeatForeverповторяет это вечно.
Суть проблемы
Проблема в том, как SwiftUI применяет модификатор .animation: он анимирует все свойства вьюхи, которые изменились в этой же транзакции, а не только угол. А в момент onAppear спиннер одновременно вставляется в ещё не устоявшийся layout — его позиция (центрирование) в этот момент тоже меняется: из «угла» в «центр». Repeat-анимация взяла оба изменения сразу:
- угол крутился как задумано;
- позиция ездила к центру 2 секунды — столько же, сколько один оборот;
- потом цикл повторов телепортировал позицию обратно, и всё начиналось заново.
Так получился бесконечный цикл «езда + сброс».
Баг потенциально задевал все экраны в приложении, где используется модификатор .loadingOverlay(...) , но бросается в глаза именно в профиле - цикл успевает повториться много раз.
Исправление
Вращение переделано на TimelineView — без SwiftUI-анимаций вообще (доступно с iOS 15, чудеса):
TimelineView(.animation) { context in
let seconds = context.date.timeIntervalSinceReferenceDate
.truncatingRemainder(dividingBy: 2.0)
Image(.loadingIndicator)
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(.degrees(seconds / 2.0 * 360))
}TimelineView(.animation) — контейнер, который перерисовывает содержимое на каждом кадре отрисовки (синхронно с частотой экрана). Внутри каждый кадр просто вычисляется угол из текущего времени: прошло 0.5с из 2-секундного оборота → 90°, 1с → 180° и т.д.
Ключевая разница: здесь нет SwiftUI-анимации как таковой — нет .animation, нет транзакций, нечему «подхватывать» позицию. Каждый кадр — просто новая отрисовка с новым углом. Позиция спиннера теперь может измениться только реальным изменением layout, и происходит это мгновенно, без дрейфа.
Попутные плюсы: исчезли @State и onAppear (меньше состояния), а вращение привязано ко времени, а не к счётчику повторов — оно не рассинхронизируется.
Заключение
Починить сервер, конечно, не выйдет, но индикатор загрузки перестал скакать по экрану - это уже хорошо 👍
А вам доводилось использовать TimelineView?