Реалізація анімації Skeleton Loading у мобільних додатках
Skeleton loading показує заповнювачі форми контенту під час завантаження даних. Сірі прямокутники, іноді з shimmer або анімацією пульсування, скорочують сприйняту тривалість очікування — користувачі відразу бачать структуру екрана замість білого екрану зі спінером.
Правильний skeleton — це не просто «сірі блоки» — його форма точно відповідає майбутньому контенту, shimmer рухається в одному напрямку по всьому екрану (а не окремо в кожному блоці), а перехід до реального контенту плавний.
Android: Shimmer через Facebook бібліотеку або кастомний drawable
Найпростіший підхід використовує бібліотеку com.facebook.shimmer:shimmer:0.5.0:
<com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/shimmerContainer"
app:shimmer_duration="1200"
app:shimmer_angle="20"
app:shimmer_base_alpha="0.7"
app:shimmer_highlight_alpha="1.0">
<!-- Layout-копія реального контенту з заповнювачами -->
<include layout="@layout/skeleton_user_card" />
</com.facebook.shimmer.ShimmerFrameLayout>
У коді:
shimmerContainer.startShimmer()
// При завершенні завантаження:
shimmerContainer.stopShimmer()
shimmerContainer.visibility = View.GONE
realContentView.visibility = View.VISIBLE
Перевага підходу: shimmer синхронізовано по всіх skeleton блоках через один ShimmerFrameLayout. Недолік: зайва залежність; ShimmerFrameLayout перераховує bounds при кожному кадрі — на складних макетах це помітно.
Кастомний варіант — AnimatedVectorDrawable з анімацією градієнта як фону:
<!-- res/drawable/skeleton_shimmer.xml -->
<animated-vector xmlns:android="..." xmlns:aapt="...">
<aapt:attr name="android:drawable">
<vector android:width="400dp" android:height="50dp" android:viewportWidth="400" android:viewportHeight="50">
<path android:fillType="evenOdd"
android:pathData="M0,0 L400,0 L400,50 L0,50 Z"
android:fillColor="#E0E0E0" />
</vector>
</aapt:attr>
<!-- animator для зміщення градієнта -->
</animated-vector>
У Compose використовуйте InfiniteTransition для shimmer:
@Composable
fun ShimmerBox(modifier: Modifier = Modifier) {
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f),
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = FastOutSlowInEasing),
),
label = "shimmer_translate"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnim - 500f, 0f),
end = Offset(translateAnim, 0f)
)
Box(modifier = modifier.background(brush, RoundedCornerShape(4.dp)))
}
Shimmer через Brush.linearGradient зі змінюваними start/end — це GPU-операція через graphicsLayer, яка не викликає перекомпозицію контенту.
iOS: UIKit та SwiftUI
У UIKit використовуйте CAGradientLayer з CABasicAnimation:
func addShimmerAnimation(to view: UIView) {
let gradientLayer = CAGradientLayer()
gradientLayer.frame = CGRect(x: -view.bounds.width, y: 0,
width: view.bounds.width * 3, height: view.bounds.height)
gradientLayer.colors = [
UIColor.systemGray5.cgColor,
UIColor.systemGray6.cgColor,
UIColor.systemGray5.cgColor
]
gradientLayer.locations = [0, 0.5, 1]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
view.layer.mask = gradientLayer
let animation = CABasicAnimation(keyPath: "position.x")
animation.fromValue = -view.bounds.width
animation.toValue = view.bounds.width * 2
animation.duration = 1.2
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
gradientLayer.add(animation, forKey: "shimmerAnimation")
}
CABasicAnimation на CALayer виконується повністю на render thread — головний потік не бере участі в кожному кадрі анімації.
У SwiftUI використовуйте TimelineView (iOS 15+) або withAnimation + @State:
struct SkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
Rectangle()
.fill(LinearGradient(
gradient: Gradient(colors: [Color(.systemGray5), Color(.systemGray6), Color(.systemGray5)]),
startPoint: .init(x: phase - 0.5, y: 0.5),
endPoint: .init(x: phase + 0.5, y: 0.5)
))
.onAppear {
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
phase = 2.0
}
}
}
}
Перехід від Skeleton до контенту
Різке з'явлення контенту на місці skeleton — це грубо. Використовуйте плавний crossfade:
// Compose
AnimatedContent(
targetState = isLoading,
transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }
) { loading ->
if (loading) SkeletonCard() else RealCard(data = data)
}
// SwiftUI
if isLoading {
SkeletonCard()
.transition(.opacity)
} else {
RealCard(data: data)
.transition(.opacity)
}
// withAnimation(.easeInOut(duration: 0.3)) { isLoading = false }
Терміни
Skeleton для одного екрана (список або деталь) з shimmer анімацією: 1 день. Компонентна система skeleton-ів для всього додатка з перехідними анімаціями: 1–2 дні. Вартість розраховується індивідуально.







