mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit carousel. While a thumbnail downloads, each cell showed the words "Loading image". Single images and the Flutter carousel already show a shimmering skeleton, so the native carousel stood out. Native carousel cells now show the same shimmer as Flutter's `SkeletonShimmer`: a fill in the theme's primary color at 10% opacity, with a lighter band sweeping across every two seconds. Flutter passes the primary color to the native view along with the brightness. With Reduce Motion on, the fill stays still. Once the thumbnail arrives the shimmer hides. If the thumbnail fails, the cell shows its existing "Image unavailable" message on the plain surface. ### Demo A synthetic private chat with a four-photo message, reopened on a cold launch. To make the loading state easy to see, both builds were patched locally to delay thumbnail delivery by 4 seconds (the patch is not part of this PR). Before (main): each cell shows the words "Loading image" until the thumbnail arrives. https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac After (this PR): each cell shimmers in the theme's primary color until the thumbnail arrives. https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc ### Validation - `just mobile-check` and the full `just mobile-test` suite pass. - Automated simulator run (iPhone 17 Pro, debug build): posted four photos to a synthetic private chat from the app's photo picker, relaunched, and opened the chat on both main and this branch. Before shows "Loading image"; after shows the shimmer, which gives way to the photo when it arrives. Limitations: light mode only, and the 4 second thumbnail delay was a local patch to make the state observable. Dark mode, Reduce Motion and the failed-thumbnail state were not exercised. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,71 @@ final class IosMediaCarouselFactory: NSObject, FlutterPlatformViewFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors Flutter's SkeletonShimmer: a translucent primary fill with a
|
||||
/// lighter band sweeping across it every two seconds.
|
||||
private final class CarouselShimmer: UIView {
|
||||
private let gradient = CAGradientLayer()
|
||||
var tint: UIColor = .systemBlue {
|
||||
didSet { updateColors() }
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isUserInteractionEnabled = false
|
||||
gradient.locations = [0, 0.34, 0.5, 0.66, 1]
|
||||
layer.addSublayer(gradient)
|
||||
updateColors()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(reduceMotionChanged),
|
||||
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
@objc private func reduceMotionChanged() { animate() }
|
||||
required init?(coder: NSCoder) { nil }
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
gradient.frame = bounds
|
||||
}
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
animate()
|
||||
}
|
||||
override var isHidden: Bool {
|
||||
didSet { animate() }
|
||||
}
|
||||
private func updateColors() {
|
||||
let base = tint.withAlphaComponent(0.1).cgColor
|
||||
let highlight = tint.withAlphaComponent(0.05).cgColor
|
||||
gradient.colors = [base, base, highlight, base, base]
|
||||
}
|
||||
private func animate() {
|
||||
gradient.removeAllAnimations()
|
||||
// The gradient spans the cell's width; its center travels from 0.75
|
||||
// widths before the cell to 0.75 widths past it, like the Flutter sweep.
|
||||
gradient.startPoint = CGPoint(x: -0.75, y: 0.5)
|
||||
gradient.endPoint = CGPoint(x: 0.25, y: 0.5)
|
||||
guard window != nil, !isHidden, !UIAccessibility.isReduceMotionEnabled else { return }
|
||||
let start = CABasicAnimation(keyPath: "startPoint")
|
||||
start.fromValue = CGPoint(x: -0.75, y: 0.5)
|
||||
start.toValue = CGPoint(x: 0.75, y: 0.5)
|
||||
let end = CABasicAnimation(keyPath: "endPoint")
|
||||
end.fromValue = CGPoint(x: 0.25, y: 0.5)
|
||||
end.toValue = CGPoint(x: 1.75, y: 0.5)
|
||||
let sweep = CAAnimationGroup()
|
||||
sweep.animations = [start, end]
|
||||
sweep.duration = 2
|
||||
sweep.repeatCount = .infinity
|
||||
sweep.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
sweep.isRemovedOnCompletion = false
|
||||
gradient.add(sweep, forKey: "shimmer")
|
||||
}
|
||||
}
|
||||
|
||||
private final class CarouselPhotoCell: UICollectionViewCell {
|
||||
let image = UIImageView()
|
||||
let status = UILabel()
|
||||
let shimmer = CarouselShimmer()
|
||||
var request: String?
|
||||
var cancelRequest: (() -> Void)?
|
||||
func cancelImage() {
|
||||
@@ -32,6 +94,7 @@ private final class CarouselPhotoCell: UICollectionViewCell {
|
||||
status.numberOfLines = 2
|
||||
status.font = .preferredFont(forTextStyle: .caption1)
|
||||
status.textColor = .secondaryLabel
|
||||
contentView.addSubview(shimmer)
|
||||
contentView.addSubview(image)
|
||||
contentView.addSubview(status)
|
||||
isAccessibilityElement = true
|
||||
@@ -41,6 +104,7 @@ private final class CarouselPhotoCell: UICollectionViewCell {
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
image.frame = contentView.bounds
|
||||
shimmer.frame = contentView.bounds
|
||||
status.frame = contentView.bounds.insetBy(dx: 12, dy: 12)
|
||||
}
|
||||
override func prepareForReuse() {
|
||||
@@ -49,6 +113,12 @@ private final class CarouselPhotoCell: UICollectionViewCell {
|
||||
image.image = nil
|
||||
status.text = nil
|
||||
}
|
||||
/// Shows the shimmer until the thumbnail resolves; a failed thumbnail gets
|
||||
/// the plain surface behind its message instead.
|
||||
func showLoading(_ loading: Bool) {
|
||||
shimmer.isHidden = !loading
|
||||
contentView.backgroundColor = loading ? .clear : .secondarySystemBackground
|
||||
}
|
||||
}
|
||||
|
||||
/// A native collection view owns drag tracking, deceleration and snapping.
|
||||
@@ -63,6 +133,7 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
|
||||
private var gap: CGFloat = 8
|
||||
private var trailingPadding: CGFloat = 16
|
||||
private var radius: CGFloat = 16
|
||||
private var skeletonTint: UIColor = .systemBlue
|
||||
private var rtl = false
|
||||
private var ready = false
|
||||
private var applying = false
|
||||
@@ -89,6 +160,7 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
|
||||
for case let cell as CarouselPhotoCell in self.collection.visibleCells where cell.request == id {
|
||||
let image = (args["bytes"] as? FlutterStandardTypedData).flatMap { UIImage(data: $0.data) }
|
||||
cell.image.image = image
|
||||
cell.showLoading(false)
|
||||
cell.status.text = image == nil ? "Image unavailable\nTap to open" : nil
|
||||
}
|
||||
result(nil)
|
||||
@@ -121,6 +193,14 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
|
||||
gap = args["gap"] as? Double ?? 8
|
||||
trailingPadding = args["trailingPadding"] as? Double ?? 16
|
||||
radius = args["radius"] as? Double ?? 16
|
||||
if let argb = args["skeletonColor"] as? Int {
|
||||
skeletonTint = UIColor(
|
||||
red: CGFloat((argb >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((argb >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(argb & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
rtl = args["rtl"] as? Bool ?? false
|
||||
collection.overrideUserInterfaceStyle = args["brightness"] as? String == "dark" ? .dark : .light
|
||||
// Reflect the strip and each cell, keeping logical indices in reading order.
|
||||
@@ -130,8 +210,9 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
|
||||
// The strip overflows the message gutter; restore that full inset at its end.
|
||||
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: trailingPadding)
|
||||
layout.invalidateLayout()
|
||||
for cell in collection.visibleCells {
|
||||
for case let cell as CarouselPhotoCell in collection.visibleCells {
|
||||
cell.contentView.layer.cornerRadius = radius
|
||||
cell.shimmer.tint = skeletonTint
|
||||
}
|
||||
if oldTrailingPadding != trailingPadding || oldStride != stride || oldGutter != gutter || oldLabels != labels || oldRTL != rtl {
|
||||
selected = min(selected, max(0, labels.count - 1))
|
||||
@@ -156,7 +237,8 @@ final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDat
|
||||
cell.contentView.layer.cornerRadius = radius
|
||||
cell.accessibilityLabel = "Open \(labels[indexPath.item])"
|
||||
cell.accessibilityValue = "\(indexPath.item + 1) of \(labels.count)"
|
||||
cell.status.text = "Loading image"
|
||||
cell.shimmer.tint = skeletonTint
|
||||
cell.showLoading(true)
|
||||
return cell
|
||||
}
|
||||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
|
||||
@@ -43,6 +43,8 @@ class IosMediaCarousel extends HookWidget {
|
||||
final channel = useState<MethodChannel?>(null);
|
||||
final latest = useRef(this)..value = this;
|
||||
final brightness = Theme.of(context).brightness.name;
|
||||
// Tints UIKit's loading shimmer like Flutter's SkeletonShimmer.
|
||||
final skeletonColor = Theme.of(context).colorScheme.primary.toARGB32();
|
||||
final labelSignature = Object.hashAll(labels);
|
||||
final rtl = Directionality.of(context) == TextDirection.rtl;
|
||||
final configuration = <String, Object>{
|
||||
@@ -53,6 +55,7 @@ class IosMediaCarousel extends HookWidget {
|
||||
'trailingPadding': trailingPadding,
|
||||
'radius': radius,
|
||||
'brightness': brightness,
|
||||
'skeletonColor': skeletonColor,
|
||||
'rtl': rtl,
|
||||
};
|
||||
useEffect(() {
|
||||
@@ -109,6 +112,7 @@ class IosMediaCarousel extends HookWidget {
|
||||
trailingPadding,
|
||||
radius,
|
||||
brightness,
|
||||
skeletonColor,
|
||||
rtl,
|
||||
labelSignature,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user