mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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>
151 lines
4.8 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|