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

Почему не .redacted и не библиотека
.redacted(reason: .placeholder) прост, но выглядит скучно, невозможно настроить форму или shimmer.
Библиотеки дают красивый shimmer, но добавляют лишний вес и зависимости. Для проекта это был лишний overhead.
Мне хотелось:
Использовать кастомные формы (например, аватар, текст, кнопка), а не лишь прямоугольник.
Управлять цветом, углами, скоростью.
Минимальный код без внешних зависимостей.
Как работает .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‑разработчикам — да и вам пригодится. Код можно взять и сразу внедрить.