iOS
Yesterday

Чиним дрейф лоадера в 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?