Files
buzz/mobile/lib/shared/widgets/skeleton_reveal.dart
klopez4212 46e60206a4 fix(mobile): fill and stabilize content-aware loading placeholders (#8083)
Fill mobile loading views across the screen and use known image, video,
voice-note, and file shapes in message placeholders. Freeze each loading
cycle’s layout so arriving metadata cannot reshuffle the shimmer just
before content appears; preserve cached Home and forum content during
reconnects.

Validation: mobile analysis and full test suite passed (2,752 passed, 4
skipped), plus 3 unconfigured-push tests and file-size checks. Installed
and launched the loading changes on Pixel 10 before the latest main
merge. Full `just ci` was stopped during desktop checks due to low disk
space.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-05 11:26:48 -04:00

151 lines
4.8 KiB
Dart

part of 'skeleton.dart';
/// Stacks a skeleton and its content in the same slot, then reveals the content.
///
/// Entering the loading state is intentionally instant so reconnects do not
/// animate backwards. Leaving it cross-fades both layers while the skeleton
/// blurs out and the content sharpens over 400ms.
class SkeletonReveal extends HookWidget {
final bool loading;
final Widget skeleton;
final Widget content;
final bool shimmerEnabled;
/// Current status announced independently of the retained visual snapshot.
final String? loadingLabel;
final Key? loadingSemanticsKey;
/// Live viewport insets, such as a measured composer dock.
final EdgeInsets skeletonPadding;
/// Reports when loaded content has finished its reveal and layout frame.
final ValueChanged<bool>? onReadyChanged;
const SkeletonReveal({
required this.loading,
required this.skeleton,
required this.content,
this.shimmerEnabled = true,
this.loadingLabel,
this.loadingSemanticsKey,
this.skeletonPadding = EdgeInsets.zero,
this.onReadyChanged,
super.key,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final reveal = useAnimationController(
duration: _revealDuration,
initialValue: loading ? 0 : 1,
);
final previousLoading = usePrevious(loading);
// Keep one placeholder layout for the entire loading cycle, including
// its outgoing fade. Newly arrived metadata belongs to the content layer.
final loadingSkeleton = useRef(skeleton);
if (loading && previousLoading != true) {
loadingSkeleton.value = skeleton;
}
useEffect(() {
if (reducedMotion || previousLoading == null) {
reveal
..stop()
..value = loading ? 0 : 1;
} else if (loading) {
// Reset directly to the loading state. Only the reveal animates.
reveal
..stop()
..value = 0;
} else if (previousLoading) {
reveal.forward(from: 0);
}
return null;
}, [loading, reducedMotion]);
useEffect(() {
var active = true;
void report([AnimationStatus? _]) {
final ready = !loading && reveal.isCompleted;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (active) onReadyChanged?.call(ready);
});
WidgetsBinding.instance.ensureVisualUpdate();
}
reveal.addStatusListener(report);
report();
return () {
active = false;
reveal.removeStatusListener(report);
};
}, [loading, reveal, onReadyChanged]);
return AnimatedBuilder(
animation: reveal,
builder: (context, _) {
final progress = reveal.value;
final skeletonBlur = reducedMotion ? 0.0 : _revealBlur * progress;
final contentBlur = reducedMotion ? 0.0 : _revealBlur * (1 - progress);
return Stack(
fit: StackFit.expand,
children: [
Opacity(
key: const Key('skeleton-reveal-content'),
opacity: progress,
child: ImageFiltered(
enabled: contentBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: contentBlur,
sigmaY: contentBlur,
),
child: IgnorePointer(
ignoring: loading || progress < 1,
child: ExcludeFocus(
excluding: loading || progress < 1,
child: ExcludeSemantics(
excluding: loading || progress < 1,
child: content,
),
),
),
),
),
Opacity(
key: const Key('skeleton-reveal-placeholder'),
opacity: 1 - progress,
child: ImageFiltered(
enabled: skeletonBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: skeletonBlur,
sigmaY: skeletonBlur,
),
child: IgnorePointer(
child: ExcludeSemantics(
excluding: !loading,
child: Semantics(
key: loadingSemanticsKey,
label: loadingLabel,
liveRegion: loadingLabel != null,
excludeSemantics: loadingLabel != null,
child: Padding(
padding: skeletonPadding,
child: SkeletonShimmer(
enabled: loading && shimmerEnabled,
child: loadingSkeleton.value,
),
),
),
),
),
),
),
],
);
},
);
}
}