Как я сделал универсальный Skeleton‑View с shimmer‑эффектом в SwiftUI

от автора

Привет! Я — iOS‑разработчик, и недавно в своём приложении столкнулся с задачей: нужно было красиво показывать placeholder‑загрузку интерфейса. Думал использовать стандартный .redacted — но он неудобен: нет анимации, мало кастомизации. Либо подгружать тяжелую библиотеку вроде SwiftUI‑Shimmer. Решил: сделаю свой легковесный и гибкий подход — и расскажу вам, как это получилось.


Почему не .redacted и не библиотека

  • .redacted(reason: .placeholder) прост, но выглядит скучно, невозможно настроить форму или shimmer.

  • Библиотеки дают красивый shimmer, но добавляют лишний вес и зависимости. Для проекта это был лишний overhead.

Мне хотелось:

  1. Использовать кастомные формы (например, аватар, текст, кнопка), а не лишь прямоугольник.

  2. Управлять цветом, углами, скоростью.

  3. Минимальный код без внешних зависимостей.


Как работает  .skeleton(isLoading:)

extension View {     func skeleton<S>(_ shape: S? = nil as Rectangle?, isLoading: Bool) -> some View where S: Shape {         guard isLoading else { return AnyView(self) }          let shapeView: AnyShape = shape.map(AnyShape.init)             ?? AnyShape(RoundedRectangle(cornerRadius: 20))          return AnyView(             self                 .opacity(0)                 .overlay(                     shapeView                         .fill(Color.gray.opacity(0.3))                         .shimmering()                 )         )     }      func shimmering() -> some View {         modifier(ShimmeringModifier())     } }
  • Если isLoading == false — возвращаем оригинальный View.

  • Иначе — делаем прозрачным контент, накладываем placeholder‑форму с shimmer‑эффектом.

  • Кастомная форма (Circle(), RoundedRectangle, свой Shape) — легко менять.

Реализация shimmer‑анимации

struct ShimmeringModifier: ViewModifier {     func body(content: Content) -> some View {         TimelineView(.animation) { timeline in             let phase = CGFloat(timeline.date.timeIntervalSinceReferenceDate                                 .truncatingRemainder(dividingBy: 1))             content.modifier(AnimatedMask(phase: phase))         }     } }  struct AnimatedMask: AnimatableModifier {     var phase: CGFloat     var animatableData: CGFloat { get { phase } set { phase = newValue } }      func body(content: Content) -> some View {         content.mask(GradientMask(phase: phase).scaleEffect(3))     } }  struct GradientMask: View {     let phase: CGFloat      var body: some View {         GeometryReader { geo in             LinearGradient(gradient: Gradient(stops: [                 .init(color: .white.opacity(0.1), location: phase),                 .init(color: .white.opacity(0.6), location: phase + 0.1),                 .init(color: .white.opacity(0.1), location: phase + 0.2),             ]), startPoint: .leading, endPoint: .trailing)             .rotationEffect(.degrees(-45))             .offset(x: -geo.size.width, y: -geo.size.height)             .frame(width: geo.size.width * 3,                    height: geo.size.height * 3)         }     } }
  • TimelineView обеспечивает плавную циклическую анимацию.

  • AnimatedMask управляет фазой анимации с помощью AnimatableModifier.

  • GradientMask рисует диагональный градиент, создающий эффект светящегося слоя.

Пример в действии

struct SkeletonPreview: View {     @State private var isLoading = true      var body: some View {         VStack(spacing: 16) {             RoundedRectangle(cornerRadius: 8)                 .frame(height: 20)                 .skeleton(isLoading: isLoading)              Circle()                 .frame(width: 50, height: 50)                 .skeleton(Circle(), isLoading: isLoading)              Button("Toggle") {                 isLoading.toggle()             }         }         .padding()     } }

Нажимайте на кнопку — и увидите: скелетоны исчезают, когда данные загружены.

Итоги

Я столкнулся с проблемой загрузочного UI — и решил её сам: написал свой универсальный .skeleton() + .shimmering().

✔️ Минималистичный

✔️ Гибкий (любой Shape, настройки)

✔️ Без сторонних зависимостей

Этот подход уже используется в моём приложении, работает стабильно и приятно. Думаю, он будет полезен знакомым iOS‑разработчикам — да и вам пригодится. Код можно взять и сразу внедрить.


ссылка на оригинал статьи https://habr.com/ru/articles/934756/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *